diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-01-31 16:59:58 +0100 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-01-31 16:59:58 +0100 |
| commit | f927c68ddbe4dccbdd0174019d6028d30b0d8058 (patch) | |
| tree | af24ebffe8241ee0c6ec9c911f2fe15f54237bcc /addons | |
| parent | 24ba5ae276a7c25cdcd4d2db2e17e032cfa7e52a (diff) | |
| parent | cb8fefa8999bd0056c2191edbdbbb85918d221da (diff) | |
[MERGE] forward port branch 12.0 up to cb8fefa8999
Diffstat (limited to 'addons')
130 files changed, 1855 insertions, 353 deletions
diff --git a/addons/account/models/account.py b/addons/account/models/account.py index d7613478ce6..872bd8f13d0 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -67,12 +67,11 @@ class AccountAccount(models.Model): @api.constrains('user_type_id') def _check_user_type_id(self): data_unaffected_earnings = self.env.ref('account.data_unaffected_earnings') - for company in self.mapped('company_id'): - account_unaffected_earnings = self.search([ - ('company_id', '=', company.id), - ('user_type_id', '=', data_unaffected_earnings.id), - ]) - if len(account_unaffected_earnings) >= 2: + result = self.read_group([('user_type_id', '=', data_unaffected_earnings.id)], ['company_id'], ['company_id']) + for res in result: + if res.get('company_id_count', 0) >= 2: + account_unaffected_earnings = self.search([('company_id', '=', res['company_id'][0]), + ('user_type_id', '=', data_unaffected_earnings.id)]) raise ValidationError(_('You cannot have more than one account with "Current Year Earnings" as type. (accounts: %s)') % [a.code for a in account_unaffected_earnings]) name = fields.Char(required=True, index=True) @@ -228,7 +227,6 @@ class AccountAccount(models.Model): self.group_id = group @api.multi - @api.depends('name', 'code') def name_get(self): result = [] for account in self: @@ -801,7 +799,6 @@ class AccountJournal(models.Model): }).id @api.multi - @api.depends('name', 'currency_id', 'company_id', 'company_id.currency_id') def name_get(self): res = [] for journal in self: @@ -946,7 +943,6 @@ class AccountTax(models.Model): default = dict(default or {}, name=_("%s (Copy)") % self.name) return super(AccountTax, self).copy(default=default) - @api.depends('name', 'type_tax_use') def name_get(self): if not self._context.get('append_type_to_tax_name'): return super(AccountTax, self).name_get() diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index b8a478cfef8..d0632970bed 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -550,6 +550,8 @@ class AccountBankStatementLine(models.Model): :returns: The journal entries with which the transaction was matched. If there was at least an entry in counterpart_aml_dicts or new_aml_dicts, this list contains the move created by the reconciliation, containing entries for the statement.line (1), the counterpart move lines (0..*) and the new move lines (0..*). """ + payable_account_type = self.env.ref('account.data_account_type_payable') + receivable_account_type = self.env.ref('account.data_account_type_receivable') counterpart_aml_dicts = counterpart_aml_dicts or [] payment_aml_rec = payment_aml_rec or self.env['account.move.line'] new_aml_dicts = new_aml_dicts or [] @@ -570,10 +572,16 @@ class AccountBankStatementLine(models.Model): raise UserError(_('A selected move line was already reconciled.')) if isinstance(aml_dict['move_line'], int): aml_dict['move_line'] = aml_obj.browse(aml_dict['move_line']) + + account_types = self.env['account.account.type'] for aml_dict in (counterpart_aml_dicts + new_aml_dicts): if aml_dict.get('tax_ids') and isinstance(aml_dict['tax_ids'][0], int): # Transform the value in the format required for One2many and Many2many fields aml_dict['tax_ids'] = [(4, id, None) for id in aml_dict['tax_ids']] + + user_type_id = self.env['account.account'].browse(aml_dict.get('account_id')).user_type_id + if user_type_id in [payable_account_type, receivable_account_type] and user_type_id not in account_types: + account_types |= user_type_id if any(line.journal_entry_ids for line in self): raise UserError(_('A selected statement line was already reconciled with an account move.')) @@ -609,6 +617,8 @@ class AccountBankStatementLine(models.Model): payment = self.env['account.payment'] if abs(total)>0.00001: payment_vals = self._prepare_payment_vals(total) + if self.partner_id and len(account_types) == 1: + payment_vals['partner_type'] = 'customer' if account_types == receivable_account_type else 'supplier' payment = payment.create(payment_vals) # Complete dicts to create both counterpart move lines and write-offs diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 8db6ced6104..91f06252680 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -147,8 +147,13 @@ class AccountInvoice(models.Model): amount_to_show = currency._convert(abs(line.amount_residual), self.currency_id, self.company_id, line.date or fields.Date.today()) if float_is_zero(amount_to_show, precision_rounding=self.currency_id.rounding): continue + if line.ref : + title = '%s : %s' % (line.move_id.name, line.ref) + else: + title = line.move_id.name info['content'].append({ 'journal_name': line.ref or line.move_id.name, + 'title': title, 'amount': amount_to_show, 'currency': currency_id.symbol, 'id': line.id, @@ -1402,6 +1407,20 @@ class AccountInvoice(models.Model): return result @api.model + def _refund_tax_lines_account_change(self, lines, taxes_to_change): + # Let's change the account on tax lines when + # @param {list} lines: a list of orm commands + # @param {dict} taxes_to_change + # key: tax ID, value: refund account + + if not taxes_to_change: + return lines + + for line in lines: + if isinstance(line[2], dict) and line[2]['tax_id'] in taxes_to_change: + line[2]['account_id'] = taxes_to_change[line[2]['tax_id']] + return lines + def _get_refund_common_fields(self): return ['partner_id', 'payment_term_id', 'account_id', 'currency_id', 'journal_id'] @@ -1447,7 +1466,12 @@ class AccountInvoice(models.Model): values['invoice_line_ids'] = self._refund_cleanup_lines(invoice.invoice_line_ids) tax_lines = invoice.tax_line_ids - values['tax_line_ids'] = self._refund_cleanup_lines(tax_lines) + taxes_to_change = { + line.tax_id.id: line.tax_id.refund_account_id.id + for line in tax_lines.filtered(lambda l: l.tax_id.refund_account_id != l.tax_id.account_id) + } + cleaned_tax_lines = self._refund_cleanup_lines(tax_lines) + values['tax_line_ids'] = self._refund_tax_lines_account_change(cleaned_tax_lines, taxes_to_change) if journal_id: journal = self.env['account.journal'].browse(journal_id) diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index f4753ef4796..ee8087a33b0 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -185,13 +185,14 @@ class account_journal(models.Model): self.env.cr.execute(query, query_args) query_results_drafts = self.env.cr.dictfetchall() - today = datetime.today() - query = """SELECT amount_total, currency_id AS currency, type, date_invoice, company_id FROM account_invoice WHERE journal_id = %s AND date <= %s AND state = 'open';""" + today = fields.Date.today() + query = """SELECT residual_signed as amount_total, currency_id AS currency, type, date_invoice, company_id FROM account_invoice WHERE journal_id = %s AND date <= %s AND state = 'open';""" self.env.cr.execute(query, (self.id, today)) late_query_results = self.env.cr.dictfetchall() - (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay, currency) - (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts, currency) - (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results, currency) + curr_cache = {} + (number_waiting, sum_waiting) = self._count_results_and_sum_amounts(query_results_to_pay, currency, curr_cache=curr_cache) + (number_draft, sum_draft) = self._count_results_and_sum_amounts(query_results_drafts, currency, curr_cache=curr_cache) + (number_late, sum_late) = self._count_results_and_sum_amounts(late_query_results, currency, curr_cache=curr_cache) difference = currency.round(last_balance-account_sum) + 0.0 return { @@ -226,23 +227,45 @@ class account_journal(models.Model): gather the bills in draft state data, and the arguments dictionary to use to run it as its second. """ - return ("""SELECT state, amount_total, currency_id AS currency, type, date_invoice, company_id - FROM account_invoice + # there is no account_move_lines for draft invoices, so no relevant residual_signed value + return ("""SELECT state, + (CASE WHEN inv.type in ('out_invoice', 'in_invoice') + THEN inv.amount_total + ELSE (-1 * inv.amount_total) + END) AS amount_total, + inv.currency_id AS currency, + inv.type, + inv.date_invoice, + inv.company_id + FROM account_invoice inv WHERE journal_id = %(journal_id)s AND state = 'draft';""", {'journal_id':self.id}) - def _count_results_and_sum_amounts(self, results_dict, target_currency): + def _count_results_and_sum_amounts(self, results_dict, target_currency, curr_cache=None): """ Loops on a query result to count the total number of invoices and sum their amount_total field (expressed in the given target currency). + amount_total must be signed ! """ rslt_count = 0 rslt_sum = 0.0 + # Create a cache with currency rates to avoid unnecessary SQL requests. Do not copy + # curr_cache on purpose, so the dictionary is modified and can be re-used for subsequent + # calls of the method. + curr_cache = {} if curr_cache is None else curr_cache for result in results_dict: cur = self.env['res.currency'].browse(result.get('currency')) company = self.env['res.company'].browse(result.get('company_id')) or self.env.user.company_id rslt_count += 1 - type_factor = result.get('type') in ('in_refund', 'out_refund') and -1 or 1 - rslt_sum += type_factor * cur._convert( - result.get('amount_total'), target_currency, company, result.get('date_invoice') or fields.Date.today()) + date = result.get('date_invoice') or fields.Date.today() + + amount = result.get('amount_total', 0) + if cur != target_currency: + key = (cur, target_currency, company, date) + # Using setdefault will call _get_conversion_rate, so we explicitly check the + # existence of the key in the cache instead. + if key not in curr_cache: + curr_cache[key] = self.env['res.currency']._get_conversion_rate(*key) + amount *= curr_cache[key] + rslt_sum += target_currency.round(amount) return (rslt_count, rslt_sum) @api.multi @@ -360,7 +383,7 @@ class account_journal(models.Model): action['view_id'] = False if self.type == 'purchase': new_help = self.env['account.invoice'].with_context(ctx).complete_empty_list_help() - action.update({'help': action.get('help', '') + new_help}) + action.update({'help': (action.get('help') or '') + new_help}) return action @api.multi diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index b19485674a4..baf69b2e646 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -410,9 +410,6 @@ class AccountMove(models.Model): journal_id=journal_id, auto=auto) reversed_moves |= reversed_move - #unreconcile all lines reversed - aml = ac_move.line_ids.filtered(lambda x: x.account_id.reconcile or x.account_id.internal_type == 'liquidity') - aml.remove_move_reconcile() #reconcile together the reconcilable (or the liquidity aml) and their newly created counterpart for account in set([x.account_id for x in aml]): to_rec = aml.filtered(lambda y: y.account_id == account) @@ -429,6 +426,18 @@ class AccountMove(models.Model): def open_reconcile_view(self): return self.line_ids.open_reconcile_view() + # FIXME: Clarify me and change me in master + @api.multi + def action_duplicate(self): + self.ensure_one() + action = self.env.ref('account.action_move_journal_line').read()[0] + action['target'] = 'inline' + action['context'] = dict(self.env.context) + action['context']['view_no_maturity'] = False + action['views'] = [(self.env.ref('account.view_move_form').id, 'form')] + action['res_id'] = self.copy().id + return action + @api.model def _run_reverses_entries(self): ''' This method is called from a cron job. ''' @@ -618,7 +627,7 @@ class AccountMoveLine(models.Model): index=True, store=True, copy=False) # related is required blocked = fields.Boolean(string='No Follow-up', default=False, help="You can check this box to mark this journal item as a litigation with the associated partner") - date_maturity = fields.Date(string='Due date', index=True, required=True, + date_maturity = fields.Date(string='Due date', index=True, required=True, copy=False, help="This field is used for payable and receivable journal entries. You can put the limit date for the payment of this line.") date = fields.Date(related='move_id.date', string='Date', index=True, store=True, copy=False, readonly=False) # related is required analytic_line_ids = fields.One2many('account.analytic.line', 'move_id', string='Analytic lines', oldname="analytic_lines") @@ -877,22 +886,13 @@ class AccountMoveLine(models.Model): ret = self._reconcile_lines(debit_moves, credit_moves, field) return ret - @api.multi - def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False): - # Empty self can happen if the user tries to reconcile entries which are already reconciled. - # The calling method might have filtered out reconciled lines. - if not self: - return True - + def _check_reconcile_validity(self): #Perform all checks on lines company_ids = set() all_accounts = [] - partners = set() for line in self: company_ids.add(line.company_id.id) all_accounts.append(line.account_id) - if (line.account_id.internal_type in ('receivable', 'payable')): - partners.add(line.partner_id.id) if line.reconciled: raise UserError(_('You are trying to reconcile some entries that are already reconciled.')) if len(company_ids) > 1: @@ -902,6 +902,14 @@ class AccountMoveLine(models.Model): if not (all_accounts[0].reconcile or all_accounts[0].internal_type == 'liquidity'): raise UserError(_('Account %s (%s) does not allow reconciliation. First change the configuration of this account to allow it.') % (all_accounts[0].name, all_accounts[0].code)) + @api.multi + def reconcile(self, writeoff_acc_id=False, writeoff_journal_id=False): + # Empty self can happen if the user tries to reconcile entries which are already reconciled. + # The calling method might have filtered out reconciled lines. + if not self: + return True + + self._check_reconcile_validity() #reconcile everything that can be remaining_moves = self.auto_reconcile_lines() @@ -919,7 +927,7 @@ class AccountMoveLine(models.Model): #add writeoff line to reconcile algorithm and finish the reconciliation remaining_moves = (remaining_moves + writeoff_to_reconcile).auto_reconcile_lines() # Check if reconciliation is total or needs an exchange rate entry to be created - (self+writeoff_to_reconcile).check_full_reconcile() + (self + writeoff_to_reconcile).check_full_reconcile() return True def _create_writeoff(self, writeoff_vals): @@ -963,8 +971,9 @@ class AccountMoveLine(models.Model): raise UserError(_("Either pass both debit and credit or none.")) if 'date' not in vals: vals['date'] = self._context.get('date_p') or fields.Date.today() - if vals['date'] < date: - date = vals['date'] + vals['date'] = fields.Date.to_date(vals['date']) + if vals['date'] and vals['date'] < date: + date = vals['date'] if 'name' not in vals: vals['name'] = self._context.get('comment') or _('Write-Off') if 'analytic_account_id' not in vals: diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index c83d2b31ed3..bb2e7ff4f03 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -324,7 +324,7 @@ class account_register_payments(models.TransientModel): pmt_communication = self.show_communication_field and self.communication \ or self.group_invoices and ' '.join([inv.reference or inv.number for inv in invoices]) \ or invoices[0].reference # in this case, invoices contains only one element, since group_invoices is False - return { + values = { 'journal_id': self.journal_id.id, 'payment_method_id': self.payment_method_id.id, 'payment_date': self.payment_date, @@ -337,8 +337,13 @@ class account_register_payments(models.TransientModel): 'partner_type': MAP_INVOICE_TYPE_PARTNER_TYPE[invoices[0].type], 'partner_bank_account_id': bank_account.id, 'multi': False, + 'payment_difference_handling': self.payment_difference_handling, + 'writeoff_account_id': self.writeoff_account_id.id, + 'writeoff_label': self.writeoff_label, } + return values + @api.multi def get_payments_vals(self): '''Compute the values for payments. @@ -429,6 +434,8 @@ class account_payment(models.Model): if move_line.account_id.reconcile: move_line_id = move_line.id break; + if not self.partner_id: + raise UserError(_("Payments without a customer can't be matched")) action_context = {'company_ids': [self.company_id.id], 'partner_ids': [self.partner_id.commercial_partner_id.id]} if self.partner_type == 'customer': action_context.update({'mode': 'customers'}) diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 8d7a48424fe..717b99ad26d 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -173,7 +173,7 @@ class AccountChartTemplate(models.Model): # (won't work well for multi-company) company = self.env.user.company_id # If we don't have any chart of account on this company, install this chart of account - if not company.chart_template_id: + if not self.existing_accounting(company): self.load_for_current_company(15.0, 15.0) def load_for_current_company(self, sale_tax_rate, purchase_tax_rate): diff --git a/addons/account/models/company.py b/addons/account/models/company.py index 5ea0db1cd95..f2efe950361 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -381,8 +381,13 @@ Best Regards,''')) ('user_type_id', '=', unaffected_earnings_type.id)]) if account: return account[0] + # Do not assume '999999' doesn't exist since the user might have created such an account + # manually. + code = 999999 + while self.env['account.account'].search([('code', '=', str(code)), ('company_id', '=', self.id)]): + code -= 1 return self.env['account.account'].create({ - 'code': '999999', + 'code': str(code), 'name': _('Undistributed Profits/Losses'), 'user_type_id': unaffected_earnings_type.id, 'company_id': self.id, diff --git a/addons/account/models/partner.py b/addons/account/models/partner.py index f2485b379e6..85ec5f9bb66 100644 --- a/addons/account/models/partner.py +++ b/addons/account/models/partner.py @@ -204,7 +204,7 @@ class ResPartner(models.Model): @api.multi def _credit_debit_get(self): - tables, where_clause, where_params = self.env['account.move.line']._query_get() + tables, where_clause, where_params = self.env['account.move.line'].with_context(company_id=self.env.user.company_id.id)._query_get() where_params = [tuple(self.ids)] + where_params if where_clause: where_clause = 'AND ' + where_clause diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index 2fb6aad94b8..df92a5b45f1 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -224,6 +224,27 @@ class AccountReconciliation(models.AbstractModel): """ Returns the data required for the invoices & payments matching of partners/accounts. If an argument is None, fetch all related reconciliations. Use [] to fetch nothing. """ + MoveLine = self.env['account.move.line'] + aml_ids = self._context.get('active_ids') and self._context.get('active_model') == 'account.move.line' and tuple(self._context.get('active_ids')) + if aml_ids: + aml = MoveLine.browse(aml_ids) + aml._check_reconcile_validity() + account = aml[0].account_id + currency = account.currency_id or account.company_id.currency_id + return { + 'accounts': [{ + 'reconciliation_proposition': self._prepare_move_lines(aml, target_currency=currency), + 'company_id': account.company_id.id, + 'currency_id': currency.id, + 'mode': 'accounts', + 'account_id': account.id, + 'account_name': account.name, + 'account_code': account.code, + }], + 'customers': [], + 'suppliers': [], + } + return { 'customers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'receivable'), 'suppliers': self.get_data_for_manual_reconciliation('partner', partner_ids, 'payable'), @@ -344,7 +365,6 @@ class AccountReconciliation(models.AbstractModel): # be reconciled. return [r for r in rows if r['reconciliation_proposition']] + [r for r in rows if not r['reconciliation_proposition']] - @api.model def process_move_lines(self, data): """ Used to validate a batch of reconciliations in a single call diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py index bc660b494b3..08d0b976263 100644 --- a/addons/account/report/account_aged_partner_balance.py +++ b/addons/account/report/account_aged_partner_balance.py @@ -142,6 +142,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): if not self.env.user.company_id.currency_id.is_zero(line_amount): partners_amount[partner_id] += line_amount + lines.setdefault(partner_id, []) lines[partner_id].append({ 'line': line, 'amount': line_amount, @@ -179,6 +180,7 @@ class ReportAgedPartnerBalance(models.AbstractModel): line_amount -= partial_line.company_id.currency_id._convert(partial_line.amount, user_currency, user_company, date_from) if not self.env.user.company_id.currency_id.is_zero(line_amount): undue_amounts[partner_id] += line_amount + lines.setdefault(partner_id, []) lines[partner_id].append({ 'line': line, 'amount': line_amount, diff --git a/addons/account/static/src/js/account_payment_field.js b/addons/account/static/src/js/account_payment_field.js index a20a681b0e6..c4dec36dda0 100644 --- a/addons/account/static/src/js/account_payment_field.js +++ b/addons/account/static/src/js/account_payment_field.js @@ -162,4 +162,8 @@ var ShowPaymentLineWidget = AbstractField.extend({ field_registry.add('payment', ShowPaymentLineWidget); +return { + ShowPaymentLineWidget: ShowPaymentLineWidget +}; + }); diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js index 5e756f28247..06387df0579 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_model.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js @@ -858,6 +858,7 @@ var StatementModel = BasicModel.extend({ 'tax_id': [tax.id, null], 'amount': tax.amount, 'label': tax.name, + 'date': prop.date, 'account_id': tax.account_id ? [tax.account_id, null] : prop.account_id, 'analytic': tax.analytic, 'is_tax': true, @@ -1072,6 +1073,7 @@ var StatementModel = BasicModel.extend({ */ _formatQuickCreate: function (line, values) { values = values || {}; + var today = new moment().utc().format(); var account = this._formatNameGet(values.account_id); var formatOptions = { currency_id: line.st_line.currency_id, @@ -1088,6 +1090,7 @@ var StatementModel = BasicModel.extend({ 'tax_id': this._formatNameGet(values.tax_id), 'debit': 0, 'credit': 0, + 'date': values.date ? values.date : field_utils.parse.date(today, {}, {isUTC: true}), 'base_amount': values.amount_type !== "percentage" ? (amount) : line.balance.amount * values.amount / 100, 'percent': values.amount_type === "percentage" ? values.amount : null, @@ -1238,7 +1241,7 @@ var StatementModel = BasicModel.extend({ * datas allowing manual reconciliation */ var ManualModel = StatementModel.extend({ - quickCreateFields: ['account_id', 'journal_id', 'amount', 'analytic_account_id', 'label', 'tax_id', 'force_tax_included', 'analytic_tag_ids'], + quickCreateFields: ['account_id', 'journal_id', 'amount', 'analytic_account_id', 'label', 'tax_id', 'force_tax_included', 'analytic_tag_ids', 'date'], //-------------------------------------------------------------------------- // Public @@ -1636,6 +1639,12 @@ var ManualModel = StatementModel.extend({ }) .then(this._formatMoveLine.bind(this, handle)); }, + + _formatToProcessReconciliation: function (line, prop) { + var result = this._super(line, prop); + result['date'] = prop.date; + return result; + }, }); return { diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js index f0641eae409..23c680ddcda 100644 --- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js +++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js @@ -542,10 +542,13 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { }, { type: 'float', name: 'amount', + }, { + type: 'char', //TODO is it a bug or a feature when type date exists ? + name: 'date', }], { account_id: {string: _t("Account")}, label: {string: _t("Label")}, - amount: {string: _t("Account")} + amount: {string: _t("Account")}, }).then(function (recordID) { self.handleCreateRecord = recordID; var record = self.model.get(self.handleCreateRecord); @@ -573,6 +576,9 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { self.fields.amount = new basic_fields.FieldFloat(self, 'amount', record, {mode: 'edit'}); + + self.fields.date = new basic_fields.FieldDate(self, + 'date', record, {mode: 'edit'}); var $create = $(qweb.render("reconciliation.line.create", {'state': state})); self.fields.account_id.appendTo($create.find('.create_account_id .o_td_field')) @@ -586,6 +592,7 @@ var LineRenderer = Widget.extend(FieldManagerMixin, { .then(addRequiredStyle.bind(self, self.fields.label)); self.fields.amount.appendTo($create.find('.create_amount .o_td_field')) .then(addRequiredStyle.bind(self, self.fields.amount)); + self.fields.date.appendTo($create.find('.create_date .o_td_field')) self.$('.create').append($create); function addRequiredStyle(widget) { @@ -893,6 +900,7 @@ var ManualLineRenderer = LineRenderer.extend({ _renderCreate: function (state) { this._super(state); this.$('.create .create_journal_id').show(); + this.$('.create .create_date').removeClass('d-none') this.$('.create .create_journal_id .o_input').addClass('o_required_modifier'); }, diff --git a/addons/account/static/src/xml/account_payment.xml b/addons/account/static/src/xml/account_payment.xml index 4e72bd67d06..55b327183fc 100644 --- a/addons/account/static/src/xml/account_payment.xml +++ b/addons/account/static/src/xml/account_payment.xml @@ -16,8 +16,8 @@ <td> <a title="assign to invoice" role="button" class="oe_form_field btn btn-link outstanding_credit_assign" t-att-data-id="line.id" style="margin-right: 10px;" href="#">Add</a> </td> - <td> - <span class="oe_form_field" style="margin-right: 30px;"><t t-esc="line.journal_name"></t></span> + <td style="max-width: 10em;"> + <div class="oe_form_field" style="margin-right: 30px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;" t-att-title="line.title"><t t-esc="line.journal_name"></t></div> </td> </t> <t t-if="!outstanding"> diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml index 3caa61eb633..33c778a9b7c 100644 --- a/addons/account/static/src/xml/account_reconciliation.xml +++ b/addons/account/static/src/xml/account_reconciliation.xml @@ -212,6 +212,10 @@ <td class="o_td_label">Tax Included in Price</td> <td class="o_td_field"></td> </tr> + <tr class="create_date d-none"> + <td class="o_td_label">Writeoff Date</td> + <td class="o_td_field"></td> + </tr> </tbody> </table> <div class="add_line_container"> diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js index 01645d5c97e..62fa37e1199 100644 --- a/addons/account/static/tests/reconciliation_tests.js +++ b/addons/account/static/tests/reconciliation_tests.js @@ -649,8 +649,8 @@ QUnit.module('account', { assert.strictEqual(widget.$('.o_input_dropdown input').val(), "Agrolait", "the partner many2one should display agrolait"); assert.strictEqual(clientAction.widgets[2].$('.o_input_dropdown input').val(), "Camptocamp", "the partner many2one should display Camptocamp"); widget.$('.accounting_view tfoot td:first').trigger('click'); - assert.containsN(widget, '.create input.o_input', 7, - "create panel should contain 7 fields (account_id, tax_id, journal_id, analytic_account_id, analytic_tag_ids, label, amount)"); + assert.containsN(widget, '.create input.o_input', 8, + "create panel should contain 8 fields (account_id, tax_id, journal_id, analytic_account_id, analytic_tag_ids, label, amount, date)"); assert.containsN(widget, '.create .create_account_id .o_required_modifier, .create .create_label .o_required_modifier, .create .create_amount .o_required_modifier', 3, "account_id, label and amount should be required fields"); assert.strictEqual(widget.$('.create .create_label input').val(), 'SAJ/2014/002 and SAJ/2014/003', diff --git a/addons/account/tests/test_account_customer_invoice.py b/addons/account/tests/test_account_customer_invoice.py index 0906eae595b..03ca61aecf5 100644 --- a/addons/account/tests/test_account_customer_invoice.py +++ b/addons/account/tests/test_account_customer_invoice.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from odoo.addons.account.tests.account_test_users import AccountTestUsers import datetime from odoo.tests import tagged @@ -165,3 +167,174 @@ class TestAccountCustomerInvoice(AccountTestUsers): )) self.assertEquals(invoice.amount_untaxed, sum([x.base for x in invoice.tax_line_ids])) + + def test_customer_invoice_tax_refund(self): + company = self.env.user.company_id + tax_account = self.env['account.account'].create({ + 'name': 'TAX', + 'code': 'TAX', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'company_id': company.id, + }) + + tax_refund_account = self.env['account.account'].create({ + 'name': 'TAX_REFUND', + 'code': 'TAX_R', + 'user_type_id': self.env.ref('account.data_account_type_current_assets').id, + 'company_id': company.id, + }) + + journalrec = self.env['account.journal'].search([('type', '=', 'sale')])[0] + partner3 = self.env.ref('base.res_partner_3') + account_id = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id + + tax = self.env['account.tax'].create({ + 'name': 'Tax 15.0', + 'amount': 15.0, + 'amount_type': 'percent', + 'type_tax_use': 'sale', + 'account_id': tax_account.id, + 'refund_account_id': tax_refund_account.id + }) + + invoice_line_data = [ + (0, 0, + { + 'product_id': self.env.ref('product.product_product_1').id, + 'quantity': 40.0, + 'account_id': account_id, + 'name': 'product test 1', + 'discount': 10.00, + 'price_unit': 2.27, + 'invoice_line_tax_ids': [(6, 0, [tax.id])], + } + )] + + invoice = self.env['account.invoice'].create(dict( + name="Test Customer Invoice", + journal_id=journalrec.id, + partner_id=partner3.id, + invoice_line_ids=invoice_line_data + )) + + invoice.action_invoice_open() + + refund = invoice.refund() + self.assertEqual(invoice.tax_line_ids.mapped('account_id'), tax_account) + self.assertEqual(refund.tax_line_ids.mapped('account_id'), tax_refund_account) + + def test_customer_invoice_dashboard(self): + def patched_today(*args, **kwargs): + return '2019-01-22' + + date_invoice = '2019-01-21' + partner3 = self.env.ref('base.res_partner_3') + account_id = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_revenue').id)], limit=1).id + + journal = self.env['account.journal'].create({ + 'name': 'sale_0', + 'code': 'SALE0', + 'type': 'sale', + }) + + invoice_line_data = [ + (0, 0, + { + 'product_id': self.env.ref('product.product_product_1').id, + 'quantity': 40.0, + 'account_id': account_id, + 'name': 'product test 1', + 'discount': 10.00, + 'price_unit': 2.27, + } + ) + ] + + invoice = self.env['account.invoice'].create(dict( + name="Test Customer Invoice", + journal_id=journal.id, + partner_id=partner3.id, + invoice_line_ids=invoice_line_data, + date_invoice=date_invoice, + )) + + refund_line_data = [ + (0, 0, + { + 'product_id': self.env.ref('product.product_product_1').id, + 'quantity': 1.0, + 'account_id': account_id, + 'name': 'product test 1', + 'price_unit': 13.3, + } + )] + + refund = self.env['account.invoice'].create(dict( + name="Test Customer Refund", + type='out_refund', + journal_id=journal.id, + partner_id=partner3.id, + invoice_line_ids=refund_line_data, + date_invoice=date_invoice, + )) + + # Check Draft + dashboard_data = journal.get_journal_dashboard_datas() + + self.assertEquals(dashboard_data['number_draft'], 2) + self.assertEquals(dashboard_data['sum_draft'], '$ 68.42') + + self.assertEquals(dashboard_data['number_waiting'], 0) + self.assertEquals(dashboard_data['sum_waiting'], '$ 0.00') + + # Check Both + invoice.action_invoice_open() + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEquals(dashboard_data['number_draft'], 1) + self.assertEquals(dashboard_data['sum_draft'], '$ -13.30') + + self.assertEquals(dashboard_data['number_waiting'], 1) + self.assertEquals(dashboard_data['sum_waiting'], '$ 81.72') + + # Check waiting payment + refund.action_invoice_open() + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEquals(dashboard_data['number_draft'], 0) + self.assertEquals(dashboard_data['sum_draft'], '$ 0.00') + + self.assertEquals(dashboard_data['number_waiting'], 2) + self.assertEquals(dashboard_data['sum_waiting'], '$ 68.42') + + # Check partial + receivable_account = refund.move_id.line_ids.mapped('account_id').filtered(lambda a: a.internal_type == 'receivable') + payment_move = self.env['account.move'].create({ + 'journal_id': journal.id, + }) + payment_move_line = self.env['account.move.line'].with_context(check_move_validity=False).create({ + 'move_id': payment_move.id, + 'account_id': receivable_account.id, + 'debit': 10.00, + }) + self.env['account.move.line'].with_context(check_move_validity=False).create({ + 'move_id': payment_move.id, + 'account_id': self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_liquidity').id)], limit=1).id, + 'credit': 10.00, + }) + + payment_move.post() + + refund.register_payment(payment_move_line) + + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEquals(dashboard_data['number_draft'], 0) + self.assertEquals(dashboard_data['sum_draft'], '$ 0.00') + + self.assertEquals(dashboard_data['number_waiting'], 2) + self.assertEquals(dashboard_data['sum_waiting'], '$ 78.42') + + with patch('odoo.fields.Date.today', patched_today): + dashboard_data = journal.get_journal_dashboard_datas() + self.assertEquals(dashboard_data['number_late'], 2) + self.assertEquals(dashboard_data['sum_late'], '$ 78.42') diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py index a061f0c7964..c010d0084aa 100644 --- a/addons/account/tests/test_payment.py +++ b/addons/account/tests/test_payment.py @@ -595,3 +595,37 @@ class TestPayment(AccountingTestCase): # The invoice should now be paid self.assertEqual(invoice.state, 'paid', "Invoice should be in 'paid' state after having reconciled the two payments with a bank statement") + + def test_register_payments_multi_invoices(self): + inv1 = self.create_invoice(amount=40) + inv2 = inv1.copy() + inv2.action_invoice_open() + + batch_payment = self.env['account.register.payments'].with_context(active_ids=(inv1 + inv2).ids).create({ + 'amount': 70, + 'partner_id': inv1.partner_id.id, + 'journal_id': self.bank_journal_usd.id, + 'invoice_ids': [(6, False, (inv1 + inv2).ids)], + 'partner_type': 'customer', + 'payment_difference_handling': 'reconcile', + 'payment_type': 'inbound', + 'payment_method_id': self.payment_method_manual_in.id, + 'writeoff_account_id': self.account_revenue.id, + 'writeoff_label': "why can't we live together"}) + + payment_id = batch_payment.create_payments()['res_id'] + payment_id = self.env['account.payment'].browse(payment_id) + + self.assertEqual(inv1.state, 'paid') + self.assertEqual(inv2.state, 'paid') + + receivable_inv1 = inv1.move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + receivable_inv2 = inv2.move_id.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + receivable_payment = payment_id.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + self.assertEqual( + receivable_inv1.full_reconcile_id.reconciled_line_ids, + receivable_inv1 + receivable_inv2 + receivable_payment + ) + write_off_line = payment_id.move_line_ids.filtered(lambda l: l.account_id == payment_id.writeoff_account_id) + self.assertEqual(write_off_line.debit, 10) diff --git a/addons/account/views/account_view.xml b/addons/account/views/account_view.xml index 15219345394..197c6a1e1cf 100644 --- a/addons/account/views/account_view.xml +++ b/addons/account/views/account_view.xml @@ -1556,10 +1556,11 @@ <field name="name">account.move.form</field> <field name="model">account.move</field> <field name="arch" type="xml"> - <form string="Account Entry"> + <form string="Account Entry" duplicate="false"> <header> <button name="action_post" states="draft" string="Post" type="object" class="oe_highlight" groups="account.group_account_invoice"/> <button name="%(action_view_account_move_reversal)d" states="posted" string="Reverse Entry" type="action" groups="account.group_account_invoice"/> + <button name="action_duplicate" string="Duplicate" type="object" groups="account.group_account_invoice"/> <field name="state" widget="statusbar"/> </header> <sheet> diff --git a/addons/account/wizard/account_invoice_send.py b/addons/account/wizard/account_invoice_send.py index ba5da81b889..54fbd7ff4be 100644 --- a/addons/account/wizard/account_invoice_send.py +++ b/addons/account/wizard/account_invoice_send.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models -from odoo.exceptions import UserError +from odoo import api, fields, models, _ +from odoo.addons.mail.wizard.mail_compose_message import _reopen class AccountInvoiceSend(models.TransientModel): @@ -67,3 +67,11 @@ class AccountInvoiceSend(models.TransientModel): if self.is_print: return self._print_document() return {'type': 'ir.actions.act_window_close'} + + @api.multi + def save_as_template(self): + self.ensure_one() + self.composer_id.save_as_template() + action = _reopen(self, self.id, self.model, context=self._context) + action.update({'name': _('Send Invoice')}) + return action diff --git a/addons/attachment_indexation/models/ir_attachment.py b/addons/attachment_indexation/models/ir_attachment.py index 2bc6aee234d..bdc4559e893 100644 --- a/addons/attachment_indexation/models/ir_attachment.py +++ b/addons/attachment_indexation/models/ir_attachment.py @@ -9,7 +9,7 @@ import zipfile from odoo import api, models _logger = logging.getLogger(__name__) -FTYPES = ['docx', 'pptx', 'xlsx', 'opendoc', 'pdf'] +FTYPES = ['docx', 'pptx', 'xlsx', 'opendoc'] def textToString(element): buff = u"" @@ -92,6 +92,9 @@ class IrAttachment(models.Model): def _index_pdf(self, bin_data): '''Index PDF documents''' + # extractText gives very bad results for indexing, hence we don't index PDF anymore. A + # better alternative is probably PDFMiner.six, but not for stable. + # See POC at https://github.com/odoo/odoo/pull/27568. buf = u"" if bin_data.startswith(b'%PDF-'): f = io.BytesIO(bin_data) diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py index 7591299f3e2..f8f01119e7d 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -15,9 +15,10 @@ class Partner(models.Model): @api.onchange('city_id') def _onchange_city_id(self): - self.city = self.city_id.name - self.zip = self.city_id.zipcode - self.state_id = self.city_id.state_id + if self.city_id: + self.city = self.city_id.name + self.zip = self.city_id.zipcode + self.state_id = self.city_id.state_id @api.model def _fields_view_get_address(self, arch): @@ -37,7 +38,10 @@ class Partner(models.Model): }" /> <field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s" - context="{'default_country_id': country_id}" + context="{'default_country_id': country_id, + 'default_name': city, + 'default_zipcode': zip, + 'default_state_id': state_id}" domain="[('country_id', '=', country_id)]" attrs="{ 'invisible': [('country_enforce_cities', '=', False)], diff --git a/addons/base_gengo/wizard/base_gengo_translations.py b/addons/base_gengo/wizard/base_gengo_translations.py index 3b260dd5a61..7ec81a25607 100644 --- a/addons/base_gengo/wizard/base_gengo_translations.py +++ b/addons/base_gengo/wizard/base_gengo_translations.py @@ -24,6 +24,7 @@ class BaseGengoTranslations(models.TransientModel): GROUPS = ['base.group_system'] _name = 'base.gengo.translations' + _description = 'Base Gengo Translations' @api.model def default_get(self, fields): diff --git a/addons/bus/models/res_partner.py b/addons/bus/models/res_partner.py index b0b4c23d74f..a476e0c08dc 100644 --- a/addons/bus/models/res_partner.py +++ b/addons/bus/models/res_partner.py @@ -15,13 +15,14 @@ class ResPartner(models.Model): self.env.cr.execute(""" SELECT U.partner_id as id, - CASE WHEN age(now() AT TIME ZONE 'UTC', B.last_poll) > interval %s THEN 'offline' - WHEN age(now() AT TIME ZONE 'UTC', B.last_presence) > interval %s THEN 'away' + CASE WHEN age(now() AT TIME ZONE 'UTC', max(B.last_poll)) > interval %s THEN 'offline' + WHEN age(now() AT TIME ZONE 'UTC', max(B.last_presence)) > interval %s THEN 'away' ELSE 'online' END as status FROM bus_presence B JOIN res_users U ON B.user_id = U.id WHERE U.partner_id IN %s AND U.active = 't' + GROUP BY U.partner_id """, ("%s seconds" % DISCONNECTION_TIMER, "%s seconds" % AWAY_TIMER, tuple(self.ids))) res = dict(((status['id'], status['status']) for status in self.env.cr.dictfetchall())) for partner in self: diff --git a/addons/crm/views/crm_lead_views.xml b/addons/crm/views/crm_lead_views.xml index acaf0f5ea72..794e37ddb66 100644 --- a/addons/crm/views/crm_lead_views.xml +++ b/addons/crm/views/crm_lead_views.xml @@ -888,6 +888,7 @@ for rec in records: name="Team Pipelines" parent="crm_menu_sales" action="sales_team.crm_team_salesteams_pipelines_act" + groups="sales_team.group_sale_manager" sequence="4"/> <menuitem id="res_partner_menu_customer" diff --git a/addons/delivery/models/delivery_carrier.py b/addons/delivery/models/delivery_carrier.py index b5b0fcb0644..ad0d4f3cb30 100644 --- a/addons/delivery/models/delivery_carrier.py +++ b/addons/delivery/models/delivery_carrier.py @@ -70,7 +70,7 @@ class DeliveryCarrier(models.Model): def install_more_provider(self): return { 'name': 'New Providers', - 'view_mode': 'kanban', + 'view_mode': 'kanban,form', 'res_model': 'ir.module.module', 'domain': [['name', 'ilike', 'delivery_']], 'type': 'ir.actions.act_window', diff --git a/addons/delivery/wizard/choose_delivery_package.py b/addons/delivery/wizard/choose_delivery_package.py index 70c06f06dac..e7ad1d75292 100644 --- a/addons/delivery/wizard/choose_delivery_package.py +++ b/addons/delivery/wizard/choose_delivery_package.py @@ -21,6 +21,7 @@ class ChooseDeliveryPackage(models.TransientModel): string='Shipping Weight', default=lambda self: self._default_shipping_weight() ) + weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name') def _default_stock_quant_package_id(self): if self.env.context.get('default_stock_quant_package_id'): @@ -45,6 +46,12 @@ class ChooseDeliveryPackage(models.TransientModel): total_weight = sum([po.qty_done * po.product_id.weight for po in move_line_ids]) return total_weight + @api.depends('stock_quant_package_id', 'delivery_packaging_id') + def _compute_weight_uom_name(self): + weight_uom_id = self.env['product.template']._get_weight_uom_id_from_ir_config_parameter() + for package in self: + package.weight_uom_name = weight_uom_id.name + @api.onchange('delivery_packaging_id', 'shipping_weight') def _onchange_packaging_weight(self): if self.delivery_packaging_id.max_weight and self.shipping_weight > self.delivery_packaging_id.max_weight: diff --git a/addons/delivery/wizard/choose_delivery_package_views.xml b/addons/delivery/wizard/choose_delivery_package_views.xml index c37231ed38d..72e5074def3 100644 --- a/addons/delivery/wizard/choose_delivery_package_views.xml +++ b/addons/delivery/wizard/choose_delivery_package_views.xml @@ -11,7 +11,7 @@ <label for="shipping_weight" attrs="{'invisible': [('delivery_packaging_id', '=', False)]}"/> <div class="o_row" attrs="{'invisible': [('delivery_packaging_id', '=', False)]}"> <field name="shipping_weight"/> - <span>kg</span> + <field name="weight_uom_name"/> </div> </group> <footer> diff --git a/addons/fleet/models/fleet_vehicle.py b/addons/fleet/models/fleet_vehicle.py index e5101c76360..383b6e06cdb 100644 --- a/addons/fleet/models/fleet_vehicle.py +++ b/addons/fleet/models/fleet_vehicle.py @@ -23,7 +23,7 @@ class FleetVehicle(models.Model): license_plate = fields.Char(track_visibility="onchange", help='License plate number of the vehicle (i = plate number for a car)') vin_sn = fields.Char('Chassis Number', help='Unique number written on the vehicle motor (VIN/SN number)', copy=False) - driver_id = fields.Many2one('res.partner', 'Driver', track_visibility="onchange", help='Driver of the vehicle', copy=False) + driver_id = fields.Many2one('res.partner', 'Driver', track_visibility="onchange", help='Driver of the vehicle', copy=False, auto_join=True) model_id = fields.Many2one('fleet.vehicle.model', 'Model', track_visibility="onchange", required=True, help='Model of the vehicle') brand_id = fields.Many2one('fleet.vehicle.model.brand', 'Brand', related="model_id.brand_id", store=True, readonly=False) @@ -59,6 +59,7 @@ class FleetVehicle(models.Model): fuel_type = fields.Selection([ ('gasoline', 'Gasoline'), ('diesel', 'Diesel'), + ('lpg', 'LPG'), ('electric', 'Electric'), ('hybrid', 'Hybrid') ], 'Fuel Type', help='Fuel Used by the vehicle') @@ -228,12 +229,7 @@ class FleetVehicle(models.Model): @api.model def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): domain = args or [] - domain = expression.AND([domain, [('name', operator, name)]]) - # we don't want to override the domain's filter on driver_id if present - if not any(['driver_id' in element for element in domain]): - partner_ids = self.env['res.partner']._search([('name', operator, name)], access_rights_uid=name_get_uid) - if partner_ids: - domain = expression.OR([domain, ['|', ('driver_id', 'in', partner_ids), ('driver_id', '=', False)]]) + domain = expression.AND([domain, ['|', ('name', operator, name), ('driver_id.name', operator, name)]]) rec = self._search(domain, limit=limit, access_rights_uid=name_get_uid) return self.browse(rec).name_get() diff --git a/addons/hr_fleet/controllers/i18n/fil.po b/addons/hr_fleet/controllers/i18n/fil.po new file mode 100644 index 00000000000..f3c56bf488c --- /dev/null +++ b/addons/hr_fleet/controllers/i18n/fil.po @@ -0,0 +1,115 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * crm_project +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server saas~11.5\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-09-21 13:17+0000\n" +"PO-Revision-Date: 2018-08-24 09:17+0000\n" +"Language-Team: Filipino (https://www.transifex.com/odoo/teams/41243/fil/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: fil\n" +"Plural-Forms: nplurals=2; plural=(n == 1 || n==2 || n==3) || (n % 10 != 4 || n % 10 != 6 || n % 10 != 9);\n" + +#. module: crm_project +#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form +msgid "" +"<span class=\"o_stat_text\">Convert To</span>\n" +" <span class=\"o_stat_text\">Task</span>" +msgstr "" + +#. module: crm_project +#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form +msgid "Cancel" +msgstr "" + +#. module: crm_project +#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action +#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form +#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form +msgid "Convert to Task" +msgstr "" + +#. module: crm_project +#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form +msgid "Create Task" +msgstr "" + +#. module: crm_project +#: selection:crm.lead.convert2task,action:0 +msgid "Create a new customer" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid +msgid "Created by" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date +msgid "Created on" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id +msgid "Customer" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name +msgid "Display Name" +msgstr "" + +#. module: crm_project +#: selection:crm.lead.convert2task,action:0 +msgid "Do not link to a customer" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id +msgid "ID" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update +msgid "Last Modified on" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date +msgid "Last Updated on" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id +msgid "Lead" +msgstr "" + +#. module: crm_project +#: model:ir.model,name:crm_project.model_crm_lead_convert2task +msgid "Lead convert to Task" +msgstr "" + +#. module: crm_project +#: selection:crm.lead.convert2task,action:0 +msgid "Link to an existing customer" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id +msgid "Project" +msgstr "" + +#. module: crm_project +#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action +msgid "Related Customer" +msgstr "" diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 37e12bd0c86..d67ddc01904 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -497,7 +497,8 @@ class HolidaysRequest(models.Model): holiday.sudo().action_validate() holiday.message_subscribe(partner_ids=[holiday._get_responsible_for_approval().partner_id.id]) holiday.sudo().message_post(body=_("The leave has been automatically approved"), subtype="mt_comment") # Message from OdooBot (sudo) - holiday.activity_update() + if not self._context.get('import_file'): + holiday.activity_update() return holiday def _read_from_database(self, field_names, inherited_field_names=[]): diff --git a/addons/l10n_be_hr_payroll_fleet/models/fleet.py b/addons/l10n_be_hr_payroll_fleet/models/fleet.py index c72f10117ba..2a89a67de88 100644 --- a/addons/l10n_be_hr_payroll_fleet/models/fleet.py +++ b/addons/l10n_be_hr_payroll_fleet/models/fleet.py @@ -45,15 +45,21 @@ class FleetVehicle(models.Model): elif contract.cost_frequency == "yearly": car.total_cost += contract.cost_generated / 12.0 - def _get_co2_fee(self, co2): - return max((((co2 * 9.0) - 600.0) * 1.2488) / 12.0, 0.0) - - @api.depends('co2') + def _get_co2_fee(self, co2, fuel_type): + fuel_coefficient = {'diesel': 600, 'gasoline': 768, 'lpg': 990, 'electric': 0, 'hybrid': 600} + co2_fee = 0 + if fuel_type and fuel_type != 'electric': + if not co2: + co2 = 165 if fuel_type in ['diesel', 'hybrid'] else 182 + co2_fee = (((co2 * 9.0) - fuel_coefficient.get(fuel_type, 0)) * 144.97 / 114.08) / 12.0 + return max(co2_fee, 26.47) + + @api.depends('co2', 'fuel_type') def _compute_co2_fee(self): for car in self: - car.co2_fee = self._get_co2_fee(car.co2) + car.co2_fee = self._get_co2_fee(car.co2, car.fuel_type) - @api.depends('fuel_type', 'car_value', 'acquisition_date') + @api.depends('fuel_type', 'car_value', 'acquisition_date', 'co2') def _compute_car_atn(self): for car in self: car.atn = car._get_car_atn(car.acquisition_date, car.car_value, car.fuel_type, car.co2) @@ -111,15 +117,18 @@ class FleetVehicle(models.Model): atn = 0.0 else: if fuel_type in ['diesel', 'hybrid']: - reference = 87.0 + reference = 88.0 else: - reference = 105.0 + reference = 107.0 + + if not co2: + co2 = 195 if fuel_type in ['diesel', 'hybrid'] else 205 if co2 <= reference: atn = car_value * max(0.04, (0.055 - 0.001 * (reference - co2))) * magic_coeff else: atn = car_value * min(0.18, (0.055 + 0.001 * (co2 - reference))) * magic_coeff - return max(1280, atn) / 12.0 + return max(1310, atn) / 12.0 @api.onchange('model_id') def _onchange_model_id(self): @@ -140,7 +149,7 @@ class FleetVehicleModel(models.Model): default_recurring_cost_amount_depreciated = fields.Float(string="Cost (Depreciated)", help="Default recurring cost amount that should be applied to a new car from this model") default_co2 = fields.Float(string="CO2 emissions") - default_fuel_type = fields.Selection([('gasoline', 'Gasoline'), ('diesel', 'Diesel'), ('electric', 'Electric'), ('hybrid', 'Hybrid')], 'Fuel Type', help='Fuel Used by the vehicle') + default_fuel_type = fields.Selection([('gasoline', 'Gasoline'), ('diesel', 'Diesel'), ('lpg', 'LPG'), ('electric', 'Electric'), ('hybrid', 'Hybrid')], 'Fuel Type', help='Fuel Used by the vehicle') default_car_value = fields.Float(string="Catalog Value (VAT Incl.)") can_be_requested = fields.Boolean(string="Can be requested", help="Can be requested on a contract as a new car") default_atn = fields.Float(compute='_compute_atn', string="ATN") @@ -158,7 +167,7 @@ class FleetVehicleModel(models.Model): for model in self: model.default_total_depreciated_cost = model.co2_fee + model.default_recurring_cost_amount_depreciated - @api.depends('default_co2') + @api.depends('default_co2', 'default_fuel_type') def _compute_co2_fee(self): for model in self: - model.co2_fee = self.env['fleet.vehicle']._get_co2_fee(model.default_co2) + model.co2_fee = self.env['fleet.vehicle']._get_co2_fee(model.default_co2, model.default_fuel_type) diff --git a/addons/l10n_fr/data/account_chart_template_data.xml b/addons/l10n_fr/data/account_chart_template_data.xml index d184a621db5..6aa50ef5cbf 100644 --- a/addons/l10n_fr/data/account_chart_template_data.xml +++ b/addons/l10n_fr/data/account_chart_template_data.xml @@ -239,7 +239,7 @@ <record id="pcg_120" model="account.account.template"> <field name="name">Résultat de l'exercice (bénéfice)</field> <field name="code">120</field> - <field name="user_type_id" ref="account.data_unaffected_earnings"/> + <field name="user_type_id" ref="account.data_account_type_current_liabilities"/> <field name="chart_template_id" ref="l10n_fr_pcg_chart_template"/> </record> diff --git a/addons/l10n_nl/data/account_account_tag.xml b/addons/l10n_nl/data/account_account_tag.xml index 73520f6f031..f5519a03ee2 100644 --- a/addons/l10n_nl/data/account_account_tag.xml +++ b/addons/l10n_nl/data/account_account_tag.xml @@ -158,7 +158,7 @@ <field name="applicability">taxes</field> </record> <record id="tag_nl_06" model="account.account.tag"> - <field name="name">1c. Leveringen/diensten belast met overige tarieven behalve 0% (omzet)</field> + <field name="name">1c. Leveringen/diensten belast met overige tarieven, behalve 0% (omzet)</field> <field name="applicability">taxes</field> </record> <record id="tag_nl_07" model="account.account.tag"> @@ -174,7 +174,7 @@ <field name="applicability">taxes</field> </record> <record id="tag_nl_10" model="account.account.tag"> - <field name="name">2a. Leveringen/diensten waarbij de heffing van omzetbelasting naar u is verlegd (omzet)</field> + <field name="name">2a. Leveringen/diensten waarbij de omzetbelasting naar u is verlegd (omzet)</field> <field name="applicability">taxes</field> </record> <record id="tag_nl_11" model="account.account.tag"> @@ -187,11 +187,11 @@ </record> <!-- Keep tag_nl_40 & tag_nl_41 to avoid migration issue. --> <record id="tag_nl_40" model="account.account.tag"> - <field name="name">3b. Leveringen naar/diensten in landen binnen de EU (omzet) (producten)</field> + <field name="name">3b. Leveringen naar of diensten in landen binnen de EU (omzet) (producten)</field> <field name="applicability">taxes</field> </record> <record id="tag_nl_41" model="account.account.tag"> - <field name="name">3b. Leveringen naar/diensten in landen binnen de EU (omzet) (diensten)</field> + <field name="name">3b. Leveringen naar of diensten in landen binnen de EU (omzet) (diensten)</field> <field name="applicability">taxes</field> </record> <record id="tag_nl_14" model="account.account.tag"> @@ -231,7 +231,7 @@ <field name="applicability">taxes</field> </record> <record id="tag_nl_23" model="account.account.tag"> - <field name="name">1c. Leveringen/diensten belast met overige tarieven behalve 0% (BTW)</field> + <field name="name">1c. Leveringen/diensten belast met overige tarieven, behalve 0% (BTW)</field> <field name="applicability">taxes</field> </record> <record id="tag_nl_24" model="account.account.tag"> @@ -247,7 +247,7 @@ <field name="applicability">taxes</field> </record> <record id="tag_nl_27" model="account.account.tag"> - <field name="name">2a. Leveringen/diensten waarbij de heffing van Heffing van omzetbelasting naar u is verlegd (BTW)</field> + <field name="name">2a. Leveringen/diensten waarbij de omzetbelasting naar u is verlegd (BTW)</field> <field name="applicability">taxes</field> </record> <record id="tag_nl_28" model="account.account.tag"> @@ -297,3 +297,4 @@ </data> </odoo> + diff --git a/addons/l10n_nl/data/account_tax_template.xml b/addons/l10n_nl/data/account_tax_template.xml index 0eccb372d52..44f42f7d71b 100644 --- a/addons/l10n_nl/data/account_tax_template.xml +++ b/addons/l10n_nl/data/account_tax_template.xml @@ -331,8 +331,6 @@ <field name="description">21% BTW verlegd</field> <field eval="21" name="amount"/> <field name="amount_type">group</field> - <field name="refund_account_id" ref="vat_payable_v"/> - <field name="account_id" ref="vat_payable_v"/> <field name="type_tax_use">purchase</field> <field name="tag_ids" eval="[(6,0,[ref('tag_nl_10')])]"/> <field name="children_tax_ids" eval="[(6, 0, [ref('btw_ink_0_1'), ref('btw_ink_0_2')])]"/> diff --git a/addons/mail/data/mail_data.xml b/addons/mail/data/mail_data.xml index d2083aa1b65..a566d1e82ac 100644 --- a/addons/mail/data/mail_data.xml +++ b/addons/mail/data/mail_data.xml @@ -264,8 +264,8 @@ Best regards, </div> <div>&nbsp;</div> - <div t-if="record.user_id and record.user_id.signature" style="font-size: 13px;"> - <div t-raw="record.user_id.signature"/> + <div t-if="record.user_id and record.user_id.sudo().signature" style="font-size: 13px;"> + <div t-raw="record.user_id.sudo().signature"/> </div> </td> </tr> diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index 77469f2fe94..51e87a86982 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -534,6 +534,16 @@ class Message(models.Model): failures_infos = [] # for each channel, build the information header and include the logged partner information for message in self: + # Check if user has access to the record before displaying a notification about it. + # In case the user switches from one company to another, it might happen that he doesn't + # have access to the record related to the notification. In this case, we skip it. + if message.model and message.res_id: + record = self.env[message.model].browse(message.res_id) + try: + record.check_access_rights('read') + record.check_access_rule('read') + except AccessError: + continue info = { 'message_id': message.id, 'record_name': message.record_name, diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index a27a8464c9c..82d45e8dce3 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -1335,7 +1335,8 @@ class MailThread(models.AbstractModel): subtype = 'mail.mt_note' if message_dict.get('parent_id'): parent_message = self.env['mail.message'].sudo().browse(message_dict['parent_id']) - partner_ids = [(4, parent_message.author_id.id)] + if parent_message.author_id: + partner_ids = [(4, parent_message.author_id.id)] else: subtype = 'mail.mt_comment' @@ -2062,7 +2063,7 @@ class MailThread(models.AbstractModel): prioritary_attachments = all_attachments.filtered(lambda x: x.mimetype.endswith('pdf')) \ or all_attachments.filtered(lambda x: x.mimetype.startswith('image')) \ or all_attachments - self.write({'message_main_attachment_id': prioritary_attachments[0].id}) + self.sudo().write({'message_main_attachment_id': prioritary_attachments[0].id}) # Notify recipients of the newly-created message (Inbox / Email + channels) if msg_vals.get('moderation_status') != 'pending_moderation': message._notify( diff --git a/addons/mail/static/src/js/activity.js b/addons/mail/static/src/js/activity.js index b1f43e495c7..d99abb11e7d 100644 --- a/addons/mail/static/src/js/activity.js +++ b/addons/mail/static/src/js/activity.js @@ -273,7 +273,7 @@ var BasicActivity = AbstractField.extend({ var self = this; var $markDoneBtn = $(ev.currentTarget); var activityID = $markDoneBtn.data('activity-id'); - var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id'); + var previousActivityTypeID = $markDoneBtn.data('previous-activity-type-id') || false; var forceNextActivity = $markDoneBtn.data('force-next-activity'); if ($markDoneBtn.data('toggle') == 'collapse') { diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index da23521f65f..330e56b77b4 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -300,6 +300,7 @@ var Chatter = Widget.extend({ recordName: this.recordName, defaultBody: oldComposer && oldComposer.$input && oldComposer.$input.val(), defaultMentionSelections: oldComposer && oldComposer.getMentionListenerSelections(), + attachmentIds: (oldComposer && oldComposer.get('attachment_ids')) || [], }); this._composer.on('input_focused', this, function () { this._composer.mentionSetPrefetchedPartners(this._mentionSuggestions || []); @@ -309,9 +310,7 @@ var Chatter = Widget.extend({ if (oldComposer) { oldComposer.destroy(); } - if (!config.device.isMobile) { - self._composer.focus(); - } + self._composer.focus(); self._composer.on('post_message', self, function (messageData) { self._discardOnReload(messageData).then(function () { self._disableComposer(); @@ -321,7 +320,7 @@ var Chatter = Widget.extend({ self.trigger_up('reload'); } else if (messageData.attachment_ids.length) { self._reloadAttachmentBox(); - self.trigger_up('reload', {fieldNames: ['message_attachment_count']}); + self.trigger_up('reload', {fieldNames: ['message_attachment_count'], keepChanges: true}); } }).fail(function () { self._enableComposer(); diff --git a/addons/mail/static/src/js/composers/basic_composer.js b/addons/mail/static/src/js/composers/basic_composer.js index af503ff2711..eef11bbab33 100644 --- a/addons/mail/static/src/js/composers/basic_composer.js +++ b/addons/mail/static/src/js/composers/basic_composer.js @@ -58,7 +58,7 @@ var BasicComposer = Widget.extend({ // Attachments this._attachmentDataSet = new data.DataSetSearch(this, 'ir.attachment', this.context); this.fileuploadID = _.uniqueId('o_chat_fileupload'); - this.set('attachment_ids', []); + this.set('attachment_ids', options.attachmentIds || []); // Mention this._mentionManager = new MentionManager(this); @@ -120,6 +120,7 @@ var BasicComposer = Widget.extend({ }); // Attachments + this._renderAttachments(); $(window).on(this.fileuploadID, this._onAttachmentLoaded.bind(this)); this.on('change:attachment_ids', this, this._renderAttachments); diff --git a/addons/mail/static/src/js/services/mail_window_manager.js b/addons/mail/static/src/js/services/mail_window_manager.js index c8e05957afb..4971e7f3f4b 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -101,31 +101,37 @@ MailManager.include({ // valid threadID, therefore no check var thread = this.getThread(threadID); var threadWindow = this._getThreadWindow(threadID); + var def = $.when(); if (!threadWindow) { - threadWindow = this._makeNewThreadWindow(thread, options); - this._placeNewThreadWindow(threadWindow, options.passively); - - threadWindow.appendTo($(this.THREAD_WINDOW_APPENDTO)) - .then(function () { - self._repositionThreadWindows(); - return thread.fetchMessages(); - }).then(function () { - threadWindow.render(); - threadWindow.scrollToBottom(); - if ( - !self._areAllThreadWindowsHidden() && - !thread.isFolded() && - !threadWindow.isPassive() - ) { - thread.markAsRead(); - } - }); + def = thread.fetchMessages().then(function () { + threadWindow = self._makeNewThreadWindow(thread, options); + self._placeNewThreadWindow(threadWindow, options.passively); + return threadWindow.appendTo($(self.THREAD_WINDOW_APPENDTO)); + }).then(function () { + self._repositionThreadWindows(); + threadWindow.render(); + threadWindow.scrollToBottom(); + if ( + !self._areAllThreadWindowsHidden() && + !thread.isFolded() && + !threadWindow.isPassive() + ) { + thread.markAsRead(); + } + }).fail(function () { + // thread window could not be open, which may happen due to + // access error while fetching messages to the document. + // abort opening the thread window in this case. + thread.close(); + }); } else if (!options.passively) { if (threadWindow.isHidden()) { this._makeThreadWindowVisible(threadWindow); } } - threadWindow.updateVisualFoldState(); + def.then(function () { + threadWindow.updateVisualFoldState(); + }); }, /** * Called when a thread has its window state that has been changed, so its diff --git a/addons/mail/static/src/js/systray/systray_activity_menu.js b/addons/mail/static/src/js/systray/systray_activity_menu.js index 2b42167861c..593cac91506 100644 --- a/addons/mail/static/src/js/systray/systray_activity_menu.js +++ b/addons/mail/static/src/js/systray/systray_activity_menu.js @@ -46,7 +46,7 @@ var ActivityMenu = Widget.extend({ kwargs: {context: session.user_context}, }).then(function (data) { self._activities = data; - self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count; }, 0); + self.activityCounter = _.reduce(data, function (total_count, p_data) { return total_count + p_data.total_count || 0; }, 0); self.$('.o_notification_counter').text(self.activityCounter); self.$el.toggleClass('o_no_notification', !self.activityCounter); }); diff --git a/addons/mail/static/src/js/thread_windows/thread_window.js b/addons/mail/static/src/js/thread_windows/thread_window.js index 57907a55943..331228c91c8 100644 --- a/addons/mail/static/src/js/thread_windows/thread_window.js +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -280,8 +280,10 @@ var ThreadWindow = AbstractThreadWindow.extend({ clear_breadcrumbs: false, active_id: this.hasThread() ? this._getThreadID() : undefined, on_reverse_breadcrumb: function () { - self.call('mail_service', 'getMailBus') - .trigger('discuss_open', false); + var mailBus = self.call('mail_service', 'getMailBus'); + if (mailBus) { + mailBus.trigger('discuss_open', false); + } }, }); } diff --git a/addons/mail/static/src/scss/followers.scss b/addons/mail/static/src/scss/followers.scss index b2bbc77f7f2..934df9b7acc 100644 --- a/addons/mail/static/src/scss/followers.scss +++ b/addons/mail/static/src/scss/followers.scss @@ -84,6 +84,8 @@ .o_followers_list { font-size: small; + max-height: 50vh; // ancestor has size 33px + overflow: auto; .o_partner { position: relative; // required for old design compatibility diff --git a/addons/mail/static/tests/document_thread_window_tests.js b/addons/mail/static/tests/document_thread_window_tests.js index 811b01f62e4..93095362306 100644 --- a/addons/mail/static/tests/document_thread_window_tests.js +++ b/addons/mail/static/tests/document_thread_window_tests.js @@ -271,6 +271,37 @@ QUnit.test('open, fold, unfold and close a document thread window', function (as messagingMenu.destroy(); }); +QUnit.test('do not open thread window on fetch message failure', function (assert) { + // this may happen when the user receives a notification from a document + // that he does not have access rights at the moment. + assert.expect(4); + + var messagingMenu = new MessagingMenu(); + testUtils.addMockEnvironment(messagingMenu, { + services: this.services, + data: this.data, + session: this.session, + mockRPC: function (route, args) { + if (args.method === 'read' && args.model === 'some.res.model' && args.args[0][0] === 1) { + assert.step('some.res.model/1/read'); + return $.Deferred().reject(); // simulate failure + } + return this._super.apply(this, arguments); + }, + }); + messagingMenu.appendTo($('#qunit-fixture')); + + testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + assert.containsOnce(messagingMenu, '.o_mail_preview'); + + testUtils.dom.click(messagingMenu.$('.o_mail_preview')); + assert.verifySteps(['some.res.model/1/read']); + assert.strictEqual($('.o_thread_window').length, 0, + "should not have open the DocumentThread in a thread window on fetch messages failure"); + + messagingMenu.destroy(); +}); + }); }); }); diff --git a/addons/mass_mailing/models/__init__.py b/addons/mass_mailing/models/__init__.py index 6afa7a98d09..c542abccfba 100644 --- a/addons/mass_mailing/models/__init__.py +++ b/addons/mass_mailing/models/__init__.py @@ -4,6 +4,7 @@ from . import mass_mailing from . import mass_mailing_stats from . import mail_mail +from . import mail_template from . import mail_thread from . import res_config_settings from . import mass_mailing_report diff --git a/addons/mass_mailing/models/mail_template.py b/addons/mass_mailing/models/mail_template.py new file mode 100644 index 00000000000..68005ee0d10 --- /dev/null +++ b/addons/mass_mailing/models/mail_template.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class MailTemplate(models.Model): + _inherit = "mail.template" + + @api.model + def render_post_process(self, html): + # super will transform relative url to absolute + html = super(MailTemplate, self).render_post_process(html) + + # apply shortener after + if self.env.context.get('post_convert_links'): + html = self.env['link.tracker'].convert_links( + html, + self.env.context['post_convert_links'], + blacklist=['/unsubscribe_from_list'] + ) + return html diff --git a/addons/mass_mailing/models/mass_mailing.py b/addons/mass_mailing/models/mass_mailing.py index a4187e0ec6e..943059eb6d9 100644 --- a/addons/mass_mailing/models/mass_mailing.py +++ b/addons/mass_mailing/models/mass_mailing.py @@ -779,19 +779,47 @@ class MassMailing(models.Model): _logger.info("Mass-mailing %s targets %s, no opt out list available", self, target._name) return opt_out + def _get_convert_links(self): + self.ensure_one() + utm_mixin = self.mass_mailing_campaign_id if self.mass_mailing_campaign_id else self + vals = {'mass_mailing_id': self.id} + + if self.mass_mailing_campaign_id: + vals['mass_mailing_campaign_id'] = self.mass_mailing_campaign_id.id + if utm_mixin.campaign_id: + vals['campaign_id'] = utm_mixin.campaign_id.id + if utm_mixin.source_id: + vals['source_id'] = utm_mixin.source_id.id + if utm_mixin.medium_id: + vals['medium_id'] = utm_mixin.medium_id.id + return vals + def _get_seen_list(self): """Returns a set of emails already targeted by current mailing/campaign (no duplicates)""" self.ensure_one() target = self.env[self.mailing_model_real] - mail_field = 'email' if 'email' in target._fields else 'email_from' - # avoid loading a large number of records in memory - # + use a basic heuristic for extracting emails - query = """ - SELECT lower(substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) - FROM mail_mail_statistics s - JOIN %(target)s t ON (s.res_id = t.id) - WHERE substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL - """ + if set(['email', 'email_from']) & set(target._fields): + mail_field = 'email' if 'email' in target._fields else 'email_from' + # avoid loading a large number of records in memory + # + use a basic heuristic for extracting emails + query = """ + SELECT lower(substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) + FROM mail_mail_statistics s + JOIN %(target)s t ON (s.res_id = t.id) + WHERE substring(t.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL + """ + elif 'partner_id' in target._fields: + mail_field = 'email' + query = """ + SELECT lower(substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)')) + FROM mail_mail_statistics s + JOIN %(target)s t ON (s.res_id = t.id) + JOIN res_partner p ON (t.partner_id = p.id) + WHERE substring(p.%(mail_field)s, '([^ ,;<@]+@[^> ,;]+)') IS NOT NULL + """ + else: + raise UserError(_("Unsupported mass mailing model %s") % self.mailing_model_id.name) + if self.mass_mailing_campaign_id.unique_ab_testing: query +=""" AND s.mass_mailing_campaign_id = %%(mailing_campaign_id)s; @@ -814,6 +842,7 @@ class MassMailing(models.Model): return { 'mass_mailing_opt_out_list': self._get_opt_out_list(), 'mass_mailing_seen_list': self._get_seen_list(), + 'post_convert_links': self._get_convert_links(), } def get_recipients(self): @@ -855,13 +884,10 @@ class MassMailing(models.Model): if not res_ids: raise UserError(_('There is no recipients selected.')) - # Convert links in absolute URLs before the application of the shortener - mailing.body_html = self.env['mail.thread']._replace_local_links(mailing.body_html) - composer_values = { 'author_id': author_id, 'attachment_ids': [(4, attachment.id) for attachment in mailing.attachment_ids], - 'body': mailing.convert_links()[mailing.id], + 'body': mailing.body_html, 'subject': mailing.name, 'model': mailing.mailing_model_real, 'email_from': mailing.email_from, diff --git a/addons/mass_mailing/tests/__init__.py b/addons/mass_mailing/tests/__init__.py index b02648f85eb..36745d1525a 100644 --- a/addons/mass_mailing/tests/__init__.py +++ b/addons/mass_mailing/tests/__init__.py @@ -2,3 +2,4 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_mass_mailing_list_merge +from . import test_mass_mailing_shortener diff --git a/addons/mass_mailing/tests/test_mass_mailing_shortener.py b/addons/mass_mailing/tests/test_mass_mailing_shortener.py new file mode 100644 index 00000000000..99e3de1e864 --- /dev/null +++ b/addons/mass_mailing/tests/test_mass_mailing_shortener.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common +from lxml import etree + +try: + from unittest.mock import patch +except ImportError: + from mock import patch + + +class TestMassMailingShortener(common.TransactionCase): + def getHrefFor(self, html, id): + return html.xpath("*[@id='%s']" % id)[0].attrib.get('href') + + def shorturl_to_link(self, short_url): + return self.env['link.tracker.code'].search([('code', '=', short_url.split('/r/')[-1])]).link_id + + def setUp(self): + super(TestMassMailingShortener, self).setUp() + + def _get_title_from_url(u): + return "Hello" + + def _compute_favicon(): + # 1px to avoid real request + return 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==' + + patcher = patch('odoo.addons.link_tracker.models.link_tracker.link_tracker._compute_favicon', wraps=_compute_favicon) + patcher2 = patch('odoo.addons.link_tracker.models.link_tracker.link_tracker._get_title_from_url', wraps=_get_title_from_url) + patcher.start() + patcher2.start() + self.addCleanup(patcher.stop) + self.addCleanup(patcher2.stop) + + def test_00_test_mass_mailing_shortener(self): + mailing_list_A = self.env['mail.mass_mailing.list'].create({ + 'name': 'A', + }) + self.env['mail.mass_mailing.contact'].create({ + 'name': 'User 1', 'email': 'user1@example.com', 'list_ids': [(4, mailing_list_A.id)] + }) + self.env['mail.mass_mailing.contact'].create({ + 'name': 'User 2', 'email': 'user2@example.com', 'list_ids': [(4, mailing_list_A.id)] + }) + self.env['mail.mass_mailing.contact'].create({ + 'name': 'User 3', 'email': 'user3@example.com', 'list_ids': [(4, mailing_list_A.id)] + }) + + mass_mailing = self.env['mail.mass_mailing'].create({ + "reply_to_mode": "email", + "reply_to": "Administrator <admin@yourcompany.example.com>", + "mailing_model_id": self.env.ref('mass_mailing.model_mail_mass_mailing_list').id, + "mailing_domain": "[('list_ids', 'in', [%d])]" % mailing_list_A.id, + "contact_list_ids": [[6, False, [mailing_list_A.id]]], + "mass_mailing_campaign_id": False, + "name": "sdf", + "body_html": """ +Hi, +% set url = "www.odoo.com" +% set httpurl = "https://www.odoo.eu" +Website0: <a id="url0" href="https://www.odoo.tz/my/${object.name}">https://www.odoo.tz/my/${object.name}</h1> +Website1: <a id="url1" href="https://www.odoo.be">https://www.odoo.be</h1> +Website2: <a id="url2" href="https://${url}">https://${url}</h1> +Website3: <a id="url3" href="${httpurl}">${httpurl}</h1> +Email: <a id="url4" href="mailto:test@odoo.com">test@odoo.com</h1> + """, + "schedule_date": False, + "state": "draft", + "keep_archives": True, + }) + + mass_mailing.put_in_queue() + mass_mailing._process_mass_mailing_queue() + + sent_mails = self.env['mail.mail'].search([('mailing_id', '=', mass_mailing.id)]) + sent_messages = sent_mails.mapped('mail_message_id') + + self.assertEqual(mailing_list_A.contact_nbr, len(sent_messages), + 'Some message has not been sent') + + xbody = etree.fromstring(sent_messages[0].body) + after_url0 = self.getHrefFor(xbody, 'url0') + after_url1 = self.getHrefFor(xbody, 'url1') + after_url2 = self.getHrefFor(xbody, 'url2') + after_url3 = self.getHrefFor(xbody, 'url3') + after_url4 = self.getHrefFor(xbody, 'url4') + + self.assertTrue('/r/' in after_url0, 'URL0 should be shortened: %s' % after_url0) + self.assertTrue('/r/' in after_url1, 'URL1 should be shortened: %s' % after_url1) + self.assertTrue('/r/' in after_url2, 'URL2 should be shortened: %s' % after_url2) + self.assertTrue('/r/' in after_url3, 'URL3 should be shortened: %s' % after_url3) + self.assertEqual(after_url4, "mailto:test@odoo.com", 'mailto: has been converted') + + short0 = self.shorturl_to_link(after_url0) + short1 = self.shorturl_to_link(after_url1) + short2 = self.shorturl_to_link(after_url2) + short3 = self.shorturl_to_link(after_url3) + + self.assertTrue("https://www.odoo.tz/my/User" in short0.url, 'URL mismatch') + self.assertEqual(short1.url, "https://www.odoo.be", 'URL mismatch') + self.assertEqual(short2.url, "https://www.odoo.com", 'URL mismatch') + self.assertEqual(short3.url, "https://www.odoo.eu", 'URL mismatch') + + _xbody = etree.fromstring(sent_messages[1].body) + _after_url0 = self.getHrefFor(_xbody, 'url0') + _after_url1 = self.getHrefFor(_xbody, 'url1') + _after_url2 = self.getHrefFor(_xbody, 'url2') + _after_url3 = self.getHrefFor(_xbody, 'url3') + _after_url4 = self.getHrefFor(_xbody, 'url4') + + self.assertTrue('/r/' in _after_url0, 'URL0 should be shortened: %s' % _after_url0) + self.assertTrue('/r/' in _after_url1, 'URL1 should be shortened: %s' % _after_url1) + self.assertTrue('/r/' in _after_url2, 'URL2 should be shortened: %s' % _after_url2) + self.assertTrue('/r/' in _after_url3, 'URL3 should be shortened: %s' % _after_url3) + self.assertEqual(_after_url4, "mailto:test@odoo.com", 'mailto: has been converted') + + _short0 = self.shorturl_to_link(_after_url0) + _short1 = self.shorturl_to_link(_after_url1) + _short2 = self.shorturl_to_link(_after_url2) + _short3 = self.shorturl_to_link(_after_url3) + + self.assertTrue("https://www.odoo.tz/my/User" in _short0.url, 'URL mismatch') + self.assertEqual(_short1.url, "https://www.odoo.be", 'URL mismatch') + self.assertEqual(_short2.url, "https://www.odoo.com", 'URL mismatch') + self.assertEqual(_short3.url, "https://www.odoo.eu", 'URL mismatch') + + self.assertNotEqual(short0.url, _short0.url) + self.assertEqual(short1.url, _short1.url) + self.assertEqual(short2.url, _short2.url) + self.assertEqual(short3.url, _short3.url) diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 6310ffd8b82..c7942656e4d 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -5,7 +5,7 @@ from collections import defaultdict from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.tools import float_compare, float_round class MrpProduction(models.Model): @@ -30,6 +30,10 @@ class MrpProduction(models.Model): location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_src_id if not location: location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + location.check_access_rule('read') + except (AttributeError, AccessError): + location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id return location and location.id or False @api.model @@ -39,6 +43,10 @@ class MrpProduction(models.Model): location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_dest_id if not location: location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + location.check_access_rule('read') + except (AttributeError, AccessError): + location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id return location and location.id or False name = fields.Char( @@ -397,6 +405,10 @@ class MrpProduction(models.Model): @api.onchange('picking_type_id') def onchange_picking_type(self): location = self.env.ref('stock.stock_location_stock') + try: + location.check_access_rule('read') + except (AttributeError, AccessError): + location = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id self.move_raw_ids.update({'picking_type_id': self.picking_type_id}) self.location_src_id = self.picking_type_id.default_location_src_id.id or location.id self.location_dest_id = self.picking_type_id.default_location_dest_id.id or location.id diff --git a/addons/mrp/models/mrp_unbuild.py b/addons/mrp/models/mrp_unbuild.py index e33e0dfbea5..2bac0c1d0f7 100644 --- a/addons/mrp/models/mrp_unbuild.py +++ b/addons/mrp/models/mrp_unbuild.py @@ -2,7 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.tools import float_compare @@ -13,10 +13,20 @@ class MrpUnbuild(models.Model): _order = 'id desc' def _get_default_location_id(self): - return self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + stock_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + stock_location.check_access_rule('read') + return stock_location.id + except (AttributeError, AccessError): + return self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id.id def _get_default_location_dest_id(self): - return self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + stock_location = self.env.ref('stock.stock_location_stock', raise_if_not_found=False) + try: + stock_location.check_access_rule('read') + return stock_location.id + except (AttributeError, AccessError): + return self.env['stock.warehouse'].search([('company_id', '=', self.env.user.company_id.id)], limit=1).lot_stock_id.id name = fields.Char('Reference', copy=False, readonly=True, default=lambda x: _('New')) product_id = fields.Many2one( diff --git a/addons/mrp/views/mrp_unbuild_views.xml b/addons/mrp/views/mrp_unbuild_views.xml index 58c1fb6014b..1a05dbc0b4a 100644 --- a/addons/mrp/views/mrp_unbuild_views.xml +++ b/addons/mrp/views/mrp_unbuild_views.xml @@ -128,8 +128,8 @@ </group> <group> <field name="mo_id" domain="[('state','=','done')]"/> - <field name="location_id" groups="stock.group_stock_multi_locations"/> - <field name="location_dest_id" groups="stock.group_stock_multi_locations"/> + <field name="location_id" groups="stock.group_stock_multi_locations" domain="[('usage','=','internal')]"/> + <field name="location_dest_id" groups="stock.group_stock_multi_locations" domain="[('usage','=','internal')]"/> <field name="has_tracking" invisible="1"/> <field name="lot_id" attrs="{'invisible': [('has_tracking', '=', 'none')], 'required': [('has_tracking', '!=', 'none')]}" groups="stock.group_production_lot"/> </group> diff --git a/addons/mrp/views/mrp_workcenter_views.xml b/addons/mrp/views/mrp_workcenter_views.xml index 8172349a27b..906cbc0070b 100644 --- a/addons/mrp/views/mrp_workcenter_views.xml +++ b/addons/mrp/views/mrp_workcenter_views.xml @@ -171,7 +171,7 @@ <div class="col-6 o_kanban_primary_left"> <div class="btn-group" name="o_wo"> <t t-if="record.workorder_count.raw_value > 0"> - <button class="btn btn-primary" name="%(action_work_orders)d" type="action" context="{'search_default_ready': 1, 'search_default_progress': 1, 'search_default_pending': 1}"> + <button class="btn btn-primary" name="%(action_work_orders)d" type="action" context="{'search_default_ready': 1, 'search_default_progress': 1}"> <span>WORK ORDERS</span> </button> </t> diff --git a/addons/pad/models/pad.py b/addons/pad/models/pad.py index 9cdcbec12af..6e3476099fd 100644 --- a/addons/pad/models/pad.py +++ b/addons/pad/models/pad.py @@ -18,6 +18,7 @@ _logger = logging.getLogger(__name__) class PadCommon(models.AbstractModel): _name = 'pad.common' + _description = 'Pad Common' @api.model def pad_is_configured(self): diff --git a/addons/payment/controllers/portal.py b/addons/payment/controllers/portal.py index ba2a2551892..516eb8712a9 100644 --- a/addons/payment/controllers/portal.py +++ b/addons/payment/controllers/portal.py @@ -100,7 +100,10 @@ class PaymentProcessing(http.Controller): class WebsitePayment(http.Controller): @http.route(['/my/payment_method'], type='http', auth="user", website=True) def payment_method(self, **kwargs): - acquirers = list(request.env['payment.acquirer'].search([('website_published', '=', True), ('registration_view_template_id', '!=', False), ('payment_flow', '=', 's2s')])) + acquirers = list(request.env['payment.acquirer'].search([ + ('website_published', '=', True), ('registration_view_template_id', '!=', False), + ('payment_flow', '=', 's2s'), ('company_id', '=', request.env.user.company_id.id) + ])) partner = request.env.user.partner_id payment_tokens = partner.payment_token_ids payment_tokens |= partner.commercial_partner_id.sudo().payment_token_ids diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml index bac8783fcea..8328fac20c8 100644 --- a/addons/payment/views/payment_portal_templates.xml +++ b/addons/payment/views/payment_portal_templates.xml @@ -34,7 +34,7 @@ <!-- TDE FIXME: was customize_show=True --> <template id="pay_meth_link" inherit_id="portal.portal_layout"> <xpath expr="//div[hasclass('o_portal_my_details')]" position="inside"> - <t t-if="request.env['payment.acquirer'].search([('website_published', '=', True), ('registration_view_template_id', '!=', False), ('payment_flow', '=', 's2s')])"> + <t t-if="request.env['payment.acquirer'].search([('website_published', '=', True), ('registration_view_template_id', '!=', False), ('payment_flow', '=', 's2s'), ('company_id', '=', request.env.user.company_id.id)])"> <div class='manage_payment_method mt16'><a href="/my/payment_method">Manage your payment methods</a></div> </t> </xpath> diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index e133f006f7c..364aa6cd96e 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -583,21 +583,21 @@ class PosOrder(models.Model): @api.onchange('statement_ids', 'lines') def _onchange_amount_all(self): - amounts = {order_id: {'paid': 0, 'return': 0, 'untaxed': 0} for order_id in self.ids} + amounts = {order_id: {'paid': 0, 'return': 0, 'taxed': 0, 'taxes': 0} for order_id in self.ids} for order in self.env['account.bank.statement.line'].read_group([('pos_statement_id', 'in', self.ids)], ['pos_statement_id', 'amount'], ['pos_statement_id']): amounts[order['pos_statement_id'][0]]['paid'] = order['amount'] for order in self.env['account.bank.statement.line'].read_group(['&', ('pos_statement_id', 'in', self.ids), ('amount', '<', 0)], ['pos_statement_id', 'amount'], ['pos_statement_id']): amounts[order['pos_statement_id'][0]]['return'] = order['amount'] - for order in self.env['pos.order.line'].read_group([('order_id', 'in', self.ids)], ['order_id', 'price_subtotal'], ['order_id']): - amounts[order['order_id'][0]]['untaxed'] = order['price_subtotal'] + for order in self.env['pos.order.line'].read_group([('order_id', 'in', self.ids)], ['order_id', 'price_subtotal', 'price_subtotal_incl'], ['order_id']): + amounts[order['order_id'][0]]['taxed'] = order['price_subtotal_incl'] + amounts[order['order_id'][0]]['taxes'] = order['price_subtotal_incl'] - order['price_subtotal'] for order in self: currency = order.pricelist_id.currency_id order.amount_paid = amounts[order.id]['paid'] order.amount_return = amounts[order.id]['return'] - order.amount_tax = currency.round(sum(self._amount_line_tax(line, order.fiscal_position_id) for line in order.lines)) - amount_untaxed = currency.round(amounts[order.id]['untaxed']) - order.amount_total = order.amount_tax + amount_untaxed + order.amount_tax = currency.round(amounts[order.id]['taxes']) + order.amount_total = currency.round(amounts[order.id]['taxed']) @api.onchange('partner_id') def _onchange_partner_id(self): diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index a1c1c11340c..912858825fb 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -876,12 +876,20 @@ exports.PosModel = Backbone.Model.extend({ transfer.pipe(function(order_server_id){ // generate the pdf and download it - self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{ - active_ids:order_server_id, - }}).done(function () { - invoiced.resolve(); - done.resolve(); - }); + if (order_server_id.length) { + self.chrome.do_action('point_of_sale.pos_invoice_report',{additional_context:{ + active_ids:order_server_id, + }}).done(function () { + invoiced.resolve(); + done.resolve(); + }); + } else { + // The order has been pushed separately in batch when + // the connection came back. + // The user has to go to the backend to print the invoice + invoiced.reject({code:401, message:'Backend Invoice', data:{order: order}}); + done.reject(); + } }); return done; diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index b82bcb38eb5..4420434229a 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -2062,6 +2062,17 @@ var PaymentScreenWidget = ScreenWidget.extend({ self.gui.show_screen('clientlist'); }, }); + } else if (error.message === 'Backend Invoice') { + self.gui.show_popup('confirm',{ + 'title': _t('Please print the invoice from the backend'), + 'body': _t('The order has been synchronized earlier. Please make the invoice from the backend for the order: ') + error.data.order.name, + confirm: function () { + this.gui.show_screen('receipt'); + }, + cancel: function () { + this.gui.show_screen('receipt'); + }, + }); } else if (error.code < 0) { // XmlHttpRequest Errors self.gui.show_popup('error',{ 'title': _t('The order could not be sent'), diff --git a/addons/portal/static/src/scss/portal.scss b/addons/portal/static/src/scss/portal.scss index 26beabb5865..4bbb741a653 100644 --- a/addons/portal/static/src/scss/portal.scss +++ b/addons/portal/static/src/scss/portal.scss @@ -162,6 +162,19 @@ hr { .container-fluid .container-fluid { @extend %o-double-container-no-padding; } +#wrap { + > .container, > .container-fluid { + // BS3 used to do this on all containers so that margins and floats are + // cleared inside containers. As lots of current odoo layouts may rely + // on this for some alignments, this is restored (at least for a while) + // here only for main containers of the frontend. + &::before, &::after { + content: ""; + display: table; + clear: both; + } + } +} [class^="col-lg-"] { min-height: 24px; } diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 02f118e2bae..34e9cf3a9ef 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -439,7 +439,7 @@ <t t-if='prev_record or next_record'> <div class="record_pager btn-group" role="group"> <a role="button" t-att-class="'btn btn-link %s' % ('disabled' if not prev_record else '')" t-att-href="prev_record or '#'" ><i class="fa fa-chevron-left" role="img" aria-label="Previous" title="Previous"></i></a> - <a role="button" t-att-class="'btn btn-link %s' % ('disabled' if not next_record else '')" t-att-href="next_record or '#'" ><i class="fa fa-chevron-right" role="img" aria-label="Next" title="Previous"></i></a> + <a role="button" t-att-class="'btn btn-link %s' % ('disabled' if not next_record else '')" t-att-href="next_record or '#'" ><i class="fa fa-chevron-right" role="img" aria-label="Next" title="Next"></i></a> </div> </t> </template> diff --git a/addons/pos_cache/models/pos_cache.py b/addons/pos_cache/models/pos_cache.py index 86dbfdfb39f..e7b779581d9 100644 --- a/addons/pos_cache/models/pos_cache.py +++ b/addons/pos_cache/models/pos_cache.py @@ -9,6 +9,7 @@ from odoo import models, fields, api class pos_cache(models.Model): _name = 'pos.cache' + _description = 'Point of Sale Cache' cache = fields.Binary(attachment=True) product_domain = fields.Text(required=True) diff --git a/addons/product/report/product_product_templates.xml b/addons/product/report/product_product_templates.xml index 2a612f556bc..f8213d924b0 100644 --- a/addons/product/report/product_product_templates.xml +++ b/addons/product/report/product_product_templates.xml @@ -1,75 +1,80 @@ -<?xml version="1.0" encoding="UTF-8"?>
-<odoo>
- <data>
- <template id="report_simple_label">
- <div class="col-4" style="padding:0;">
- <table class="table table-condensed" style="border-bottom: 0px solid white !important;width: 3in;">
- <tr>
- <th style="text-align: left;">
- <strong t-field="product.display_name"/>
- </th>
- </tr>
- <tr>
- <td>
- <strong>Price:</strong>
- <strong t-field="product.lst_price" t-options="{'widget': 'monetary', 'display_currency': product.company_id.currency_id}"/>
- </td>
- </tr>
- <t t-if="product.barcode">
- <tr>
- <td style="text-align: center; vertical-align: middle;" class="col-5">
- <img alt="Barcode" t-if="len(product.barcode) == 13" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <img alt="Barcode" t-elif="len(product.barcode) == 8" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN8', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <img alt="Barcode" t-else="" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('Code128', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <span t-field="product.barcode"/>
- </td>
- </tr>
- </t>
- </table>
- </div>
- </template>
- <template id="report_productlabel">
- <t t-call="web.basic_layout">
- <div class="page">
- <t t-foreach="docs" t-as="product">
- <t t-call="product.report_simple_label">
- <t t-set="product" t-value="product"/>
- </t>
- </t>
- </div>
- </t>
- </template>
- <template id="report_simple_barcode">
- <div class="col-4" style="padding:0;">
- <table class="table table-condensed" style="border-bottom: 0px solid white !important;width: 3in;">
- <tr>
- <th style="text-align: left;">
- <strong t-field="product.display_name"/>
- </th>
- </tr>
- <t t-if="product.barcode">
- <tr>
- <td style="text-align: center; vertical-align: middle;" class="col-5">
- <img alt="Barcode" t-if="len(product.barcode) == 13" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <img alt="Barcode" t-elif="len(product.barcode) == 8" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN8', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <img alt="Barcode" t-else="" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('Code128', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
- <span t-field="product.barcode"/>
- </td>
- </tr>
- </t>
- </table>
- </div>
- </template>
- <template id="report_productbarcode">
- <t t-call="web.basic_layout">
- <div class="page">
- <t t-foreach="docs" t-as="product">
- <t t-call="product.report_simple_barcode">
- <t t-set="product" t-value="product"/>
- </t>
- </t>
- </div>
- </t>
- </template>
- </data>
-</odoo>
+<?xml version="1.0" encoding="UTF-8"?> +<odoo> + <data> + <template id="report_simple_label"> + <div style="width: 32%; display: inline-table; height:14rem;"> + <table class="table table-bordered mb-0" style="border: 2px solid black;"> + <tr> + <th class="table-active text-left" style="height: 4rem;"> + <strong t-field="product.display_name"/> + </th> + </tr> + <tr> + <td style="height: 2rem"> + <strong>Price:</strong> + <strong t-field="product.lst_price" t-options="{'widget': 'monetary', 'display_currency': product.company_id.currency_id}"/> + </td> + </tr> + <tr> + <td class="text-center align-middle" style="height: 6rem"> + <t t-if="product.barcode"> + <img alt="Barcode" t-if="len(product.barcode) == 13" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height::4rem;"/> + <img alt="Barcode" t-elif="len(product.barcode) == 8" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN8', product.barcode, 600, 150)" style="width:100%;height::4rem;"/> + <img alt="Barcode" t-else="" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('Code128', product.barcode, 600, 150)" style="width:100%;height::4rem;"/> + <span t-field="product.barcode"/> + </t> + <t t-else=""><span class="text-muted">No barcode available</span></t> + </td> + </tr> + </table> + </div> + </template> + + <template id="report_productlabel"> + <t t-call="web.basic_layout"> + <div class="page"> + <t t-foreach="docs" t-as="product"> + <t t-call="product.report_simple_label"> + <t t-set="product" t-value="product"/> + </t> + </t> + </div> + </t> + </template> + + <template id="report_simple_barcode"> + <div style="width: 32%; display: inline-table; height: 10rem;"> + <table class="table table-bordered mb-0" style="border: 2px solid black;"> + <tr> + <th class="table-active text-left" style="height: 4rem;"> + <strong t-field="product.display_name"/> + </th> + </tr> + <tr> + <td class="text-center align-middle" style="height: 6rem;"> + <t t-if="product.barcode"> + <img alt="Barcode" t-if="len(product.barcode) == 13" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height:4rem;"/> + <img alt="Barcode" t-elif="len(product.barcode) == 8" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN8', product.barcode, 600, 150)" style="width:100%;height:4rem;"/> + <img alt="Barcode" t-else="" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('Code128', product.barcode, 600, 150)" style="width:100%;height:4rem"/> + <span t-field="product.barcode"/> + </t> + <t t-else=""><span class="text-muted">No barcode available</span></t> + </td> + </tr> + </table> + </div> + </template> + + <template id="report_productbarcode"> + <t t-call="web.basic_layout"> + <div class="page"> + <t t-foreach="docs" t-as="product"> + <t t-call="product.report_simple_barcode"> + <t t-set="product" t-value="product"/> + </t> + </t> + </div> + </t> + </template> + </data> +</odoo> diff --git a/addons/rating/models/mail_message.py b/addons/rating/models/mail_message.py index f6b3553b7f4..aade3b9ade7 100644 --- a/addons/rating/models/mail_message.py +++ b/addons/rating/models/mail_message.py @@ -8,7 +8,7 @@ class MailMessage(models.Model): _inherit = 'mail.message' rating_ids = fields.One2many('rating.rating', 'message_id', string='Related ratings') - rating_value = fields.Float("Rating Value", compute='_compute_rating_value', store=False) + rating_value = fields.Float("Rating Value", compute='_compute_rating_value', store=False, search='_search_rating_value') @api.multi @api.depends('rating_ids', 'rating_ids.rating') @@ -17,3 +17,7 @@ class MailMessage(models.Model): mapping = dict((r.message_id.id, r.rating) for r in ratings) for message in self: message.rating_value = mapping.get(message.id, 0.0) + + @api.multi + def _search_rating_value(self, operator, operand): + return [('rating_ids.rating', operator, operand)] diff --git a/addons/resource/models/resource.py b/addons/resource/models/resource.py index b16c121799b..fd0e8260fc5 100644 --- a/addons/resource/models/resource.py +++ b/addons/resource/models/resource.py @@ -196,7 +196,8 @@ class ResourceCalendar(models.Model): hour_count = 0.0 for attendance in attendances: hour_count += attendance.hour_to - attendance.hour_from - self.hours_per_day = float_round(hour_count / float(len(set(attendances.mapped('dayofweek')))), precision_digits=2) + if attendances: + self.hours_per_day = float_round(hour_count / float(len(set(attendances.mapped('dayofweek')))), precision_digits=2) # -------------------------------------------------- # Computation API diff --git a/addons/sale/data/mail_data.xml b/addons/sale/data/mail_data.xml index 5edabafb06b..a2abc42ff23 100644 --- a/addons/sale/data/mail_data.xml +++ b/addons/sale/data/mail_data.xml @@ -103,7 +103,7 @@ </div> </field> <field name="report_template" ref="action_report_saleorder"/> - <field name="report_name">${(object.name or '').replace('/','_')}${object.state == 'draft' and '_draft' or ''}</field> + <field name="report_name">${(object.name or '').replace('/','_')}</field> <field name="lang">${object.partner_id.lang}</field> <field name="user_signature" eval="False"/> <field name="auto_delete" eval="True"/> diff --git a/addons/sale/models/analytic.py b/addons/sale/models/analytic.py index 8fb938582a0..a60dd11b023 100644 --- a/addons/sale/models/analytic.py +++ b/addons/sale/models/analytic.py @@ -19,7 +19,7 @@ class AccountAnalyticLine(models.Model): @api.model def create(self, values): result = super(AccountAnalyticLine, self).create(values) - if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy != 'no' and result.amount <= 0: # allow to force a False value for so_line + if 'so_line' not in values and not result.so_line and result.product_id and result.product_id.expense_policy not in [False, 'no'] and result.amount <= 0: # allow to force a False value for so_line result.sudo()._sale_determine_order_line() return result @@ -28,7 +28,8 @@ class AccountAnalyticLine(models.Model): result = super(AccountAnalyticLine, self).write(values) if 'so_line' not in values: # allow to force a False value for so_line # only take the AAL from expense or vendor bill, meaning having a negative amount - self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no' and aal.amount <= 0)._sale_determine_order_line() + self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy not in [False, 'no'] and aal.amount <= 0)._sale_determine_order_line() + return result # ---------------------------------------------------------- # Vendor Bill / Expense : determine the Sale Order to reinvoice diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index f4b4ceb6826..da8ee7fa25c 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -47,8 +47,8 @@ class SaleOrder(models.Model): amount_untaxed += line.price_subtotal amount_tax += line.price_tax order.update({ - 'amount_untaxed': order.pricelist_id.currency_id.round(amount_untaxed), - 'amount_tax': order.pricelist_id.currency_id.round(amount_tax), + 'amount_untaxed': amount_untaxed, + 'amount_tax': amount_tax, 'amount_total': amount_untaxed + amount_tax, }) @@ -66,12 +66,14 @@ class SaleOrder(models.Model): for possible refunds created directly from existing invoices. This is necessary since such a refund is not directly linked to the SO. """ + # Ignore the status of the deposit product + deposit_product_id = self.env['sale.advance.payment.inv']._default_product_id() + line_invoice_status_all = [(d['order_id'][0], d['invoice_status']) for d in self.env['sale.order.line'].read_group([('order_id', 'in', self.ids), ('product_id', '!=', deposit_product_id.id)], ['order_id', 'invoice_status'], ['order_id', 'invoice_status'], lazy=False)] for order in self: invoice_ids = order.order_line.mapped('invoice_lines.invoice_id').filtered(lambda r: r.type in ['out_invoice', 'out_refund']) - # Ignore the status of the deposit product - deposit_product_id = self.env['sale.advance.payment.inv']._default_product_id() - line_invoice_status = [line.invoice_status for line in order.order_line if line.product_id != deposit_product_id] + + line_invoice_status = [d[1] for d in line_invoice_status_all if d[0] == order.id] if order.state not in ('sale', 'done'): invoice_status = 'no' @@ -652,7 +654,7 @@ class SaleOrder(models.Model): # create an analytic account if at least an expense product for order in self: - if any([expense_policy != 'no' for expense_policy in order.order_line.mapped('product_id.expense_policy')]): + if any([expense_policy not in [False, 'no'] for expense_policy in order.order_line.mapped('product_id.expense_policy')]): if not order.analytic_account_id: order._create_analytic_account() @@ -1016,7 +1018,7 @@ class SaleOrderLine(models.Model): msg = _("Extra line with %s ") % (line.product_id.display_name,) line.order_id.message_post(body=msg) # create an analytic account if at least an expense product - if line.product_id.expense_policy != 'no' and not self.order_id.analytic_account_id: + if line.product_id.expense_policy not in [False, 'no'] and not self.order_id.analytic_account_id: self.order_id._create_analytic_account() return line diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index ade6a2e3f52..d0f81c38a8a 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -6,7 +6,6 @@ name="Sales" web_icon="sale,static/description/icon.png" active="False" - groups="sales_team.group_sale_salesman,sales_team.group_sale_salesman_all_leads,sales_team.group_sale_manager" sequence="7"/> <menuitem id="sale_order_menu" @@ -17,6 +16,7 @@ <menuitem id="report_sales_team" name="Sales Teams" parent="sale_order_menu" + groups="sales_team.group_sale_manager" action="sales_team.crm_team_salesteams_act" sequence="3"/> @@ -314,6 +314,7 @@ <field name="qty_delivered_manual" invisible="1"/> <field name="qty_delivered_method" invisible="1"/> <field name="price_total" invisible="1"/> + <field name="price_tax" invisible="1"/> <field name="price_subtotal" invisible="1"/> <label for="product_uom_qty" string="Ordered Quantity"/> <div> @@ -322,6 +323,7 @@ name="product_uom_qty" class="oe_inline"/> <field name="product_uom" + force_save="1" groups="uom.group_uom" class="oe_inline oe_no_button" attrs="{ @@ -442,6 +444,7 @@ <field name="qty_to_invoice" invisible="1"/> <field name="product_uom" + force_save="1" attrs="{ 'readonly': [('state', 'in', ('sale','done', 'cancel'))], 'required': [('display_type', '=', False)], @@ -473,6 +476,7 @@ <field name="invoice_status" invisible="1"/> <field name="customer_lead" invisible="1"/> <field name="currency_id" invisible="1"/> + <field name="price_tax" invisible="1"/> </tree> <kanban class="o_kanban_mobile"> <field name="name"/> @@ -480,6 +484,7 @@ <field name="product_uom_qty"/> <field name="product_uom" groups="uom.group_uom"/> <field name="price_subtotal"/> + <field name="price_tax" invisible="1"/> <field name="price_total" invisible="1"/> <field name="price_unit"/> <field name="display_type"/> diff --git a/addons/sale_expense/models/analytic.py b/addons/sale_expense/models/analytic.py index 9f037bfcae1..d387091adc1 100644 --- a/addons/sale_expense/models/analytic.py +++ b/addons/sale_expense/models/analytic.py @@ -10,7 +10,7 @@ class AccountAnalyticLine(models.Model): @api.multi def _sale_determine_order(self): mapping = super(AccountAnalyticLine, self)._sale_determine_order() - for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy != 'no'): + for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.product_id and aal.product_id.expense_policy not in [False, 'no']): if analytic_line.move_id.expense_id.sale_order_id: mapping[analytic_line.id] = analytic_line.move_id.expense_id.sale_order_id return mapping diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 9f7613ccf51..75e7953e903 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -51,7 +51,7 @@ class SaleOrder(models.Model): def _action_confirm(self): """ On SO confirmation, some lines should generate a task or a project. """ result = super(SaleOrder, self)._action_confirm() - self.mapped('order_line').sudo()._timesheet_service_generation() + self.mapped('order_line').sudo().with_context(default_company_id=self.company_id.id)._timesheet_service_generation() return result @api.multi diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py index b92954e3199..25215366e04 100644 --- a/addons/stock/models/stock_move_line.py +++ b/addons/stock/models/stock_move_line.py @@ -143,7 +143,7 @@ class StockMoveLine(models.Model): """ res = {} if self.qty_done and self.product_id.tracking == 'serial': - if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0: + if float_compare(self.qty_done, 1.0, precision_rounding=self.product_id.uom_id.rounding) != 0: message = _('You can only process 1.0 %s of products with unique serial number.') % self.product_id.uom_id.name res['warning'] = {'title': _('Warning'), 'message': message} return res diff --git a/addons/stock/models/stock_scrap.py b/addons/stock/models/stock_scrap.py index 16f6b505acb..7c8eeefb57a 100644 --- a/addons/stock/models/stock_scrap.py +++ b/addons/stock/models/stock_scrap.py @@ -131,7 +131,8 @@ class StockScrap(models.Model): self.package_id, self.owner_id, strict=True).mapped('quantity')) - if float_compare(available_qty, self.scrap_qty, precision_digits=precision) >= 0: + scrap_qty = self.product_uom_id._compute_quantity(self.scrap_qty, self.product_id.uom_id) + if float_compare(available_qty, scrap_qty, precision_digits=precision) >= 0: return self.do_scrap() else: return { diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 2e2d171eddf..8e9ebbd980c 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -971,7 +971,7 @@ class Orderpoint(models.Model): # These days will be substracted when creating the PO days += self.product_id._select_seller( quantity=product_qty, - date=start_date, + date=fields.Date.context_today(self,start_date), uom_id=self.product_uom).delay or 0.0 date_planned = start_date + relativedelta.relativedelta(days=days) return date_planned.strftime(DEFAULT_SERVER_DATETIME_FORMAT) diff --git a/addons/stock/report/report_stockpicking_operations.xml b/addons/stock/report/report_stockpicking_operations.xml index 63bfbd8cc14..09715938ca3 100644 --- a/addons/stock/report/report_stockpicking_operations.xml +++ b/addons/stock/report/report_stockpicking_operations.xml @@ -155,7 +155,7 @@ </tbody> </table> <t t-set="no_reserved_product" t-value="o.move_lines.filtered(lambda x: x.product_uom_qty != x.reserved_availability and x.move_line_ids and x.state!='done')"/> - <p t-if="o.state in ['draft', 'waiting', 'confirmed'] or no_reserved_product" class="fa fa-exclamation-triangle"> + <p t-if="o.state in ['draft', 'waiting', 'confirmed'] or no_reserved_product"><i class="fa fa-exclamation-triangle" /> All products could not be reserved. Click on the "Check Availability" button to try to reserve products </p> <p t-field="o.note"/> diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py index 2037ec4f34e..0939389dda9 100644 --- a/addons/stock/tests/test_move.py +++ b/addons/stock/tests/test_move.py @@ -3569,6 +3569,24 @@ class StockMove(TransactionCase): self.assertEqual(scrap.state, 'done') self.assertEqual(move1.reserved_availability, 0.25) + def test_scrap_6(self): + """ Check that scrap correctly handle UoM. """ + self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1) + scrap = self.env['stock.scrap'].create({ + 'product_id': self.product1.id, + 'product_uom_id': self.uom_dozen.id, + 'scrap_qty': 1, + }) + warning_message = scrap.action_validate() + self.assertEqual(warning_message.get('res_model', 'Wrong Model'), 'stock.warn.insufficient.qty.scrap') + insufficient_qty_wizard = self.env['stock.warn.insufficient.qty.scrap'].create({ + 'product_id': self.product1.id, + 'location_id': self.stock_location.id, + 'scrap_id': scrap.id + }) + insufficient_qty_wizard.action_done() + self.assertEqual(self.env['stock.quant']._gather(self.product1, self.stock_location).quantity, -11) + def test_in_date_1(self): """ Check that moving a tracked quant keeps the incoming date. """ diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index 3a75e9744a0..8629dbff92c 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -220,7 +220,7 @@ <button name="%(act_stock_return_picking)d" string="Return" attrs="{'invisible': ['|', ('state', '!=', 'done'), ('is_locked', '=', False)]}" type="action" groups="base.group_user"/> <button name="action_cancel" attrs="{'invisible': ['|', ('state', 'not in', ('assigned', 'confirmed', 'partially_available', 'draft', 'waiting')), ('is_locked', '=', False)]}" string="Cancel" groups="base.group_user" type="object"/> <field name="picking_type_code" invisible="1"/> - <button name="do_unreserve" string="Unreserve" groups="base.group_user" type="object" attrs="{'invisible': ['|', '|', ('picking_type_code', '=', 'incoming'), ('state', 'not in', ('assigned', 'partially_available')), ('is_locked', '=', False)]}"/> + <button name="do_unreserve" string="Unreserve" groups="base.group_user" type="object" attrs="{'invisible': ['|', '|', '|', ('picking_type_code', '=', 'incoming'), ('is_locked', '=', False), '&', ('state', 'not in', ('assigned', 'partially_available')), ('move_type', '!=', 'one'), '&', ('state', 'not in', ('assigned', 'partially_available', 'confirmed')), ('move_type', '=', 'one')]}"/> <field name="show_operations" invisible="1" readonly="1"/> <field name="move_line_exist" invisible="1"/> <field name="has_packages" invisible="1"/> diff --git a/addons/stock_account/models/product.py b/addons/stock_account/models/product.py index 8e4bde1a2e2..3ba8f85bf26 100644 --- a/addons/stock_account/models/product.py +++ b/addons/stock_account/models/product.py @@ -138,23 +138,38 @@ class ProductProduct(models.Model): StockMove = self.env['stock.move'] to_date = self.env.context.get('to_date') - self.env['account.move.line'].check_access_rights('read') - fifo_automated_values = {} - query = """SELECT aml.product_id, aml.account_id, sum(aml.debit) - sum(aml.credit), sum(quantity), array_agg(aml.id) - FROM account_move_line AS aml - WHERE aml.product_id IS NOT NULL AND aml.company_id=%%s %s - GROUP BY aml.product_id, aml.account_id""" - params = (self.env.user.company_id.id,) + real_time_product_ids = [product.id for product in self if product.product_tmpl_id.valuation == 'real_time'] + if real_time_product_ids: + self.env['account.move.line'].check_access_rights('read') + fifo_automated_values = {} + query = """SELECT aml.product_id, aml.account_id, sum(aml.debit) - sum(aml.credit), sum(quantity), array_agg(aml.id) + FROM account_move_line AS aml + WHERE aml.product_id IN %%s AND aml.company_id=%%s %s + GROUP BY aml.product_id, aml.account_id""" + params = (tuple(real_time_product_ids), self.env.user.company_id.id) + if to_date: + query = query % ('AND aml.date <= %s',) + params = params + (to_date,) + else: + query = query % ('',) + self.env.cr.execute(query, params=params) + + res = self.env.cr.fetchall() + for row in res: + fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4])) + + product_values = {product: 0 for product in self} + product_move_ids = {product: [] for product in self} if to_date: - query = query % ('AND aml.date <= %s',) - params = params + (to_date,) + domain = [('product_id', 'in', self.ids), ('date', '<=', to_date)] + StockMove._get_all_base_domain() + for move in StockMove.search(domain).with_context(prefetch_fields=False): + product_values[move.product_id] += move.value + product_move_ids[move.product_id].append(move.id) else: - query = query % ('',) - self.env.cr.execute(query, params=params) - - res = self.env.cr.fetchall() - for row in res: - fifo_automated_values[(row[0], row[1])] = (row[2], row[3], list(row[4])) + domain = [('product_id', 'in', self.ids)] + StockMove._get_all_base_domain() + for move in StockMove.search(domain).with_context(prefetch_fields=False): + product_values[move.product_id] += move.remaining_value + product_move_ids[move.product_id].append(move.id) for product in self: if product.cost_method in ['standard', 'average']: @@ -170,11 +185,9 @@ class ProductProduct(models.Model): elif product.cost_method == 'fifo': if to_date: if product.product_tmpl_id.valuation == 'manual_periodic': - domain = [('product_id', '=', product.id), ('date', '<=', to_date)] + StockMove._get_all_base_domain() - moves = StockMove.search(domain) - product.stock_value = sum(moves.mapped('value')) + product.stock_value = product_values[product] product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available - product.stock_fifo_manual_move_ids = StockMove.browse(moves.ids) + product.stock_fifo_manual_move_ids = StockMove.browse(product_move_ids[product]) elif product.product_tmpl_id.valuation == 'real_time': valuation_account_id = product.categ_id.property_stock_valuation_account_id.id value, quantity, aml_ids = fifo_automated_values.get((product.id, valuation_account_id)) or (0, 0, []) @@ -182,7 +195,7 @@ class ProductProduct(models.Model): product.qty_at_date = quantity product.stock_fifo_real_time_aml_ids = self.env['account.move.line'].browse(aml_ids) else: - product.stock_value, moves = product._sum_remaining_values() + product.stock_value, moves = product_values[product], StockMove.browse(product_move_ids[product]) product.qty_at_date = product.with_context(company_owned=True, owner_id=False).qty_available if product.product_tmpl_id.valuation == 'manual_periodic': product.stock_fifo_manual_move_ids = moves diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index 3f362f29989..11d1bcb40a4 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -387,7 +387,7 @@ class StockMove(models.Model): product_tot_qty_available = move.product_id.qty_available + tmpl_dict[move.product_id.id] rounding = move.product_id.uom_id.rounding - qty_done = 0.0 + qty_done = move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id) if float_is_zero(product_tot_qty_available, precision_rounding=rounding): new_std_price = move._get_price_unit() elif float_is_zero(product_tot_qty_available + move.product_qty, precision_rounding=rounding) or \ @@ -396,7 +396,6 @@ class StockMove(models.Model): else: # Get the standard price amount_unit = std_price_update.get((move.company_id.id, move.product_id.id)) or move.product_id.standard_price - qty_done = move.product_uom._compute_quantity(move.quantity_done, move.product_id.uom_id) qty = forced_qty or qty_done new_std_price = ((amount_unit * product_tot_qty_available) + (move._get_price_unit() * qty)) / (product_tot_qty_available + qty_done) @@ -483,10 +482,8 @@ class StockMove(models.Model): fifo_valued_products = self.env['product.product'] fifo_valued_categories = self.env['product.category'].search([('property_cost_method', '=', 'fifo')]) fifo_valued_products |= self.env['product.product'].search([('categ_id', 'child_of', fifo_valued_categories.ids)]) - moves_to_vacuum = self.env['stock.move'] - for product in fifo_valued_products: - moves_to_vacuum |= self.search( - [('product_id', '=', product.id), ('remaining_qty', '<', 0)] + self._get_all_base_domain()) + moves_to_vacuum = self.search( + [('product_id', 'in', fifo_valued_products.ids), ('remaining_qty', '<', 0)] + self._get_all_base_domain()) moves_to_vacuum._fifo_vacuum() @api.multi diff --git a/addons/stock_account/tests/test_stockvaluation.py b/addons/stock_account/tests/test_stockvaluation.py index 3720b453635..0c7be5df182 100644 --- a/addons/stock_account/tests/test_stockvaluation.py +++ b/addons/stock_account/tests/test_stockvaluation.py @@ -2587,6 +2587,40 @@ class TestStockValuation(TransactionCase): self.assertAlmostEqual(self.product1.qty_at_date, 0.0) self.assertAlmostEqual(self.product1.stock_value, 0.0) + def test_average_perpetual_6(self): + self.product1.product_tmpl_id.cost_method = 'average' + + move1 = self.env['stock.move'].create({ + 'name': 'Receive 1 unit at 10', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + 'price_unit': 10, + }) + move1._action_confirm() + move1._action_assign() + move1.move_line_ids.qty_done = 1.0 + + move2 = self.env['stock.move'].create({ + 'name': 'Receive 1 units at 5', + 'location_id': self.supplier_location.id, + 'location_dest_id': self.stock_location.id, + 'product_id': self.product1.id, + 'product_uom': self.uom_unit.id, + 'product_uom_qty': 1.0, + 'price_unit': 5, + }) + move2._action_confirm() + move2._action_assign() + move2.move_line_ids.qty_done = 1.0 + + # Receive both at the same time + (move1 | move2)._action_done() + + self.assertAlmostEqual(self.product1.standard_price, 7.5) + def test_average_negative_1(self): """ Test edit in the past. Receive 10, send 20, edit the second move to only send 10. """ diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py index aadfa7eecdc..4d3bb1380e9 100644 --- a/addons/stock_landed_costs/models/stock_landed_cost.py +++ b/addons/stock_landed_costs/models/stock_landed_cost.py @@ -307,6 +307,9 @@ class AdjustmentLines(models.Model): return False accounts = self.product_id.product_tmpl_id.get_product_accounts() debit_account_id = accounts.get('stock_valuation') and accounts['stock_valuation'].id or False + # If the stock move is dropshipped move we need to get the cost account instead the stock valuation account + if self.move_id._is_dropshipped(): + debit_account_id = accounts.get('expense') and accounts['expense'].id or False already_out_account_id = accounts['stock_output'].id credit_account_id = self.cost_line_id.account_id.id or cost_product.property_account_expense_id.id or cost_product.categ_id.property_account_expense_categ_id.id diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py index 32250825bd3..6495a9a1ffb 100644 --- a/addons/survey/models/survey_user.py +++ b/addons/survey/models/survey_user.py @@ -77,6 +77,7 @@ class SurveyUserInput(models.Model): """ Send again the invitation """ self.ensure_one() local_context = { + 'survey_resent_user_input': self.id, 'survey_resent_token': True, 'default_partner_ids': self.partner_id and [self.partner_id.id] or [], 'default_multi_email': self.email or "", diff --git a/addons/survey/wizard/survey_invite.py b/addons/survey/wizard/survey_invite.py index 7aa47259262..4cf1768b0e3 100644 --- a/addons/survey/wizard/survey_invite.py +++ b/addons/survey/wizard/survey_invite.py @@ -205,6 +205,10 @@ class SurveyInvite(models.TransientModel): Mail.create(values).send() def create_token(wizard, partner_id, email): + if context.get("survey_resent_user_input"): + survey_user_input = SurveyUserInput.browse(context.get("survey_resent_user_input")) + if survey_user_input.state in ('new', 'skip'): + return survey_user_input.token if context.get("survey_resent_token"): survey_user_input = SurveyUserInput.search([('survey_id', '=', wizard.survey_id.id), ('state', 'in', ['new', 'skip']), '|', ('partner_id', '=', partner_id), diff --git a/addons/test_mail/tests/test_mail_channel.py b/addons/test_mail/tests/test_mail_channel.py index 74e49ce7de4..79ab747fed5 100644 --- a/addons/test_mail/tests/test_mail_channel.py +++ b/addons/test_mail/tests/test_mail_channel.py @@ -155,7 +155,15 @@ class TestChannelFeatures(common.BaseFunctionalTest, common.MockEmails): """ Posting a message on a mailing list should send one email to all recipients """ self.env['ir.config_parameter'].set_param('mail.catchall.domain', 'schlouby.fr') self.test_channel.write({'email_send': True}) - self._join_channel(self.test_channel, self.user_employee.partner_id | self.test_partner) + + # Subscribe an user without email. We shouldn't try to send email to them. + nomail = self.env['res.users'].create({ + "login": "nomail", + "name": "No Mail", + "email": False, + "notification_type": "email", + }) + self._join_channel(self.test_channel, self.user_employee.partner_id | self.test_partner | nomail.partner_id) self.test_channel.message_post(body="Test", message_type='comment', subtype='mt_comment') self.assertEqual(len(self._mails), 1) diff --git a/addons/test_mass_mailing/tests/test_performance.py b/addons/test_mass_mailing/tests/test_performance.py index 54f9df307b3..f69f2e74966 100644 --- a/addons/test_mass_mailing/tests/test_performance.py +++ b/addons/test_mass_mailing/tests/test_performance.py @@ -49,7 +49,7 @@ class TestMassMailPerformance(TestMassMailPerformanceBase): 'mailing_domain': [('id', 'in', self.mm_recs.ids)], }) - with self.assertQueryCount(__system__=2435, marketing=3091): + with self.assertQueryCount(__system__=2480, marketing=3136): mailing.send_mail() self.assertEqual(mailing.sent, 50) @@ -86,7 +86,7 @@ class TestMassMailBlPerformance(TestMassMailPerformanceBase): 'mailing_domain': [('id', 'in', self.mm_recs.ids)], }) - with self.assertQueryCount(__system__=2807, marketing=3559): + with self.assertQueryCount(__system__=2867, marketing=3619): mailing.send_mail() self.assertEqual(mailing.sent, 50) diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py index 57c86a2c6d6..3eaed012f25 100644 --- a/addons/web/controllers/main.py +++ b/addons/web/controllers/main.py @@ -1063,21 +1063,19 @@ class Binary(http.Controller): if status != 200 and download: return request.env['ir.http']._response_by_status(status, headers, content) + if not content: + content = base64.b64encode(self.placeholder(image='placeholder.png')) + headers = self.force_contenttype(headers, contenttype='image/png') + if not (width or height): + suffix = field.split('_')[-1] + if suffix in ('small', 'medium', 'big'): + content = getattr(odoo.tools, 'image_resize_image_%s' % suffix)(content) + content = limited_image_resize( content, width=width, height=height, crop=crop, upper_limit=upper_limit, avoid_if_small=avoid_if_small) - if content: - image_base64 = base64.b64decode(content) - else: - suffix = field.split('_')[-1] - if suffix in ('small', 'medium', 'big'): - encoded_placeholder = base64.b64encode(self.placeholder(image='placeholder.png')) - image_base64 = base64.b64decode(getattr(odoo.tools, 'image_resize_image_%s' % suffix)(encoded_placeholder)) - else: - image_base64 = self.placeholder(image='placeholder.png') # could return (contenttype, content) in master - headers = self.force_contenttype(headers, contenttype='image/png') - + image_base64 = base64.b64decode(content) headers.append(('Content-Length', len(image_base64))) response = request.make_response(image_base64, headers) response.status_code = status diff --git a/addons/web/models/models.py b/addons/web/models/models.py index fe01ffe6938..768885f701f 100644 --- a/addons/web/models/models.py +++ b/addons/web/models/models.py @@ -63,7 +63,8 @@ class Base(models.AbstractModel): locale=locale) if field_type == 'selection': - group_by_value = selection_labels[group_by_value] + group_by_value = selection_labels[group_by_value] \ + if group_by_value in selection_labels else False if type(group_by_value) == tuple: group_by_value = group_by_value[1] # FIXME should use technical value (0) diff --git a/addons/web/static/lib/tempusdominus/tempusdominus.js b/addons/web/static/lib/tempusdominus/tempusdominus.js index a5dac48ac3a..ffca0c8e632 100644 --- a/addons/web/static/lib/tempusdominus/tempusdominus.js +++ b/addons/web/static/lib/tempusdominus/tempusdominus.js @@ -2093,6 +2093,9 @@ var TempusDominusBootstrap4 = function ($) { } currentDate = this._viewDate.clone().startOf('M').startOf('w').startOf('d'); + // !! ODOO FIX START !! + var now = this.getMoment(); + // !! ODOO FIX END !! for (i = 0; i < 42; i++) { //always display 42 days (should show 6 weeks) @@ -2125,7 +2128,9 @@ var TempusDominusBootstrap4 = function ($) { if (!this._isValid(currentDate, 'd')) { clsName += ' disabled'; } - if (currentDate.isSame(this.getMoment(), 'd')) { + // !! ODOO FIX START !! + if (currentDate.date() === now.date() && currentDate.month() === now.month() && currentDate.year() === now.year()) { + // !! ODOO FIX END !! clsName += ' today'; } if (currentDate.day() === 0 || currentDate.day() === 6) { diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index 6d53d283f8a..14eb10c663c 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -240,6 +240,12 @@ var FieldMany2One = AbstractField.extend({ */ _bindAutoComplete: function () { var self = this; + // avoid ignoring autocomplete="off" by obfuscating placeholder, see #30439 + if (this.$input.attr('placeholder')) { + this.$input.attr('placeholder', function (index, val) { + return val.split('').join('\ufeff'); + }); + } this.$input.autocomplete({ source: function (req, resp) { _.each(self._autocompleteSources, function (source) { 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 9706251ed07..646e883fb7e 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -432,6 +432,7 @@ var BasicModel = AbstractModel.extend({ var isNew = this.isNew(id); var rollback = 'rollback' in options ? options.rollback : isNew; var initialOffset = element.offset; + element._domains = {}; this._visitChildren(element, function (elem) { if (rollback && elem._savePoint) { if (elem._savePoint instanceof Array) { diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js index c2c90ea7e62..03c59885fbe 100644 --- a/addons/web/static/src/js/views/calendar/calendar_renderer.js +++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js @@ -362,6 +362,11 @@ return AbstractRenderer.extend({ this.$calendar = this.$(".o_calendar_widget"); + // This seems like a workaround but apparently passing the locale + // in the options is not enough. We should initialize it beforehand + var locale = moment.locale(); + $.fullCalendar.locale(locale); + //Documentation here : http://arshaw.com/fullcalendar/docs/ var fc_options = $.extend({}, this.state.fc_options, { eventDrop: function (event) { @@ -414,6 +419,7 @@ return AbstractRenderer.extend({ height: 'parent', unselectAuto: false, isRTL: _t.database.parameters.direction === "rtl", + locale: locale, // reset locale when fullcalendar has already been instanciated before now }); this.$calendar.fullCalendar(fc_options); diff --git a/addons/web/static/src/js/views/control_panel/search/search_filters.js b/addons/web/static/src/js/views/control_panel/search/search_filters.js index 6ff84dce9c7..4703995ac7c 100644 --- a/addons/web/static/src/js/views/control_panel/search/search_filters.js +++ b/addons/web/static/src/js/views/control_panel/search/search_filters.js @@ -193,8 +193,10 @@ var DateTime = Field.extend({ operators: [ {value: "=", text: _lt("is equal to")}, {value: "!=", text: _lt("is not equal to")}, - {value: ">=", text: _lt("is after")}, - {value: "<=", text: _lt("is before")}, + {value: ">", text: _lt("is after")}, + {value: "<", text: _lt("is before")}, + {value: ">=", text: _lt("is after or equal to")}, + {value: "<=", text: _lt("is before or equal to")}, {value: "between", text: _lt("is between")}, {value: "∃", text: _lt("is set")}, {value: "∄", text: _lt("is not set")} diff --git a/addons/web/static/src/js/views/form/form_controller.js b/addons/web/static/src/js/views/form/form_controller.js index 5feefce716f..d85a538d5ec 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -227,17 +227,16 @@ var FormController = BasicController.extend({ // are displayed with an alert var fields = self.renderer.state.fields; var data = self.renderer.state.data; - var alertFields = []; + var alertFields = {}; for (var k = 0; k < changedFields.length; k++) { var field = fields[changedFields[k]]; var fieldData = data[changedFields[k]]; if (field.translate && fieldData) { - alertFields.push(field); + alertFields[changedFields[k]] = field; } } - if (alertFields.length) { - self.renderer.alertFields = alertFields; - self.renderer.displayTranslationAlert(); + if (!_.isEmpty(alertFields)) { + self.renderer.updateAlertFields(alertFields); } } return changedFields; 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 52bbc044d1f..2d6615b4b00 100644 --- a/addons/web/static/src/js/views/form/form_renderer.js +++ b/addons/web/static/src/js/views/form/form_renderer.js @@ -14,6 +14,7 @@ var FormRenderer = BasicRenderer.extend({ className: "o_form_view", events: _.extend({}, BasicRenderer.prototype.events, { 'click .o_notification_box .oe_field_translate': '_onTranslate', + 'click .o_notification_box .close': '_onTranslateNotificationClose', 'click .oe_title, .o_inner_group': '_onClick', }), custom_events: _.extend({}, BasicRenderer.prototype.custom_events, { @@ -31,6 +32,7 @@ var FormRenderer = BasicRenderer.extend({ this._super.apply(this, arguments); this.idsForLabels = {}; this.lastActivatedFieldIndex = -1; + this.alertFields = {}; }, /** * @override @@ -104,23 +106,32 @@ var FormRenderer = BasicRenderer.extend({ }); return fieldNames; }, + /* + * Updates translation alert fields for the current state and display updated fields + * + * @param {Object} alertFields + */ + updateAlertFields: function (alertFields) { + this.alertFields[this.state.res_id] = _.extend(this.alertFields[this.state.res_id] || {}, alertFields); + this.displayTranslationAlert(); + }, /** * Show a warning message if the user modified a translated field. For each * field, the notification provides a link to edit the field's translations. - * - * @param {Object[]} alertFields field list */ displayTranslationAlert: function () { this.$('.o_notification_box').remove(); - var $notification = $(qweb.render('notification-box', {type: 'info'})) - .append(qweb.render('translation-alert', { - fields: this.alertFields, - lang: _t.database.parameters.name - })); - if (this.$('.o_form_statusbar').length) { - this.$('.o_form_statusbar').after($notification); - } else { - this.$el.prepend($notification); + if (this.alertFields[this.state.res_id]) { + var $notification = $(qweb.render('notification-box', {type: 'info'})) + .append(qweb.render('translation-alert', { + fields: this.alertFields[this.state.res_id], + lang: _t.database.parameters.name + })); + if (this.$('.o_form_statusbar').length) { + this.$('.o_form_statusbar').after($notification); + } else { + this.$el.prepend($notification); + } } }, /** @@ -334,6 +345,7 @@ var FormRenderer = BasicRenderer.extend({ }); self.trigger_up('swipe_right'); }, + excludedElements: ".o_notebook .nav.nav-tabs", }); }, /** @@ -915,6 +927,9 @@ var FormRenderer = BasicRenderer.extend({ return $.when.apply($, defs).then(function () { self._updateView($form.contents()); + if (self.state.res_id in self.alertFields) { + self.displayTranslationAlert(); + } }, function () { $form.remove(); }).then(function(){ @@ -1030,6 +1045,15 @@ var FormRenderer = BasicRenderer.extend({ ev.preventDefault(); this.trigger_up('translate', {fieldName: ev.target.name, id: this.state.id}); }, + /** + * remove alert fields of record from alertFields object + * + * @private + * @param {MouseEvent} ev + */ + _onTranslateNotificationClose: function(ev) { + delete this.alertFields[this.state.res_id]; + }, }); return FormRenderer; diff --git a/addons/web/static/src/js/views/graph/graph_renderer.js b/addons/web/static/src/js/views/graph/graph_renderer.js index 856882a303b..09706695bbc 100644 --- a/addons/web/static/src/js/views/graph/graph_renderer.js +++ b/addons/web/static/src/js/views/graph/graph_renderer.js @@ -198,10 +198,12 @@ return AbstractRenderer.extend({ } } - // For Bar chart View, we keep only groups where count > 0 - data[0].values = _.filter(data[0].values, function (elem, index) { - return self.state.data[index].count > 0; - }); + // For one level Bar chart View, we keep only groups where count > 0 + if (this.state.groupedBy.length <= 1) { + data[0].values = _.filter(data[0].values, function (elem, index) { + return self.state.data[index].count > 0; + }); + } var $svgContainer = $('<div/>', {class: 'o_graph_svg_container'}); this.$el.append($svgContainer); diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js index d05bf2d33fc..347769b4042 100644 --- a/addons/web/static/src/js/widgets/data_export.js +++ b/addons/web/static/src/js/widgets/data_export.js @@ -311,7 +311,7 @@ var DataExport = Dialog.extend({ }); self.$export_format_inputs = $fmts.find('input'); - self.$export_format_inputs.first().prop('checked', true); + self.$export_format_inputs.filter(':enabled').first().prop('checked', true); } }, show_exports_list: function() { diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js index b677abe377a..367ab23178d 100644 --- a/addons/web/static/src/js/widgets/date_picker.js +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -25,6 +25,7 @@ var DateWidget = Widget.extend({ this.name = parent.name; this.options = _.extend({ + locale: moment.locale(), format : this.type_of_date === 'datetime' ? time.getLangDatetimeFormat() : time.getLangDateFormat(), minDate: moment({ y: 1900 }), maxDate: moment().add(200, "y"), diff --git a/addons/web/static/src/scss/base_settings.scss b/addons/web/static/src/scss/base_settings.scss index 82137b00948..a7ff1d848a7 100644 --- a/addons/web/static/src/scss/base_settings.scss +++ b/addons/web/static/src/scss/base_settings.scss @@ -114,9 +114,11 @@ font-size: 13px; line-height: 40px; cursor: pointer; + white-space: nowrap; .icon { width: 23px; + min-width: 23px; margin-right: 10px; } diff --git a/addons/web/static/src/scss/form_view.scss b/addons/web/static/src/scss/form_view.scss index 31032a75cc2..697ec4dcd12 100644 --- a/addons/web/static/src/scss/form_view.scss +++ b/addons/web/static/src/scss/form_view.scss @@ -743,6 +743,7 @@ } .o_field_widget { width: 50%; + flex: 0 0 auto; } button.btn-link:first-child { padding-left: 0; diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss index c771d6a37ab..c4c0e9d011d 100644 --- a/addons/web/static/src/scss/kanban_view.scss +++ b/addons/web/static/src/scss/kanban_view.scss @@ -564,6 +564,8 @@ @include o-kanban-record-color; .oe_kanban_color_help { - @include o-position-absolute(0, 5px, 0, -1px); + @include o-position-absolute(0, auto, 0, -1px); + width: $o-kanban-color-border-width; + z-index: 1; // show the title over kanban color } } diff --git a/addons/web/static/src/scss/navbar_mobile.scss b/addons/web/static/src/scss/navbar_mobile.scss index 6ef917b8517..f4593436163 100644 --- a/addons/web/static/src/scss/navbar_mobile.scss +++ b/addons/web/static/src/scss/navbar_mobile.scss @@ -23,7 +23,6 @@ position: relative; height: $o-navbar-height; - overflow: hidden; > ul { > li { @@ -39,6 +38,7 @@ &.o_menu_sections { width: 100%; + display: none; .dropdown-menu.show { position: static; @@ -82,6 +82,9 @@ body.o_mobile_menu_opened > .o_main_navbar { height: 100%; overflow: auto; + .o_menu_sections { + display: block; + } } } diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 55d744d3a60..5e2f27ccc66 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -1488,7 +1488,7 @@ <div role="alertdialog"> Please update translations of : <t t-foreach="fields" t-as="field"> - <strong><a class="oe_field_translate" t-att-name="field.name" href="#"><t t-esc="field.string"/><t t-if="!field_last">, </t></a></strong> + <strong><a class="oe_field_translate" t-att-name="field" href="#"><t t-esc="field_value.string"/><t t-if="field_index < _.size(fields)-1">, </t></a></strong> </t> </div> </t> diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index f5568abe28e..b721861c4b6 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -2962,6 +2962,47 @@ QUnit.module('basic_fields', { form.destroy(); }); + QUnit.test('date field support internalization', function (assert) { + assert.expect(2); + + var originalLocale = moment.locale(); + var originalParameters = _.clone(core._t.database.parameters); + + _.extend(core._t.database.parameters, {date_format: '%d. %b %Y', time_format: '%H:%M:%S'}); + moment.defineLocale('norvegianForTest', { + monthsShort: 'jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.'.split('_'), + monthsParseExact: true, + dayOfMonthOrdinalParse: /\d{1,2}\./, + ordinal: '%d.', + }); + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch:'<form string="Partners"><field name="date"/></form>', + res_id: 1, + }); + + var dateViewForm = form.$('.o_field_date').text(); + testUtils.dom.click(form.$buttons.find('.o_form_button_edit')); + testUtils.openDatepicker(form.$('.o_datepicker')); + assert.strictEqual(form.$('.o_datepicker_input').val(), dateViewForm, + "input date field should be the same as it was in the view form"); + + testUtils.dom.click($('.day:contains(30)')); + var dateEditForm = form.$('.o_datepicker_input').val() + testUtils.dom.click(form.$buttons.find('.o_form_button_save')); + assert.strictEqual(form.$('.o_field_date').text(), dateEditForm, + "date field should be the same as the one selected in the view form"); + + moment.locale(originalLocale); + moment.updateLocale('norvegianForTest', null); + core._t.database.parameters = originalParameters; + + form.destroy(); + }); + QUnit.module('FieldDatetime'); QUnit.test('datetime field in form view', function (assert) { diff --git a/addons/web/static/tests/fields/field_utils_tests.js b/addons/web/static/tests/fields/field_utils_tests.js index 940f5674780..c4281b38c87 100644 --- a/addons/web/static/tests/fields/field_utils_tests.js +++ b/addons/web/static/tests/fields/field_utils_tests.js @@ -297,7 +297,61 @@ QUnit.test('parse percentage', function(assert) { core._t.database.parameters = originalParameters; -}) +}); + +QUnit.test('parse datetime', function (assert) { + assert.expect(5); + + var originalParameters = _.clone(core._t.database.parameters); + var originalLocale = moment.locale(); + var dateStr, date1, date2; + + moment.defineLocale('englishForTest', { + dayOfMonthOrdinalParse: /\d{1,2}(st|nd|rd|th)/, + ordinal: function (number) { + var b = number % 10, + output = (~~(number % 100 / 10) === 1) ? 'th' : + (b === 1) ? 'st' : + (b === 2) ? 'nd' : + (b === 3) ? 'rd' : 'th'; + return number + output; + }, + }); + + moment.defineLocale('norvegianForTest', { + monthsShort: 'jan._feb._mars_april_mai_juni_juli_aug._sep._okt._nov._des.'.split('_'), + monthsParseExact: true, + dayOfMonthOrdinalParse: /\d{1,2}\./, + ordinal: '%d.', + }); + + moment.locale('englishForTest'); + _.extend(core._t.database.parameters, {date_format: '%m/%d/%Y', time_format: '%H:%M:%S'}); + assert.throws(function () {fieldUtils.parse.datetime("13/01/2019 12:00:00", {}, {})}, /is not a correct/, "Wrongly formated dates should be invalids"); + assert.throws(function () {fieldUtils.parse.datetime("1899-01-01 12:00:00", {}, {})}, /is not a correct/, "Dates before 1900 should be invalids"); + + dateStr = '01/13/2019 10:05:45'; + date1 = fieldUtils.parse.datetime(dateStr); + date2 = moment.utc(dateStr, ['MM/DD/YYYY HH:mm:ss'], true); + assert.equal(date1.format(), date2.format(), "Date with leading 0"); + + dateStr = '1/14/2019 10:5:45'; + date1 = fieldUtils.parse.datetime(dateStr); + date2 = moment.utc(dateStr, ['M/D/YYYY H:m:s'], true); + assert.equal(date1.format(), date2.format(), "Date without leading 0"); + + moment.locale('norvegianForTest'); + _.extend(core._t.database.parameters, {date_format: '%d. %b %Y', time_format: '%H:%M:%S'}); + dateStr = '16. jan. 2019 10:05:45'; + date1 = fieldUtils.parse.datetime(dateStr); + date2 = moment.utc(dateStr, ['DD. MMM YYYY HH:mm:ss'], true); + assert.equal(date1.format(), date2.format(), "Day/month inverted + month i18n"); + + moment.locale(originalLocale); + moment.updateLocale("englishForTest", null); + moment.updateLocale("norvegianForTest", null); + core._t.database.parameters = originalParameters; +}); }); }); diff --git a/addons/web/static/tests/views/calendar_tests.js b/addons/web/static/tests/views/calendar_tests.js index 264a00cc52a..9ea9992426b 100644 --- a/addons/web/static/tests/views/calendar_tests.js +++ b/addons/web/static/tests/views/calendar_tests.js @@ -2372,6 +2372,44 @@ QUnit.module('Views', { calendar.destroy(); }); + QUnit.test('fullcalendar initializes with right locale', function (assert) { + assert.expect(1); + + var initialLocale = moment.locale(); + // This will set the locale to zz + moment.defineLocale('zz', { + longDateFormat: { + L: 'DD/MM/YYYY' + }, + weekdaysShort: ["zz1.", "zz2.", "zz3.", "zz4.", "zz5.", "zz6.", "zz7."], + }); + + var calendar = createView({ + View: CalendarView, + model: 'event', + data: this.data, + arch: '<calendar class="o_calendar_test" '+ + 'date_start="start" '+ + 'date_stop="stop" '+ + 'mode="week"> '+ + '<field name="name"/>'+ + '</calendar>', + archs: archs, + viewOptions: { + initialDate: initialDate, + action: {views: [{viewID: 1, type: 'kanban'}, {viewID: 43, type: 'form'}]} + }, + + }); + + assert.strictEqual(calendar.$('.fc-day-header:first').text(), "zz1. 11/12", + 'The day should be in the given locale specific format'); + + moment.locale(initialLocale); + + calendar.destroy(); + }); + }); }); diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index e7d425b997e..e0d0b8daa83 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -16,6 +16,7 @@ var Widget = require('web.Widget'); var _t = core._t; var createView = testUtils.createView; +var createActionManager = testUtils.createActionManager; var createAsyncView = testUtils.createAsyncView; var createActionManager = testUtils.createActionManager; @@ -5530,9 +5531,10 @@ QUnit.module('Views', { }); QUnit.test('display translation alert', function (assert) { - assert.expect(1); + assert.expect(2); this.data.partner.fields.foo.translate = true; + this.data.partner.fields.display_name.translate = true; var multi_lang = _t.database.multi_lang; _t.database.multi_lang = true; @@ -5545,6 +5547,7 @@ QUnit.module('Views', { '<sheet>' + '<group>' + '<field name="foo"/>' + + '<field name="display_name"/>' + '</group>' + '</sheet>' + '</form>', @@ -5554,11 +5557,150 @@ QUnit.module('Views', { testUtils.form.clickEdit(form); testUtils.fields.editInput(form.$('input[name="foo"]'), "test"); testUtils.form.clickSave(form); + assert.containsOnce(form, '.o_form_view > .alert > div .oe_field_translate', + "should have single translation alert"); + + testUtils.form.clickEdit(form); + testUtils.fields.editInput(form.$('input[name="display_name"]'), "test2"); + testUtils.form.clickSave(form); + assert.containsN(form, '.o_form_view > .alert > div .oe_field_translate', 2, + "should have two translate fields in translation alert"); + + form.destroy(); + + _t.database.multi_lang = multi_lang; + }); + + QUnit.test('translation alerts are preserved on pager change', function (assert) { + assert.expect(5); + + this.data.partner.fields.foo.translate = true; + + var multi_lang = _t.database.multi_lang; + _t.database.multi_lang = true; + + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners">' + + '<sheet>' + + '<field name="foo"/>' + + '</sheet>' + + '</form>', + viewOptions: { + ids: [1, 2], + index: 0, + }, + res_id: 1, + }); + + testUtils.form.clickEdit(form); + testUtils.fields.editInput(form.$('input[name="foo"]'), "test"); + testUtils.form.clickSave(form); assert.containsOnce(form, '.o_form_view > .alert > div',"should have a translation alert"); + + // click on the pager to switch to the next record + form.pager.$('.o_pager_next').click(); + assert.strictEqual(form.$('.o_form_view > .alert > div').length, 0, + "should not have a translation alert"); + + // click on the pager to switch back to the previous record + form.pager.$('.o_pager_previous').click(); + assert.containsOnce(form, '.o_form_view > .alert > div',"should have a translation alert"); + + // remove translation alert by click X and check alert even after form reload + form.$('.o_form_view > .alert > .close').click(); + assert.strictEqual(form.$('.o_form_view > .alert > div').length, 0, + "should not have a translation alert"); + form.reload(); + assert.strictEqual(form.$('.o_form_view > .alert > div').length, 0, + "should not have a translation alert after reload"); + form.destroy(); + _t.database.multi_lang = multi_lang; + }); + + QUnit.test('translation alerts preseved on reverse breadcrumb', function (assert) { + assert.expect(2); + + this.data['ir.translation'] = { + fields: { + name: { string: "name", type: "char" }, + source: {string: "Source", type: "char"}, + value: {string: "Value", type: "char"}, + }, + records: [], + }; + + this.data.partner.fields.foo.translate = true; + + var multi_lang = _t.database.multi_lang; + _t.database.multi_lang = true; + var archs = { + 'partner,false,form': '<form string="Partners">' + + '<sheet>' + + '<field name="foo"/>' + + '</sheet>' + + '</form>', + 'partner,false,search': '<search></search>', + 'ir.translation,false,list': '<tree>' + + '<field name="name"/>' + + '<field name="source"/>' + + '<field name="value"/>' + + '</tree>', + 'ir.translation,false,search': '<search></search>', + }; + + var actions = [{ + id: 1, + name: 'Partner', + res_model: 'partner', + type: 'ir.actions.act_window', + views: [[false, 'form']], + }, { + id: 2, + name: 'Translate', + res_model: 'ir.translation', + type: 'ir.actions.act_window', + views: [[false, 'list']], + target: 'current', + flags: {'search_view': true, 'action_buttons': true}, + }]; + + var actionManager = createActionManager({ + actions: actions, + archs: archs, + data: this.data, + }); + + actionManager.doAction(1); + + actionManager.controlPanel.$el.find('.o_form_button_edit').click(); + actionManager.$('input[name="foo"]').val("test").trigger("input"); + actionManager.controlPanel.$el.find('.o_form_button_save').click(); + + assert.strictEqual(actionManager.$('.o_form_view > .alert > div').length, 1, + "should have a translation alert"); + + var currentController = actionManager.getCurrentController().widget; + actionManager.doAction(2, { + on_reverse_breadcrumb: function () { + if (!_.isEmpty(currentController.renderer.alertFields)) { + currentController.renderer.displayTranslationAlert(); + } + return false; + }, + }); + + $('.o_control_panel .breadcrumb a:first').click(); + assert.strictEqual(actionManager.$('.o_form_view > .alert > div').length, 1, + "should have a translation alert"); + + actionManager.destroy(); _t.database.multi_lang = multi_lang; }); @@ -7207,6 +7349,64 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('domain returned by onchange is cleared on discard', function (assert) { + assert.expect(4); + + this.data.partner.onchanges = { + foo: function () {}, + }; + + var domain = ['id', '=', 1]; + var expectedDomain = domain; + var form = createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="foo"/><field name="trululu"/></form>', + mockRPC: function (route, args) { + if (args.method === 'onchange' && args.args[0][0] === 1) { + // onchange returns a domain only on record 1 + return $.when({ + domain: { + trululu: domain, + }, + }); + } + if (args.method === 'name_search') { + assert.deepEqual(args.kwargs.args, expectedDomain); + } + return this._super.apply(this, arguments); + }, + res_id: 1, + viewOptions: { + ids: [1, 2], + mode: 'edit', + }, + }); + + assert.strictEqual(form.$('input[name=foo]').val(), 'yop', "should be on record 1"); + + // change foo to trigger the onchange + testUtils.fields.editInput(form.$('input[name=foo]'), 'new value'); + + // open many2one dropdown to check if the domain is applied + testUtils.fields.many2one.clickOpenDropdown('trululu'); + + // switch to another record (should ask to discard changes, and reset the domain) + testUtils.dom.click(form.pager.$('.o_pager_next')); + + // discard changes by clicking on confirm in the dialog + testUtils.dom.click($('.modal .modal-footer .btn-primary:first')); + + assert.strictEqual(form.$('input[name=foo]').val(), 'blip', "should be on record 2"); + + // open many2one dropdown to check if the domain is applied + expectedDomain = []; + testUtils.fields.many2one.clickOpenDropdown('trululu'); + + form.destroy(); + }); + QUnit.module('FormViewTABMainButtons'); QUnit.test('using tab in an empty required string field should not move to the next field',function(assert) { diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py index ffa03b739d9..1ed329bafe1 100644 --- a/addons/web/tests/__init__.py +++ b/addons/web/tests/__init__.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import test_image from . import test_js from . import test_menu from . import test_serving_base diff --git a/addons/web/tests/test_image.py b/addons/web/tests/test_image.py new file mode 100644 index 00000000000..96e09444c05 --- /dev/null +++ b/addons/web/tests/test_image.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import io + +from PIL import Image + +from odoo.tests.common import HttpCase + + +class TestImage(HttpCase): + def test_01_content_image_resize_placeholder(self): + response = self.url_open('/web/image/0/200x150') + image = Image.open(io.BytesIO(response.content)) + self.assertEqual(image.size, (200, 150)) + + response = self.url_open('/web/image/fake/0/image_small') + image = Image.open(io.BytesIO(response.content)) + self.assertEqual(image.size, (64, 64)) diff --git a/addons/web/views/report_templates.xml b/addons/web/views/report_templates.xml index 80479b0bcfa..6b5a6a95b82 100644 --- a/addons/web/views/report_templates.xml +++ b/addons/web/views/report_templates.xml @@ -356,7 +356,7 @@ </t> <t t-if="company.external_report_layout_id" t-call="{{company.external_report_layout_id.key}}"><t t-raw="0"/></t> - <t t-else="else" t-raw="0"/> + <t t-else="else" t-call="web.external_layout_standard"><t t-raw="0"/></t> </template> diff --git a/addons/web_editor/controllers/main.py b/addons/web_editor/controllers/main.py index 40cf2aba5ef..2ad2730dc93 100644 --- a/addons/web_editor/controllers/main.py +++ b/addons/web_editor/controllers/main.py @@ -50,11 +50,16 @@ class Web_Editor(http.Controller): kwargs.update(trans) - record = None - if model and kwargs.get('res_id'): - record = request.env[model].with_context(trans).browse(kwargs.get('res_id')) + content = None + if model: + Model = request.env[model].with_context(trans) + if kwargs.get('res_id'): + record = Model.browse(kwargs.get('res_id')) + content = record and getattr(record, field) + else: + content = Model.default_get([field]).get(field) - kwargs.update(content=record and getattr(record, field) or "") + kwargs.update(content=content or '') return request.render(kwargs.get("template") or "web_editor.FieldTextHtml", kwargs, uid=request.uid) @@ -265,6 +270,9 @@ class Web_Editor(http.Controller): result['originalSrc'] = record.url return result + def _get_view_fields_to_read(self): + return ['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id'] + ## The get_assets_editor_resources route is in charge of transmitting the resources the assets ## editor needs to work. ## @param key - the xml_id or id of the view the resources are related to @@ -277,7 +285,7 @@ class Web_Editor(http.Controller): def get_assets_editor_resources(self, key, get_views=True, get_scss=True, bundles=False, bundles_restriction=[]): # Related views must be fetched if the user wants the views and/or the style views = request.env["ir.ui.view"].get_related_views(key, bundles=bundles) - views = views.read(['name', 'id', 'key', 'xml_id', 'arch', 'active', 'inherit_id']) + views = views.read(self._get_view_fields_to_read()) scss_files_data_by_bundle = [] @@ -423,7 +431,7 @@ class Web_Editor(http.Controller): (note: searching for the URL string is not enough as it could appear in a comment or an xpath expression.) """ - return bool(etree.XML(view.arch).xpath("link[@href='{}']".format(url))) + return bool(etree.XML(view.arch).xpath("//link[@href='{}']".format(url))) view_to_xpath = IrUiView.get_related_views(bundle_xmlid, bundles=True).filtered(views_linking_url) diff --git a/addons/web_editor/static/src/js/widgets/ace.js b/addons/web_editor/static/src/js/widgets/ace.js index f3c47f30050..37c21f2aa36 100644 --- a/addons/web_editor/static/src/js/widgets/ace.js +++ b/addons/web_editor/static/src/js/widgets/ace.js @@ -2,6 +2,7 @@ odoo.define('web_editor.ace', function (require) { 'use strict'; var ajax = require('web.ajax'); +var concurrency = require('web.concurrency'); var core = require('web.core'); var Dialog = require('web.Dialog'); var Widget = require('web.Widget'); @@ -359,7 +360,7 @@ var ViewEditor = Widget.extend({ this.aceEditor.setSession(editingSession); if (this.currentType === 'xml') { - this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].xml_id)); + this.$viewID.text(_.str.sprintf(_t("Template ID: %s"), this.views[resID].key)); } else { this.$viewID.text(_.str.sprintf(_t("SCSS file: %s"), resID)); } @@ -536,6 +537,7 @@ var ViewEditor = Widget.extend({ * error occured. */ _saveResources: function () { + var self = this; var toSave = {}; var errorFound = false; _.each(this.editingSessions, (function (editingSessions, type) { @@ -563,8 +565,15 @@ var ViewEditor = Widget.extend({ if (errorFound) return $.Deferred().reject(errorFound); var defs = []; + var mutex = new concurrency.Mutex(); _.each(toSave, (function (_toSave, type) { - defs = defs.concat(_.map(_toSave, (type === 'xml' ? this._saveView : this._saveSCSS).bind(this))); + // Child views first as COW on a parent would delete them + _toSave = _.sortBy(_toSave, 'id').reverse(); + _.each(_toSave, function (session) { + defs.push(mutex.exec(function () { + return (type === 'xml' ? self._saveView(session) : self._saveSCSS(session)); + })); + }); }).bind(this)); return $.when.apply($, defs).fail((function (session, error) { diff --git a/addons/web_editor/static/src/scss/web_editor.ui.scss b/addons/web_editor/static/src/scss/web_editor.ui.scss index 06e56ee657f..e615b3b5e38 100644 --- a/addons/web_editor/static/src/scss/web_editor.ui.scss +++ b/addons/web_editor/static/src/scss/web_editor.ui.scss @@ -1064,10 +1064,11 @@ body.editor_enable { box-shadow: inset 0 0 0px 1px darken($o-we-switch-inactive-color,10%); border-radius: 100rem; height: $o-we-switch-size; - width: $o-we-switch-size * 1.8; - margin-right: 0.5em; + width: $o-we-switch-size * 1.81; + margin-right: 0.51em; display: inline-block; transition: all 0.3s $o-we-md-ease; + font-size: 15px; &:after { content: ""; diff --git a/addons/website/controllers/web_editor.py b/addons/website/controllers/web_editor.py index 93c3aa46007..530c9ef2b10 100644 --- a/addons/website/controllers/web_editor.py +++ b/addons/website/controllers/web_editor.py @@ -6,6 +6,11 @@ from odoo.http import request class Web_Editor(Web_Editor): + def _get_view_fields_to_read(self): + res = super(Web_Editor, self)._get_view_fields_to_read() + res.append('website_id') + return res + def save_scss_view_hook(self): res = super(Web_Editor, self).save_scss_view_hook() diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index a94397e77ba..dc3ebb286c3 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -72,7 +72,7 @@ class View(models.Model): website_specific_view = view.copy(copy_vals) view._create_website_specific_pages_for_view(website_specific_view, - view.env['website'].browse(current_website_id)) + view.env['website'].browse(current_website_id)) for inherit_child in view.inherit_children_ids.filter_duplicate().sorted(key=lambda v: (v.priority, v.id)): if inherit_child.website_id.id == current_website_id: @@ -116,7 +116,7 @@ class View(models.Model): if cow_view[key] == self[key]: authorized_vals[key] = values[key] cow_view.write(authorized_vals) - super(View, self)._load_records_write(values) + super(View, self)._load_records_write(values) def _load_records_create(self, values): """ During module install, when creating a generic child view, we should @@ -166,6 +166,7 @@ class View(models.Model): # create new pages for this view page.copy({ 'view_id': new_view.id, + 'is_published': page.is_published, }) @api.model diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 7c789572093..96648264880 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -839,7 +839,7 @@ class WebsitePublishedMixin(models.AbstractModel): _description = 'Website Published Mixin' website_published = fields.Boolean('Visible on current website', related='is_published', readonly=False) - is_published = fields.Boolean('Is published') + is_published = fields.Boolean('Is published', copy=False) website_url = fields.Char('Website URL', compute='_compute_website_url', help='The full URL to access the document through the website.') @api.multi diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js index 42a12414557..d64a3891bfd 100644 --- a/addons/website/static/src/js/content/menu.js +++ b/addons/website/static/src/js/content/menu.js @@ -60,6 +60,10 @@ sAnimation.registry.affixMenu = sAnimation.Class.extend({ * @private */ _onWindowUpdate: function () { + if (this.$navbarCollapses.hasClass('show')) { + return; + } + var wOffset = $(window).scrollTop(); var hOffset = this.$target.scrollTop(); this.$headerClone.toggleClass('affixed', wOffset > (hOffset + 300)); diff --git a/addons/website/static/src/js/html_editor.test.js b/addons/website/static/src/js/html_editor.test.js new file mode 100644 index 00000000000..6c7633cf7aa --- /dev/null +++ b/addons/website/static/src/js/html_editor.test.js @@ -0,0 +1,143 @@ +odoo.define('website.test.html_editor', function (require) { +'use strict'; + +var tour = require('web_tour.tour'); +var base = require('web_editor.base'); + +tour.register('html_editor_multiple_templates', { + test: true, + url: '/aboutus', + wait_for: base.ready(), +}, + [ + // 1. Edit the page through Edit Mode, it will COW the view + { + content: "enter edit mode", + trigger: 'a[data-action=edit]', + }, + { + content: "drop a snippet", + trigger: '#oe_snippets .oe_snippet:has(.s_cover) .oe_snippet_thumbnail', + // id starting by 'oe_structure..' will actually create an inherited view + run: "drag_and_drop #oe_structure_test_ui", + }, + { + content: "save the page", + extra_trigger: '#wrapwrap .s_cover', + trigger: "#web_editor-top-edit button[data-action=save]", + }, + // 2. Edit generic aboutus view + { + content: "open customize menu", + extra_trigger: "body:not(.editor_enable)", + trigger: '#customize-menu > a', + }, + { + content: "open html editor", + trigger: '#html_editor', + }, + { + content: "add something in the aboutus view", + trigger: 'div.ace_line .ace_xml:contains("aboutus")', + run: function () { + ace.edit('ace-view-editor').getSession().insert({row: 3, column: 1}, '<p>somenewcontent</p>\n'); + }, + }, + // 3. Edit oe_structure specific view + { + content: "select oe_structure specific view", + trigger: 'div.ace_line .ace_xml:contains("somenewcontent")', + run: function () { + var viewId = $('#ace-view-list option:contains("oe_structure")').val(); + $('#ace-view-list').val(viewId).trigger('change'); + }, + }, + { + content: "add something in the oe_structure specific view", + extra_trigger: '#ace-view-id:contains("website.aboutus_oe_structure_test_ui")', // If no xml_id it should show key + trigger: 'div.ace_line .ace_xml:contains("s_cover")', + run: function () { + ace.edit('ace-view-editor').getSession().insert({row: 2, column: 1}, '<p>anothernewcontent</p>\n'); + }, + }, + { + content: "save the html editor", + extra_trigger: 'div.ace_line .ace_xml:contains("anothernewcontent")', + trigger: ".o_ace_view_editor button[data-action=save]", + }, + { + content: "check that the page has both modification", + extra_trigger: '#wrapwrap:contains("anothernewcontent")', + trigger: '#wrapwrap:contains("somenewcontent")', + run: function () {}, // it's a check + }, + ] +); + +tour.register('test_html_editor_scss', { + test: true, + url: '/aboutus', + wait_for: base.ready(), +}, + [ + // 1. Open Html Editor and select a scss file + { + content: "open customize menu", + extra_trigger: '#wrap:visible', // ensure state for later + trigger: '#customize-menu > a', + }, + { + content: "open html editor", + trigger: '#html_editor', + }, + { + content: "open type switcher", + trigger: '.o_ace_type_switcher button', + }, + { + content: "select scss files", + trigger: '.o_ace_type_switcher_choice[data-type="scss"]', + }, + { + content: "select 'website.ui.scss'", + trigger: '#ace-scss-list', + run: function () { + var scssId = $('#ace-scss-list option:contains("website.ui")').val(); + $('#ace-scss-list').val(scssId).trigger('change'); + }, + }, + // 2. Edit that file and ensure it was saved then reset it + { + content: "add some scss content in the file", + trigger: 'div.ace_line .ace_variable:contains("body.o_connected_user")', + run: function () { + ace.edit('ace-view-editor').getSession().insert({row: 2, column: 0}, '#wrap {display: none;}\n'); + }, + }, + { + content: "save the html editor", + extra_trigger: 'div.ace_line:contains("#wrap {display: none;}")', + trigger: ".o_ace_view_editor button[data-action=save]", + }, + { + content: "check that the scss modification got applied", + trigger: 'body:has(#wrap:hidden)', + run: function () {}, // it's a check + }, + { + content: "reset view (after reload, html editor should have been reopened where it was)", + trigger: '#ace-view-id button[data-action="reset"]', + }, + { + content: "confirm reset warning", + trigger: '.modal-footer .btn-primary', + }, + { + content: "check that the scss file was reset correctly, wrap content should now be visible again", + trigger: '#wrap:visible', + run: function () {}, // it's a check + }, + ] +); + +}); diff --git a/addons/website/static/src/js/widgets/ace.js b/addons/website/static/src/js/widgets/ace.js index 22a1755bd82..f4fe74ddd38 100644 --- a/addons/website/static/src/js/widgets/ace.js +++ b/addons/website/static/src/js/widgets/ace.js @@ -2,6 +2,7 @@ odoo.define("website.ace", function (require) { "use strict"; var AceEditor = require('web_editor.ace'); +var weContext = require('web_editor.context'); /** * Extends the default view editor so that the URL hash is updated with view ID @@ -37,9 +38,30 @@ var WebsiteAceEditor = AceEditor.extend({ */ _saveResources: function () { return this._super.apply(this, arguments).then((function () { - this._updateHash(); - window.location.reload(); - return $.Deferred(); + var defs = []; + if (this.currentType === 'xml') { + var selectedView = _.findWhere(this.views, {id: this._getSelectedResource()}); + if (!selectedView.website_id[0]) { + // When saving a generic view, the view will be COW'd and + // replace by the specific view after the reload. Thus the id in + // URL won't exist anymore. We need to find the specific ID. + var context = weContext.get(); + defs.push(this._rpc({ + model: 'ir.ui.view', + method: 'search_read', + fields: ['id'], + domain: [['key', '=', selectedView.key], ['website_id', '=', context.website_id]], + }).then((function (view) { + if (view[0]) { + this._updateHash(view[0].id); + } + }).bind(this))); + } + } + return $.when.apply($, defs).then((function () { + window.location.reload(); + return $.Deferred(); + })); }).bind(this)); }, /** @@ -56,8 +78,8 @@ var WebsiteAceEditor = AceEditor.extend({ * * @private */ - _updateHash: function () { - window.location.hash = this.hash + "?res=" + this._getSelectedResource(); + _updateHash: function (resID) { + window.location.hash = this.hash + "?res=" + (resID || this._getSelectedResource()); }, }); diff --git a/addons/website/tests/test_ui.py b/addons/website/tests/test_ui.py index 0cf61f88c30..a9e9d502dbf 100644 --- a/addons/website/tests/test_ui.py +++ b/addons/website/tests/test_ui.py @@ -4,6 +4,35 @@ import odoo import odoo.tests + +@odoo.tests.tagged('-at_install', 'post_install') +class TestUiHtmlEditor(odoo.tests.HttpCase): + def test_html_editor_multiple_templates(self): + Website = self.env['website'] + View = self.env['ir.ui.view'] + generic_aboutus = Website.viewref('website.aboutus') + # Use an empty page layout with oe_structure id for this test + oe_structure_layout = ''' + <t name="About us" t-name="website.aboutus"> + <t t-call="website.layout"> + <p>aboutus</p> + <div id="oe_structure_test_ui" class="oe_structure oe_empty"/> + </t> + </t> + ''' + generic_aboutus.arch = oe_structure_layout + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('html_editor_multiple_templates')", "odoo.__DEBUG__.services['web_tour.tour'].tours.html_editor_multiple_templates.ready", login='admin') + self.assertEqual(View.search_count([('key', '=', 'website.aboutus')]), 2, "Aboutus view should have been COW'd") + self.assertTrue(generic_aboutus.arch == oe_structure_layout, "Generic Aboutus view should be untouched") + self.assertEqual(len(generic_aboutus.inherit_children_ids.filtered(lambda v: 'oe_structure' in v.name)), 0, "oe_structure view should have been deleted when aboutus was COW") + specific_aboutus = Website.with_context(website_id=1).viewref('website.aboutus') + self.assertTrue(specific_aboutus.arch != oe_structure_layout, "Specific Aboutus view should have been changed") + self.assertEqual(len(specific_aboutus.inherit_children_ids.filtered(lambda v: 'oe_structure' in v.name)), 1, "oe_structure view should have been created on the specific tree") + + def test_html_editor_scss(self): + self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('test_html_editor_scss')", "odoo.__DEBUG__.services['web_tour.tour'].tours.test_html_editor_scss.ready", login='admin') + + class TestUiTranslate(odoo.tests.HttpCase): def test_admin_tour_rte_translator(self): self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('rte_translator')", "odoo.__DEBUG__.services['web_tour.tour'].tours.rte_translator.ready", login='admin', timeout=120) diff --git a/addons/website/views/website_navbar_templates.xml b/addons/website/views/website_navbar_templates.xml index 80eb4a2dfdd..3ce8155534f 100644 --- a/addons/website/views/website_navbar_templates.xml +++ b/addons/website/views/website_navbar_templates.xml @@ -171,7 +171,7 @@ <span class="sr-only">Toggle Dropdown</span> </a> <div class="dropdown-menu" role="menu"> - <a role="menuitem" style="text-align: left;" t-attf-href="/web#return_label=Website&view_type=form&model=#{main_object._name}&id=#{main_object.id}&action=#{action}" + <a role="menuitem" style="text-align: left;" t-attf-href="/web#return_label=Website&view_type=form&model=#{main_object._name}&id=#{main_object.id}&action=#{action}&menu_id=#{menu or main_object.env.ref('website.menu_website_configuration').id}" class="dropdown-item" title='Edit in backend' id="edit-in-backend">Edit in backend</a> </div> </li> diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index 05febbf1fae..60022a477a3 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -57,6 +57,8 @@ <script type="text/javascript" src="/website/static/src/js/content/zoomodoo.js"/> <script type="text/javascript" src="/website/static/src/js/menu/navbar.js"/> + + <script type="text/javascript" src="/website/static/src/js/html_editor.test.js"/> </xpath> </template> @@ -313,7 +315,7 @@ </record> <!-- Features template --> -<template id="login_layout" inherit_id="web.login_layout" name="Website Login Layout"> +<template id="login_layout" inherit_id="web.login_layout" name="Website Login Layout" priority="20"> <xpath expr="t" position="replace"> <t t-call="website.layout"> <div class="oe_website_login_container" t-raw="0"/> @@ -411,7 +413,7 @@ <button type="button" t-attf-class="btn btn-default dropdown-toggle dropdown-toggle-split" t-att-id="'dopprod-%s' % object.id" data-toggle="dropdown"/> <div class="dropdown-menu" role="menu" t-att-aria-labelledby="'dopprod-%s' % object.id"> <t t-raw="0"/> - <a role="menuitem" t-attf-href="/web#return_label=Website&view_type=form&model=#{object._name}&id=#{object.id}&action=#{action}" + <a role="menuitem" t-attf-href="/web#return_label=Website&view_type=form&model=#{object._name}&id=#{object.id}&action=#{action}&menu_id=#{menu or object.env.ref('website.menu_website_configuration').id}" title='Edit in backend' class="dropdown-item" t-if="publish_edit">Edit</a> </div> </div> diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py index a35b8a18f3b..ee9098dd614 100644 --- a/addons/website_forum/models/forum.py +++ b/addons/website_forum/models/forum.py @@ -842,6 +842,7 @@ class PostReason(models.Model): class Vote(models.Model): _name = 'forum.post.vote' _description = 'Post Vote' + _order = 'create_date desc, id desc' post_id = fields.Many2one('forum.post', string='Post', ondelete='cascade', required=True) user_id = fields.Many2one('res.users', string='User', required=True, default=lambda self: self._uid) 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 45373cea37a..cc104fecc39 100644 --- a/addons/website_sale/static/src/js/website_sale_payment.js +++ b/addons/website_sale/static/src/js/website_sale_payment.js @@ -7,9 +7,12 @@ var $checkbox = $("#checkbox_cgv"); if (!$checkbox.length) { return; } +var $pay_button = $('button#o_payment_form_pay'); $checkbox.on('change', function (ev) { - $('button#o_payment_form_pay').prop('disabled', !ev.target.checked); + $pay_button.data('disabled_reasons', $pay_button.data('disabled_reasons') || {}); + $pay_button.data('disabled_reasons').cgv = !ev.target.checked; + $pay_button.prop('disabled', _.contains($pay_button.data('disabled_reasons'), true)); }); $checkbox.change(); }); diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 8231728213b..2290b8025f9 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -417,9 +417,10 @@ <!-- /shop/product product page --> <template id="product_edit_options" inherit_id="website.user_navbar" name="Edit Product Options"> <xpath expr="//li[@id='edit-page-menu']" position="after"> - <t t-if="main_object._name == 'product.template'" t-set="action" t-value="'product.product_template_action'" /> + <t t-if="main_object._name == 'product.template'" t-set="action" t-value="'website_sale.product_template_action_website'" /> </xpath> </template> + <template id="product" name="Product"> <t t-set="first_possible_combination" t-value="product._get_first_possible_combination()"/> diff --git a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js index 6d911b02f87..ef717be1f9a 100644 --- a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js +++ b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js @@ -62,8 +62,8 @@ sAnimations.registry.websiteSaleDelivery = sAnimations.Class.extend({ $carrierBadge.children('span').text(result.new_amount_delivery); $carrierBadge.removeClass('d-none'); $computeBadge.addClass('d-none'); - $payButton.prop('disabled', false); - } else { + $payButton.data('disabled_reasons').carrier_selection = false; + $payButton.prop('disabled', _.contains($payButton.data('disabled_reasons'), true)); console.error(result.error_message); $computeBadge.text(result.error_message); $amountDelivery.text(result.new_amount_delivery); @@ -82,7 +82,10 @@ sAnimations.registry.websiteSaleDelivery = sAnimations.Class.extend({ * @param {Event} ev */ _onCarrierClick: function (ev) { - $('#o_payment_form_pay').prop('disabled', true); + var $payButton = $('#o_payment_form_pay'); + $payButton.prop('disabled', true); + $payButton.data('disabled_reasons', $payButton.data('disabled_reasons') || {}); + $payButton.data('disabled_reasons').carrier_selection = true; this._rpc({ route: '/shop/update_carrier', params: { diff --git a/addons/website_sale_stock/static/src/js/product_configurator_mixin.js b/addons/website_sale_stock/static/src/js/product_configurator_mixin.js index fe9a92f1d5e..8781eba0779 100644 --- a/addons/website_sale_stock/static/src/js/product_configurator_mixin.js +++ b/addons/website_sale_stock/static/src/js/product_configurator_mixin.js @@ -45,7 +45,7 @@ ProductConfiguratorMixin._onChangeCombinationStock = function (ev, $parent, comb var qty = $parent.find('input[name="add_qty"]').val(); $parent.find('#add_to_cart').removeClass('out_of_stock'); - if (_.contains(['always', 'threshold'], combination.inventory_availability)) { + if (combination.product_type === 'product' && _.contains(['always', 'threshold'], combination.inventory_availability)) { combination.virtual_available -= parseInt(combination.cart_qty); if (combination.virtual_available < 0) { combination.virtual_available = 0; @@ -88,4 +88,4 @@ sAnimations.registry.WebsiteSale.include({ return ProductConfiguratorMixin; -});
\ No newline at end of file +}); diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index f04a61c4903..e4f3f18a040 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -190,7 +190,7 @@ class WebsiteSlides(http.Controller): @http.route('''/slides/slide/<model("slide.slide"):slide>/download''', type='http', auth="public", website=True, sitemap=False) def slide_download(self, slide, **kw): slide = slide.sudo() - if slide.download_security == 'public' or (slide.download_security == 'user' and request.session.uid): + if slide.download_security == 'public' or (slide.download_security == 'user' and request.env.user and request.env.user != request.website.user_id): filecontent = base64.b64decode(slide.datas) disposition = 'attachment; filename=%s.pdf' % werkzeug.urls.url_quote(slide.name) return request.make_response( |
