diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-09-13 13:32:51 +0000 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-09-13 13:32:51 +0000 |
| commit | 5a273e74f0fff18df5ab6cb240c9e260be4bb7ff (patch) | |
| tree | 4fbc7e142335d110fa0a984ff2c345a487e37ca4 /addons | |
| parent | dc5f83b63313018999b598210b972636986c5bea (diff) | |
| parent | fe59754c5201f9392b75d5d4cce8ca1400a7ef5d (diff) | |
[MERGE] forward port branch saas-12.4 up to fe59754c520
closes odoo/odoo#36721
Signed-off-by: Christophe Simonis <chs@odoo.com>
Diffstat (limited to 'addons')
136 files changed, 2771 insertions, 539 deletions
diff --git a/addons/account/models/account.py b/addons/account/models/account.py index 197ef0a8b37..8817006b4ac 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -1441,20 +1441,27 @@ class AccountTax(models.Model): cached_tax_amounts = {} if handle_price_include: for tax in reversed(taxes): + tax_repartition_lines = ( + is_refund + and tax.refund_repartition_line_ids + or tax.invoice_repartition_line_ids + ).filtered(lambda x: x.repartition_type == "tax") + sum_repartition_factor = sum(tax_repartition_lines.mapped("factor")) + if tax.include_base_amount: base = recompute_base(base, incl_fixed_amount, incl_percent_amount, incl_division_amount) incl_fixed_amount = incl_percent_amount = incl_division_amount = 0 store_included_tax_total = True if tax.price_include or self._context.get('force_price_include'): if tax.amount_type == 'percent': - incl_percent_amount += tax.amount + incl_percent_amount += tax.amount * sum_repartition_factor elif tax.amount_type == 'division': - incl_division_amount += tax.amount + incl_division_amount += tax.amount * sum_repartition_factor elif tax.amount_type == 'fixed': - incl_fixed_amount += quantity * tax.amount + incl_fixed_amount += quantity * tax.amount * sum_repartition_factor else: # tax.amount_type == other (python) - tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) + tax_amount = tax._compute_amount(base, price_unit, quantity, product, partner) * sum_repartition_factor incl_fixed_amount += tax_amount # Avoid unecessary re-computation cached_tax_amounts[i] = tax_amount @@ -1473,6 +1480,9 @@ class AccountTax(models.Model): i = 0 cumulated_tax_included_amount = 0 for tax in taxes: + tax_repartition_lines = (is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids).filtered(lambda x: x.repartition_type == 'tax') + sum_repartition_factor = sum(tax_repartition_lines.mapped('factor')) + #compute the tax_amount if (self._context.get('force_price_include') or tax.price_include) and total_included_checkpoints.get(i): # We know the total to reach for that tax, so we make a substraction to avoid any rounding issues @@ -1480,13 +1490,14 @@ class AccountTax(models.Model): cumulated_tax_included_amount = 0 else: tax_amount = tax.with_context(force_price_include=False)._compute_amount( - base, price_unit, quantity, product, partner) + base, sign * price_unit, quantity, product, partner) - # Round the tax_amount + # Round the tax_amount multiplied by the computed repartition lines factor. tax_amount = round(tax_amount, prec) + factorized_tax_amount = round(tax_amount * sum_repartition_factor, prec) if tax.price_include and not total_included_checkpoints.get(i): - cumulated_tax_included_amount += tax_amount + cumulated_tax_included_amount += factorized_tax_amount # If the tax affects the base of subsequent taxes, its tax move lines must # receive the base tags and tag_ids of these taxes, so that the tax report computes @@ -1497,18 +1508,24 @@ class AccountTax(models.Model): subsequent_taxes = taxes[i+1:] subsequent_tags = subsequent_taxes.get_tax_tags(is_refund, 'base') - # Compute the tax lines - tax_repartition_lines = (is_refund and tax.refund_repartition_line_ids or tax.invoice_repartition_line_ids).filtered(lambda x: x.repartition_type == 'tax') - sum_factor = sum(tax_repartition_lines.mapped('factor')) - repartition_lines_to_treat = len(tax_repartition_lines) - total_amount = 0 - for repartition_line in tax_repartition_lines: - if repartition_lines_to_treat != 1 or sum_factor != 1.0: - line_amount = round(tax_amount * repartition_line.factor, prec) - else: - # When the sum of the repartition lines factor is 100%, we need to ensure the whole tax amount has - # been spread into the repartition lines. - line_amount = round(tax_amount - total_amount, prec) + # Compute the tax line amounts by multiplying each factor with the tax amount. + # Then, spread the tax rounding to ensure the consistency of each line independently with the factorized + # amount. E.g: + # + # Suppose a tax having 4 x 50% repartition line applied on a tax amount of 0.03 with 2 decimal places. + # The factorized_tax_amount will be 0.06 (200% x 0.03). However, each line taken independently will compute + # 50% * 0.03 = 0.01 with rounding. It means there is 0.06 - 0.04 = 0.02 as total_rounding_error to dispatch + # in lines as 2 x 0.01. + repartition_line_amounts = [round(tax_amount * line.factor, prec) for line in tax_repartition_lines] + total_rounding_error = round(factorized_tax_amount - sum(repartition_line_amounts), prec) + nber_rounding_steps = int(abs(total_rounding_error / currency.rounding)) + rounding_error = round(nber_rounding_steps and total_rounding_error / nber_rounding_steps or 0.0, prec) + + for repartition_line, line_amount in zip(tax_repartition_lines, repartition_line_amounts): + + if nber_rounding_steps: + line_amount += rounding_error + nber_rounding_steps -= 1 taxes_vals.append({ 'id': tax.id, @@ -1525,16 +1542,14 @@ class AccountTax(models.Model): 'tax_ids': subsequent_taxes.ids, }) - total_amount += line_amount if not repartition_line.account_id: total_void += line_amount - repartition_lines_to_treat -= 1 # Affect subsequent taxes if tax.include_base_amount: - base += tax_amount + base += factorized_tax_amount - total_included += tax_amount + total_included += factorized_tax_amount i += 1 return { diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index a3002fa1f8c..61afd62f4eb 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -172,6 +172,8 @@ class AccountMove(models.Model): states={'draft': [('readonly', False)]}, string='Salesperson', default=lambda self: self.env.user) + user_id = fields.Many2one(string='User', related='invoice_user_id', + help='Technical field used to fit the generic behavior in mail templates.') invoice_payment_state = fields.Selection(selection=[ ('not_paid', 'Not Paid'), ('in_payment', 'In Payment'), @@ -426,25 +428,50 @@ class AccountMove(models.Model): :param base_line: The account.move.line owning the taxes. :return: The result of the compute_all method. ''' + move = base_line.move_id + + if move.is_invoice(): + sign = -1 if move.is_inbound() else 1 + quantity = base_line.quantity + if base_line.currency_id: + price_unit_foreign_curr = sign * base_line.price_unit * (1 - (base_line.discount / 100.0)) + price_unit_comp_curr = base_line.currency_id._convert(price_unit_foreign_curr, move.company_id.currency_id, move.company_id, move.date) + else: + price_unit_foreign_curr = 0.0 + price_unit_comp_curr = sign * base_line.price_unit * (1 - (base_line.discount / 100.0)) + else: + quantity = 1.0 + price_unit_foreign_curr = base_line.amount_currency + price_unit_comp_curr = base_line.balance + balance_taxes_res = base_line.tax_ids._origin.compute_all( - base_line.balance, + price_unit_comp_curr, currency=base_line.company_currency_id, + quantity=quantity, + product=base_line.product_id, partner=base_line.partner_id, is_refund=self.type in ('out_refund', 'in_refund'), - handle_price_include=False, ) if base_line.currency_id: # Multi-currencies mode: Taxes are computed both in company's currency / foreign currency. amount_currency_taxes_res = base_line.tax_ids._origin.compute_all( - base_line.amount_currency, + price_unit_foreign_curr, currency=base_line.currency_id, + quantity=quantity, + product=base_line.product_id, partner=base_line.partner_id, is_refund=self.type in ('out_refund', 'in_refund'), - handle_price_include=False, ) for b_tax_res, ac_tax_res in zip(balance_taxes_res['taxes'], amount_currency_taxes_res['taxes']): + tax = self.env['account.tax'].browse(b_tax_res['id']) b_tax_res['amount_currency'] = ac_tax_res['amount'] + + # A tax having a fixed amount must be converted into the company currency when dealing with a + # foreign currency. + if tax.amount_type == 'fixed': + b_tax_res['amount'] = base_line.currency_id._convert(b_tax_res['amount'], move.company_id.currency_id, move.company_id, move.date) + return balance_taxes_res taxes_map = {} @@ -897,35 +924,43 @@ class AccountMove(models.Model): total_tax_currency = 0.0 total_residual = 0.0 total_residual_currency = 0.0 + total = 0.0 + total_currency = 0.0 currencies = set() for line in move.line_ids: if line.currency_id: currencies.add(line.currency_id) - # Untaxed amount. - if (move.is_invoice(include_receipts=True) and not line.exclude_from_invoice_tab)\ - or (move.type == 'entry' and line.debit and not line.tax_line_id): - total_untaxed += line.balance - total_untaxed_currency += line.amount_currency - - # Tax amount. - if line.tax_line_id: - total_tax += line.balance - total_tax_currency += line.amount_currency - - # Residual amount. - if move.type == 'entry' or line.account_id.user_type_id.type in ('receivable', 'payable'): - total_residual += line.amount_residual - total_residual_currency += line.amount_residual_currency - - total = total_untaxed + total_tax - total_currency = total_untaxed_currency + total_tax_currency + if move.is_invoice(include_receipts=True): + # === Invoices === + + if not line.exclude_from_invoice_tab: + # Untaxed amount. + total_untaxed += line.balance + total_untaxed_currency += line.amount_currency + total += line.balance + total_currency += line.amount_currency + elif line.tax_line_id: + # Tax amount. + total_tax += line.balance + total_tax_currency += line.amount_currency + total += line.balance + total_currency += line.amount_currency + elif line.account_id.user_type_id.type in ('receivable', 'payable'): + # Residual amount. + total_residual += line.amount_residual + total_residual_currency += line.amount_residual_currency + else: + # === Miscellaneous journal entry === + if line.debit: + total += line.balance + total_currency += line.amount_currency - if (move.type == 'entry' and total < 0.0) or move.is_inbound(): - sign = -1 - else: + if move.type == 'entry' or move.is_outbound(): sign = 1 + else: + sign = -1 move.amount_untaxed = sign * (total_untaxed_currency if len(currencies) == 1 else total_untaxed) move.amount_tax = sign * (total_tax_currency if len(currencies) == 1 else total_tax) move.amount_total = sign * (total_currency if len(currencies) == 1 else total) @@ -1209,27 +1244,25 @@ class AccountMove(models.Model): if 'posted' in self.mapped('line_ids.payment_id.state'): raise ValidationError(_("You cannot modify a journal entry linked to a posted payment.")) - @api.constrains('name', 'journal_id') + @api.constrains('name', 'journal_id', 'state') def _check_unique_sequence_number(self): - if not self: + moves = self.filtered(lambda move: move.state == 'posted') + if not moves: return + self.flush() + + # /!\ Computed stored fields are not yet inside the database. self._cr.execute(''' - WITH current_moves (id, name, state, company_id, journal_id, type) as ( values {} ) SELECT move2.id - FROM ( SELECT id, name, state, company_id, journal_id, type from account_move - UNION ALL SELECT id, name, state, company_id, journal_id, type from current_moves - ) move + FROM account_move move INNER JOIN account_move move2 ON move2.name = move.name - AND move2.company_id = move.company_id AND move2.journal_id = move.journal_id AND move2.type = move.type AND move2.id != move.id - WHERE move.id IN %s - AND move.state = 'posted' - AND move2.state = 'posted' - '''.format(", ".join(["(%s, %s, %s, %s, %s, %s)"] * len(self))), list(chain(*self.mapped(lambda r: [r.id, r.name, r.state, r.company_id.id, r.journal_id.id, r.type]))) + [tuple(self.ids)]) + WHERE move.id IN %s AND move2.state = 'posted' + ''', [tuple(moves.ids)]) res = self._cr.fetchone() if res: raise ValidationError(_('Posted journal entry must have an unique sequence number per company.')) @@ -1240,23 +1273,28 @@ class AccountMove(models.Model): if not moves: return + self.env["account.move"].flush([ + "ref", "type", "invoice_date", "journal_id", + "company_id", "partner_id", "commercial_partner_id", + ]) + self.env["account.journal"].flush(["company_id"]) + self.env["res.partner"].flush(["commercial_partner_id"]) + + # /!\ Computed stored fields are not yet inside the database. self._cr.execute(''' - WITH current_moves (id, ref, company_id, commercial_partner_id, type, invoice_date) as ( values {} ) SELECT move2.id - FROM ( SELECT id, ref, company_id, commercial_partner_id, type, invoice_date from account_move - UNION ALL SELECT id, ref, company_id, commercial_partner_id, type, invoice_date from current_moves - ) move + FROM account_move move + JOIN account_journal journal ON journal.id = move.journal_id + JOIN res_partner partner ON partner.id = move.partner_id INNER JOIN account_move move2 ON move2.ref = move.ref - AND move2.company_id = move.company_id - AND move2.commercial_partner_id = move.commercial_partner_id + AND move2.company_id = journal.company_id + AND move2.commercial_partner_id = partner.commercial_partner_id AND move2.type = move.type - AND (move2.invoice_date = move.invoice_date OR move.invoice_date is NULL) + AND (move.invoice_date is NULL OR move2.invoice_date = move.invoice_date) AND move2.id != move.id WHERE move.id IN %s - AND move.type in ('in_invoice', 'in_refund') - AND move.ref IS NOT NULL - '''.format(", ".join(["(%s, %s, %s, %s, %s, CAST(%s as DATE))"] * len(moves))), list(chain(*moves.mapped(lambda r: [r.id, r.ref, r.company_id.id, r.commercial_partner_id.id, r.type, r.invoice_date or None]))) + [tuple(moves.ids)]) + ''', [tuple(moves.ids)]) if self._cr.fetchone(): raise ValidationError(_('Duplicated vendor reference detected. You probably encoded twice the same vendor bill/credit note.')) @@ -1304,24 +1342,22 @@ class AccountMove(models.Model): return True def _check_tax_lock_date(self): - if not self: - return - - self._cr.execute(''' - SELECT move.id, company.tax_lock_date - FROM account_move move - JOIN account_journal journal ON journal.id = move.journal_id - JOIN res_company company ON company.id = journal.company_id - WHERE move.id IN %s - AND move.date < company.tax_lock_date - ''', [tuple(self.ids)]) - - query_res = self._cr.fetchone() - if query_res: - raise UserError(_(''' - The operation is refused as it would impact an already issued tax statement. - Please change the journal entry date or the tax lock date set in the settings (%s) to proceed - ''' % query_res[1])) + for move in self: + if ( + move.company_id.tax_lock_date + and move.date <= move.company_id.tax_lock_date + and any( + line.tax_ids + or line.tax_line_id + or line.tag_ids.filtered(lambda x: x.applicability == "taxes") + for line in move.line_ids + ) + ): + raise UserError( + _( + "The operation is refused as it would impact an already issued tax statement. Please change the journal entry date or the tax lock date set in the settings ({}) to proceed" + ).format(move.company_id.tax_lock_date or date.min) + ) def _check_move_consistency(self): for move in self: @@ -1330,6 +1366,7 @@ class AccountMove(models.Model): raise UserError(_("Cannot create moves for different companies.")) self._check_balanced() + self._check_tax_lock_date() self._check_fiscalyear_lock_date() # ------------------------------------------------------------------------- @@ -1694,6 +1731,13 @@ class AccountMove(models.Model): else: return abs(total_reconciled / total_amount) + def _get_reconciled_payments(self): + """Helper used to retrieve the reconciled payments on this journal entry""" + pay_term_line_ids = self.line_ids.filtered(lambda line: line.account_id.user_type_id.type in ('receivable', 'payable')) + reconciled_amls = pay_term_line_ids.mapped('matched_debit_ids.debit_move_id') + \ + pay_term_line_ids.mapped('matched_credit_ids.credit_move_id') + return reconciled_amls.mapped('payment_id') + def _reverse_move_vals(self, default_values, cancel=True): ''' Reverse values passed as parameter being the copied values of the original journal entry. For example, debit / credit must be switched. The tax lines must be edited in case of refunds. @@ -1820,7 +1864,16 @@ class AccountMove(models.Model): 'reversed_entry_id': move.id, }) move_vals_list.append(move._reverse_move_vals(default_values, cancel=cancel)) + reverse_moves = self.env['account.move'].create(move_vals_list) + for move, reverse_move in zip(self, reverse_moves.with_context(check_move_validity=False)): + # Update amount_currency if the date has changed. + if move.date != reverse_move.date: + for line in reverse_move.line_ids: + if line.currency_id: + line._onchange_currency() + reverse_move._recompute_dynamic_lines(recompute_all_taxes=False) + reverse_moves._check_balanced() # Reconcile moves together to cancel the previous one. if cancel: @@ -1861,7 +1914,6 @@ class AccountMove(models.Model): move.invoice_date = fields.Date.context_today(self) move.with_context(check_move_validity=False)._onchange_invoice_date() - self._check_tax_lock_date() self._check_move_consistency() # Create the analytic lines in batch is faster as it leads to less cache invalidation. self.mapped('line_ids').create_analytic_lines() @@ -1946,6 +1998,7 @@ class AccountMove(models.Model): self.write({'state': 'draft'}) def button_cancel(self): + self._check_tax_lock_date() AccountMoveLine = self.env['account.move.line'] excluded_move_ids = [] @@ -1953,6 +2006,10 @@ class AccountMove(models.Model): excluded_move_ids = AccountMoveLine.search(AccountMoveLine._get_suspense_moves_domain() + [('move_id', 'in', self.ids)]).mapped('move_id').ids for move in self: + if move in move.line_ids.mapped('full_reconcile_id.exchange_move_id'): + raise UserError(_('You cannot cancel an exchange difference journal entry.')) + if move.tax_cash_basis_rec_id: + raise UserError(_('You cannot cancel a tax cash basis journal entry.')) if not move.journal_id.update_posted and move.id not in excluded_move_ids: raise UserError(_('You cannot modify a posted entry of this journal.\nFirst you should set the journal to allow cancelling entries.')) # We remove all the analytics entries for this journal @@ -2002,6 +2059,7 @@ class AccountMove(models.Model): """ if any(not move.is_invoice(include_receipts=True) for move in self): raise UserError(_("Only invoices could be printed.")) + self._check_tax_lock_date() self.filtered(lambda inv: not inv.invoice_sent).write({'invoice_sent': True}) if self.user_has_groups('account.group_account_invoice'): @@ -2695,7 +2753,7 @@ class AccountMoveLine(models.Model): else: date = partial_line.credit_move_id.date if partial_line.debit_move_id == line else partial_line.debit_move_id.date rate = line.currency_id.with_context(date=date).rate - amount_residual_currency += sign_partial_line * partial_line.amount * rate + amount_residual_currency += sign_partial_line * line.currency_id.round(partial_line.amount * rate) #computing the `reconciled` field. reconciled = False @@ -2778,36 +2836,21 @@ class AccountMoveLine(models.Model): if line.reconciled: raise UserError(_('Lines from "Off-Balance Sheet" accounts cannot be reconciled')) - @api.constrains('tax_ids', 'tax_line_id') - def _check_tax_lock_date1(self): - for line in self: - if line.date <= (line.company_id.tax_lock_date or date.min): - raise ValidationError( - _("The operation is refused as it would impact an already issued tax statement. " + - "Please change the journal entry date or the tax lock date set in the settings ({}) to proceed").format( - line.company_id.tax_lock_date or date.min)) - - @api.constrains('credit', 'debit', 'date') - def _check_tax_lock_date2(self): - for line in self: - if (line.tax_ids or line.tax_line_id) and line.date <= (line.company_id.tax_lock_date or date.min): - raise ValidationError( - _("The operation is refused as it would impact an already issued tax statement. " + - "Please change the journal entry date or the tax lock date set in the settings ({}) to proceed").format( - line.company_id.tax_lock_date or date.min)) - def _update_check(self): """ Raise Warning to cause rollback if the move is posted, some entries are reconciled or the move is older than the lock date""" move_ids = set() for line in self: err_msg = _('Move name (id): %s (%s)') % (line.move_id.name, str(line.move_id.id)) - if line.move_id.state != 'draft': + if line.move_id.state == 'posted': raise UserError(_('You cannot do this modification on a posted journal entry, you can just change some non legal fields. You must revert the journal entry to cancel it.\n%s.') % err_msg) if line.reconciled and not (line.debit == 0 and line.credit == 0): raise UserError(_('You cannot do this modification on a reconciled entry. You can just change some non legal fields or you must unreconcile first.\n%s.') % err_msg) if line.move_id.id not in move_ids: move_ids.add(line.move_id.id) - self.env['account.move'].browse(list(move_ids))._check_fiscalyear_lock_date() + if move_ids: + moves = self.env['account.move'].browse(list(move_ids)) + moves._check_fiscalyear_lock_date() + moves._check_tax_lock_date() return True # ------------------------------------------------------------------------- @@ -2963,7 +3006,6 @@ class AccountMoveLine(models.Model): def unlink(self): self._update_check() - self._check_tax_lock_date2() move_ids = set() for line in self: if line.move_id.id not in move_ids: @@ -2997,6 +3039,11 @@ class AccountMoveLine(models.Model): # Suggest default value for debit / credit to balance the journal entry. balance = sum(line['debit'] - line['credit'] for line in move.line_ids) + # if we are here, line_ids is in context, so journal_id should also be. + journal = self.env['account.journal'].browse(self._context.get('default_journal_id') or self._context['journal_id']) + currency = journal.exists() and journal.company_id.currency_id + if currency: + balance = currency.round(balance) if balance < 0.0: values.update({'debit': -balance}) if balance > 0.0: @@ -3201,7 +3248,11 @@ class AccountMoveLine(models.Model): for after_rec_dict in cash_basis_subjected: new_rec = part_rec.create(after_rec_dict) # if the pair belongs to move being reverted, do not create CABA entry - if cash_basis and not (new_rec.debit_move_id + new_rec.credit_move_id).mapped('move_id.reversed_entry_id'): + if cash_basis and not ( + new_rec.debit_move_id.move_id == new_rec.credit_move_id.move_id.reversed_entry_id + or + new_rec.credit_move_id.move_id == new_rec.debit_move_id.move_id.reversed_entry_id + ): new_rec.create_tax_cash_basis_entry(cash_basis_percentage_before_rec) return debit_moves+credit_moves @@ -3369,8 +3420,11 @@ class AccountMoveLine(models.Model): def copy_data(self, default=None): res = super(AccountMoveLine, self).copy_data(default=default) - if self._context.get('include_business_fields'): - for line, values in zip(self, res): + for line, values in zip(self, res): + # Don't copy the name of a payment term line. + if line.move_id.is_invoice() and line.account_id.user_type_id.type in ('receivable', 'payable'): + values['name'] = '' + if self._context.get('include_business_fields'): line._copy_data_extend_business_fields(values) return res diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index b9e381f7fbf..25ddf2a3544 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -443,7 +443,8 @@ class account_payment(models.Model): for move in rec.move_line_ids.mapped('move_id'): if rec.reconciled_invoice_ids: move.line_ids.remove_move_reconcile() - move.button_cancel() + if move.state != 'draft': + move.button_cancel() move.unlink() rec.write({ 'state': 'cancelled', diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index 6ac0ad9986c..3a70d0516ae 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -399,7 +399,7 @@ class AccountReconcileModel(models.Model): query += ' AND st_line.name NOT ILIKE %s' params += ['%%%s%%' % rule['match_' + field + '_param']] elif rule['match_' + field] == 'match_regex': - query += ' AND st_line.name ~ %s' + query += ' AND st_line.name ~* %s' params += [rule['match_' + field + '_param']] # Filter on partners. @@ -469,13 +469,24 @@ class AccountReconcileModel(models.Model): aml.amount_currency AS aml_amount_currency, account.internal_type AS account_internal_type, - -- Determine a matching or not with the statement line communication using the move.name or move.ref. - regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+') - && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') + -- Determine a matching or not with the statement line communication using the aml.name, move.name or move.ref. + ( + aml.name IS NOT NULL + AND + TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')) != '' + AND + regexp_split_to_array(TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')),'\s+') + && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') + ) + OR + regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+') + && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') OR ( move.ref IS NOT NULL AND + TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')) != '' + AND regexp_split_to_array(TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')),'\s+') && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') ) AS communication_flag @@ -518,12 +529,23 @@ class AccountReconcileModel(models.Model): TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')) != '' AND ( - regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+') - && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') + ( + aml.name IS NOT NULL + AND + TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')) != '' + AND + regexp_split_to_array(TRIM(REGEXP_REPLACE(aml.name, '[^0-9|^\s]', '', 'g')),'\s+') + && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') + ) + OR + regexp_split_to_array(TRIM(REGEXP_REPLACE(move.name, '[^0-9|^\s]', '', 'g')),'\s+') + && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') OR ( move.ref IS NOT NULL AND + TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')) != '' + AND regexp_split_to_array(TRIM(REGEXP_REPLACE(move.ref, '[^0-9|^\s]', '', 'g')),'\s+') && regexp_split_to_array(TRIM(REGEXP_REPLACE(st_line.name, '[^0-9|^\s]', '', 'g')), '\s+') ) @@ -633,8 +655,10 @@ class AccountReconcileModel(models.Model): if line_residual > total_residual: amount_percentage = (total_residual / line_residual) * 100 - else: + elif total_residual: amount_percentage = (line_residual / total_residual) * 100 if total_residual else 0 + else: + return False return amount_percentage >= self.match_total_amount_param def _apply_rules(self, st_lines, excluded_ids=None, partner_map=None): diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index 42f8eabb0a7..367b8f82a93 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -141,7 +141,8 @@ class AccountReconciliation(models.AbstractModel): query += 'LEFT JOIN res_partner_bank bank ON bank.id = st_line.bank_account_id OR bank.acc_number = st_line.account_number %s\n' % (where_bank) query += 'LEFT JOIN res_partner p1 ON st_line.partner_id=p1.id \n' query += 'LEFT JOIN res_partner p2 ON bank.partner_id=p2.id \n' - query += 'LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s\n' % (where_partner) + # By definition the commercial partner_id doesn't have a parent_id set + query += 'LEFT JOIN res_partner p3 ON p3.name ILIKE st_line.partner_name %s AND p3.parent_id is NULL \n' % (where_partner) query += 'WHERE st_line.id IN %s' params += [tuple(st_lines.ids)] @@ -538,8 +539,10 @@ class AccountReconciliation(models.AbstractModel): ]) domain = expression.OR([domain_reconciliation, domain_matching]) + partner_domain = [] if partner_id: - domain = expression.AND([domain, [('partner_id', '=', partner_id)]]) + partner_domain = [('partner_id', '=', partner_id)] + domain = expression.AND([domain, partner_domain]) if mode == 'rp': domain = expression.AND([domain, [('account_id.internal_type', 'in', ['receivable', 'payable', 'liquidity'])] @@ -553,10 +556,13 @@ class AccountReconciliation(models.AbstractModel): if search_str: str_domain = self._domain_move_lines(search_str=search_str) if not partner_id: - str_domain = expression.OR([ - str_domain, - [('partner_id.name', 'ilike', search_str)] - ]) + partner_domain = [('partner_id.name', 'ilike', search_str)] + + str_domain = expression.OR([ + str_domain, + partner_domain, + ]) + domain = expression.AND([ domain, str_domain diff --git a/addons/account/static/tests/tours/reconciliation.js b/addons/account/static/tests/tours/reconciliation.js index 803acef88d0..6566c55e09f 100644 --- a/addons/account/static/tests/tours/reconciliation.js +++ b/addons/account/static/tests/tours/reconciliation.js @@ -63,19 +63,19 @@ Tour.register('bank_statement_reconciliation', { }, { content: "use filter", - trigger: '.o_reconciliation_line:nth-child(2) .match .match_controls .filter', + trigger: '.o_reconciliation_line:nth-child(1) .match .match_controls .filter', run: 'text 4610' }, { content: "select a line linked to Deco Addict ", - extra_trigger: ".o_reconciliation_line:nth-child(2) .match .line_info_button:last[data-content*='4,610']", - trigger: ".o_reconciliation_line:nth-child(2) .match .line_info_button[data-content*='Deco Addict']" + extra_trigger: ".o_reconciliation_line:nth-child(1) .match .line_info_button:last[data-content*='4,610']", + trigger: ".o_reconciliation_line:nth-child(1) .match .line_info_button[data-content*='Deco Addict']" }, { content: "deselect the line", - trigger: '.o_reconciliation_line:nth-child(2) .accounting_view tbody .cell_label:first', + trigger: '.o_reconciliation_line:nth-child(1) .accounting_view tbody .cell_label:first', run: function() { - $('.o_reconciliation_line:nth-child(2) .accounting_view tbody .cell_label:first').trigger('click'); + $('.o_reconciliation_line:nth-child(1) .accounting_view tbody .cell_label:first').trigger('click'); } }, { diff --git a/addons/account/tests/__init__.py b/addons/account/tests/__init__.py index 0cca1a3c3a1..59dd42269fe 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -20,5 +20,6 @@ from . import test_templates_consistency from . import test_account_fiscal_year from . import test_account_all_l10n from . import test_reconciliation_matching_rules +from . import test_account_move_tax_lock_date from . import test_account_onboarding from . import test_portal_attachment diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py index 4a0fbf2848a..587dfc48deb 100644 --- a/addons/account/tests/test_account_move_entry.py +++ b/addons/account/tests/test_account_move_entry.py @@ -69,34 +69,27 @@ class TestAccountMove(InvoiceTestCommon): # lines[3] = 'revenue line 2' lines = self.test_move.line_ids.sorted('debit') - # Writing not affecting a tax is allowed. - self.test_move.write({ - 'line_ids': [ - (1, lines[0].id, {'credit': 1750.0}), # counterpart line - (1, lines[2].id, {'debit': 600.0}), # revenue line 1 - ], - }) self.test_move.flush() self.cr.execute('SAVEPOINT test_misc_tax_lock_date_1') # Writing something affecting a tax is not allowed. - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.write({ 'line_ids': [ (1, lines[0].id, {'credit': 2750.0}), - (1, lines[3].id, {'debit': 2000.0}), + (1, lines[3].id, {'debit': 2100.0}), ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.write({ 'line_ids': [ (1, lines[3].id, {'tax_ids': [(6, 0, self.company_data['default_tax_purchase'].ids)]}), ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.write({ 'line_ids': [ (1, lines[0].id, {'credit': 1900.0}), @@ -104,12 +97,22 @@ class TestAccountMove(InvoiceTestCommon): ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.unlink() self.test_move.flush() self.test_move.invalidate_cache() self.cr.execute('ROLLBACK TO SAVEPOINT test_misc_tax_lock_date_1') - with self.assertRaises(UserError): - self.test_move.post() + def test_misc_unique_sequence_number(self): + ''' Ensure two journal entries can't share the same name when using the same sequence. ''' + self.test_move.post() + + # Edit the sequence to force the next move to get the same name. + self.test_move.journal_id\ + .sequence_id.date_range_ids\ + .filtered(lambda seq: seq.date_from == fields.Date.from_string('2016-01-01')).number_next -= 1 + + test_move2 = self.test_move.copy() + with self.assertRaises(ValidationError): + test_move2.post() diff --git a/addons/account/tests/test_account_move_in_invoice.py b/addons/account/tests/test_account_move_in_invoice.py index dd4a9a834e5..9696c31065f 100644 --- a/addons/account/tests/test_account_move_in_invoice.py +++ b/addons/account/tests/test_account_move_in_invoice.py @@ -3,6 +3,7 @@ from odoo.addons.account.tests.invoice_test_common import InvoiceTestCommon from odoo.tests.common import Form from odoo.tests import tagged from odoo import fields +from odoo.exceptions import ValidationError @tagged('post_install', '-at_install') @@ -851,7 +852,7 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): self.term_line_vals_1, ], self.move_vals) - def test_in_invoice_create_draft_refund(self): + def test_in_invoice_create_refund(self): self.invoice.post() move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ @@ -885,8 +886,10 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): }, { **self.term_line_vals_1, + 'name': '', 'debit': 1128.0, 'credit': 0.0, + 'date_maturity': move_reversal.date, }, ], { **self.move_vals, @@ -898,7 +901,7 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ 'date': fields.Date.from_string('2019-02-01'), - 'reason': 'because', + 'reason': 'no reason again', 'refund_method': 'cancel', }) reversal = move_reversal.reverse_moves() @@ -927,8 +930,10 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): }, { **self.term_line_vals_1, + 'name': '', 'debit': 1128.0, 'credit': 0.0, + 'date_maturity': move_reversal.date, }, ], { **self.move_vals, @@ -938,6 +943,128 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): 'invoice_payment_state': 'paid', }) + def test_in_invoice_create_refund_multi_currency(self): + ''' Test the account.move.reversal takes care about the currency rates when setting + a custom reversal date. + ''' + move_form = Form(self.invoice) + move_form.date = '2016-01-01' + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.invoice.post() + + # The currency rate changed from 1/3 to 1/2. + move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason', + 'refund_method': 'refund', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': -800.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 400.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': -160.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 80.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': -144.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 72.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': -24.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 12.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': 1128.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 564.0, + 'credit': 0.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'draft', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'invoice_payment_state': 'not_paid', + }) + + move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason again', + 'refund_method': 'cancel', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': -800.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 400.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': -160.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 80.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': -144.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 72.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': -24.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 12.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': 1128.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 564.0, + 'credit': 0.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'posted', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'invoice_payment_state': 'paid', + }) + def test_in_invoice_create_1(self): # Test creating an account_move with the least information. move = self.env['account.move'].create({ @@ -1042,6 +1169,14 @@ class TestAccountMoveInInvoiceOnchanges(InvoiceTestCommon): 'currency_id': self.currency_data['currency'].id, }) + def test_in_invoice_duplicate_supplier_reference(self): + ''' Ensure two vendor bills can't share the same vendor reference. ''' + self.invoice.ref = 'a supplier reference' + invoice2 = self.invoice.copy() + + with self.assertRaises(ValidationError): + invoice2.ref = 'a supplier reference' + def test_in_invoice_switch_in_refund_1(self): # Test creating an account_move with an in_invoice_type and switch it in an in_refund. move = self.env['account.move'].create({ diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py index c7a6330db8e..d3c68051ee1 100644 --- a/addons/account/tests/test_account_move_out_invoice.py +++ b/addons/account/tests/test_account_move_out_invoice.py @@ -836,7 +836,7 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon): self.assertRecordValues(invoice_copy, [{'name': 'INV/2019/0043'}]) - def test_out_invoice_create_draft_refund(self): + def test_out_invoice_create_refund(self): self.invoice.post() move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ @@ -870,12 +870,14 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon): }, { **self.term_line_vals_1, - 'name': self.invoice.name, + 'name': '', 'debit': 0.0, 'credit': 1410.0, + 'date_maturity': move_reversal.date, }, ], { **self.move_vals, + 'name': '/', 'date': move_reversal.date, 'state': 'draft', 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), @@ -913,9 +915,10 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon): }, { **self.term_line_vals_1, - 'name': self.invoice.name, + 'name': '', 'debit': 0.0, 'credit': 1410.0, + 'date_maturity': move_reversal.date, }, ], { **self.move_vals, @@ -925,6 +928,128 @@ class TestAccountMoveOutInvoiceOnchanges(InvoiceTestCommon): 'invoice_payment_state': 'paid', }) + def test_out_invoice_create_refund_multi_currency(self): + ''' Test the account.move.reversal takes care about the currency rates when setting + a custom reversal date. + ''' + move_form = Form(self.invoice) + move_form.date = '2016-01-01' + move_form.currency_id = self.currency_data['currency'] + move_form.save() + + self.invoice.post() + + # The currency rate changed from 1/3 to 1/2. + move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason', + 'refund_method': 'refund', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': 1000.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 500.0, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': 200.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 100.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': 180.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 90.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': 30.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 15.0, + 'credit': 0.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': -1410.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 705.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'draft', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'invoice_payment_state': 'not_paid', + }) + + move_reversal = self.env['account.move.reversal'].with_context(active_ids=self.invoice.ids).create({ + 'date': fields.Date.from_string('2017-01-01'), + 'reason': 'no reason', + 'refund_method': 'cancel', + }) + reversal = move_reversal.reverse_moves() + reverse_move = self.env['account.move'].browse(reversal['res_id']) + + self.assertInvoiceValues(reverse_move, [ + { + **self.product_line_vals_1, + 'amount_currency': 1000.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 500.0, + 'credit': 0.0, + }, + { + **self.product_line_vals_2, + 'amount_currency': 200.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 100.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_1, + 'amount_currency': 180.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 90.0, + 'credit': 0.0, + }, + { + **self.tax_line_vals_2, + 'amount_currency': 30.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 15.0, + 'credit': 0.0, + }, + { + **self.term_line_vals_1, + 'name': '', + 'amount_currency': -1410.0, + 'currency_id': self.currency_data['currency'].id, + 'debit': 0.0, + 'credit': 705.0, + 'date_maturity': move_reversal.date, + }, + ], { + **self.move_vals, + 'currency_id': self.currency_data['currency'].id, + 'date': move_reversal.date, + 'state': 'posted', + 'ref': 'Reversal of: %s, %s' % (self.invoice.name, move_reversal.reason), + 'invoice_payment_state': 'paid', + }) + def test_out_invoice_create_1(self): # Test creating an account_move with the least information. move = self.env['account.move'].create({ diff --git a/addons/account/tests/test_account_move_tax_lock_date.py b/addons/account/tests/test_account_move_tax_lock_date.py new file mode 100644 index 00000000000..280536e6b46 --- /dev/null +++ b/addons/account/tests/test_account_move_tax_lock_date.py @@ -0,0 +1,98 @@ +from odoo.addons.account.tests.account_test_classes import AccountingTestCase +from odoo.exceptions import UserError +from datetime import date +from calendar import monthrange +# from odoo.tools import DEFAULT_SERVER_DATE_FORMAT +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestTaxBlockDate(AccountingTestCase): + """ + Forbid creation, edition and deletion of Journal Items related to taxes with + a date prior to the Tax Block Date. + """ + + def setUp(self): + super(TestTaxBlockDate, self).setUp() + self.user_id = self.env.user + self.company = self.env.company + company_id = self.company.id + + last_day_month = date.today() + self.last_day_month = last_day_month.replace(day=monthrange(last_day_month.year, last_day_month.month)[1]) + first_day_month = date.today() + self.first_day_month = first_day_month.replace(day=1) + middle_day_month = date.today() + middle_day_month = middle_day_month.replace(day=15) + + self.sale_journal_id = self.env['account.journal'].search([('type', '=', 'sale'), ('company_id', '=', company_id)], limit=1)[0] + self.account_id = self.env['account.account'].search([('internal_type', '=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0] + self.other_account_id = self.env['account.account'].search([('internal_type', '!=', 'receivable'), ('company_id', '=', company_id)], limit=1)[0] + self.tax_id = self.env['account.tax'].search([('company_id', '=', company_id)], limit=1)[0] + + self.move = { + 'name': '/', + 'journal_id': self.sale_journal_id.id, + 'date': middle_day_month, + 'line_ids': [(0, 0, { + 'name': 'foo', + 'debit': 10, + 'account_id': self.account_id.id, + 'tax_ids': [(6, False, [self.tax_id.id])] + }), (0, 0, { + 'name': 'bar', + 'credit': 10, + 'account_id': self.account_id.id, + })] + } + + self.move_no_tax = { + 'name': '/', + 'journal_id': self.sale_journal_id.id, + 'date': middle_day_month, + 'line_ids': [(0, 0, { + 'name': 'foo', + 'debit': 10, + 'account_id': self.account_id.id, + }), (0, 0, { + 'name': 'bar', + 'credit': 10, + 'account_id': self.account_id.id, + })] + } + + def test_tax_lock_date_post(self): + """ Checks that it is impossible to post an entry + before the tax lock date. + """ + # Posting after the lock date is always allowed + self.company.tax_lock_date = self.first_day_month + # copy() because mail.thread modifies the dictionary given to create + move_after_lock = self.env['account.move'].create(self.move.copy()) + move_after_lock.post() + + # Posting before the tax lock date is allowed only if the move doesn't contain any tax + self.user_id.company_id.tax_lock_date = self.last_day_month + move_before_lock_no_tax = self.env['account.move'].create(self.move_no_tax) + move_before_lock_no_tax.post() + + # Posting before the tax lock date is not allowed for moves impacting the tax report + self.user_id.company_id.tax_lock_date = self.last_day_month + with self.assertRaises(UserError): + self.env['account.move'].create(self.move.copy()) + + def test_tax_lock_date_cancel(self): + """ Checks that it is not possible to cancel an entry posted before the + tax lock date if it impacts the tax report. """ + self.sale_journal_id.update_posted = True + move = self.env['account.move'].create(self.move) + move.post() + move_no_tax = self.env['account.move'].create(self.move_no_tax) + move_no_tax.post() + + self.user_id.company_id.tax_lock_date = self.last_day_month + + move_no_tax.button_cancel() + with self.assertRaises(UserError): + move.button_cancel() diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index fa94e34018e..94e5e69bee4 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -149,6 +149,24 @@ class TestReconciliation(AccountingTestCase): invoice.post() return invoice + def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False, payment_term_id=False): + move = self.env['account.move'].with_context(default_type=type).create({ + 'type': type, + 'partner_id': partner_id, + 'invoice_date': time.strftime('%Y') + '-07-01', + 'date': time.strftime('%Y') + '-07-01', + 'currency_id': currency_id, + 'invoice_payment_term_id': payment_term_id, + 'invoice_line_ids': [(0, 0, { + 'quantity': 1, + 'price_unit': invoice_amount, + + 'name': 'product that cost ' + str(invoice_amount), + })], + }) + move.post() + return move + def make_payment(self, invoice_record, bank_journal, amount=0.0, amount_currency=0.0, currency_id=None): bank_stmt = self.acc_bank_stmt_model.create({ 'journal_id': bank_journal.id, @@ -895,22 +913,6 @@ class TestReconciliationExec(TestReconciliation): self.assertEqual(reversed_bank_line.full_reconcile_id.id, bank_line.full_reconcile_id.id) self.assertEqual(reversed_customer_line.full_reconcile_id.id, customer_line.full_reconcile_id.id) - def create_invoice_partner(self, type='out_invoice', invoice_amount=50, currency_id=None, partner_id=False, payment_term_id=False): - move = self.env['account.move'].with_context(default_type=type).create({ - 'type': type, - 'partner_id': partner_id, - 'invoice_date': time.strftime('%Y') + '-07-01', - 'date': time.strftime('%Y') + '-07-01', - 'currency_id': currency_id, - 'invoice_payment_term_id': payment_term_id, - 'invoice_line_ids': [(0, 0, { - 'quantity': 1, - 'price_unit': invoice_amount, - 'name': 'product that cost ' + str(invoice_amount), - })], - }) - move.post() - return move def test_aged_report(self): AgedReport = self.env['report.account.report_agedpartnerbalance'].with_context(include_nullified_amount=True) @@ -1169,6 +1171,68 @@ class TestReconciliationExec(TestReconciliation): # because they owe us still 50 CC. self.assertEqual(invoice_cust_1.invoice_payment_state, 'not_paid', 'Invoice is in status %s' % invoice_cust_1.state) + def test_inv_refund_foreign_payment_writeoff_domestic(self): + company = self.env.ref('base.main_company') + self.env['res.currency.rate'].search([]).unlink() + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.0, + 'currency_id': self.currency_euro_id, + 'company_id': company.id + }) + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y') + '-07-01', + 'rate': 1.113900, # Don't change this ! + 'currency_id': self.currency_usd_id, + 'company_id': self.env.ref('base.main_company').id + }) + inv1 = self.create_invoice(invoice_amount=480, currency_id=self.currency_usd_id) + inv2 = self.create_invoice(type="out_refund", invoice_amount=140, currency_id=self.currency_usd_id) + + payment = self.env['account.payment'].create({ + 'payment_method_id': self.inbound_payment_method.id, + 'payment_type': 'inbound', + 'partner_type': 'customer', + 'partner_id': inv1.partner_id.id, + 'amount': 287.20, + 'journal_id': self.bank_journal_euro.id, + 'company_id': company.id, + }) + payment.post() + + inv1_receivable = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + inv2_receivable = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + pay_receivable = payment.move_line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + data_for_reconciliation = [ + { + 'type': 'partner', + 'id': inv1.partner_id.id, + 'mv_line_ids': (inv1_receivable + inv2_receivable + pay_receivable).ids, + 'new_mv_line_dicts': [ + { + 'credit': 18.04, + 'debit': 0.00, + 'journal_id': self.bank_journal_euro.id, + 'name': 'Total WriteOff (Fees)', + 'account_id': self.diff_expense_account.id + } + ] + } + ] + + self.env["account.reconciliation.widget"].process_move_lines(data_for_reconciliation) + + self.assertTrue(inv1_receivable.full_reconcile_id.exists()) + self.assertEquals(inv1_receivable.full_reconcile_id, inv2_receivable.full_reconcile_id) + self.assertEquals(inv1_receivable.full_reconcile_id, pay_receivable.full_reconcile_id) + + self.assertTrue(all(l.reconciled for l in inv1_receivable)) + self.assertTrue(all(l.reconciled for l in inv2_receivable)) + + self.assertEquals(inv1.invoice_payment_state, 'paid') + self.assertEquals(inv2.invoice_payment_state, 'paid') + def test_multiple_term_reconciliation_opw_1906665(self): '''Test that when registering a payment to an invoice with multiple payment term lines the reconciliation happens against the line diff --git a/addons/account/tests/test_reconciliation_widget.py b/addons/account/tests/test_reconciliation_widget.py index a81b3471d9b..99769bc2f2e 100644 --- a/addons/account/tests/test_reconciliation_widget.py +++ b/addons/account/tests/test_reconciliation_widget.py @@ -53,3 +53,82 @@ class TestReconciliationWidget(TestReconciliation): result = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids) self.assertEqual(result['lines'][0]['reconciliation_proposition'][0]['amount_str'], '$ 50.00') + + def test_filter_partner1(self): + inv1 = self.create_invoice(currency_id=self.currency_euro_id) + inv2 = self.create_invoice(currency_id=self.currency_euro_id) + + receivable1 = inv1.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + receivable2 = inv2.line_ids.filtered(lambda l: l.account_id.internal_type == 'receivable') + + bank_stmt = self.acc_bank_stmt_model.create({ + 'company_id': self.env.ref('base.main_company').id, + 'journal_id': self.bank_journal_euro.id, + 'date': time.strftime('%Y-07-15'), + 'name': 'test', + }) + + bank_stmt_line = self.acc_bank_stmt_line_model.create({ + 'name': 'testLine', + 'statement_id': bank_stmt.id, + 'amount': 100, + 'date': time.strftime('%Y-07-15'), + }) + + # This is like opening the widget, and type "deco" in the filter + mv_lines_rec = self.env['account.reconciliation.widget'].get_move_lines_for_bank_statement_line( + bank_stmt_line.id, + partner_id=False, + excluded_ids=[], + search_str="deco", + mode="rp", + ) + mv_lines_ids = [l['id'] for l in mv_lines_rec] + + self.assertIn(receivable1.id, mv_lines_ids) + self.assertIn(receivable2.id, mv_lines_ids) + + # This is like clicking on the first receivable + partner = inv1.partner_id + mv_lines_rec = self.env['account.reconciliation.widget'].get_move_lines_for_bank_statement_line( + bank_stmt_line.id, + partner_id=partner.id, + excluded_ids=[receivable1.id], + search_str="deco", + mode="rp", + ) + mv_lines_ids = [l['id'] for l in mv_lines_rec] + + self.assertNotIn(receivable1.id, mv_lines_ids) + self.assertIn(receivable2.id, mv_lines_ids) + + def test_partner_name_with_parent(self): + parent_partner = self.env['res.partner'].create({ + 'name': 'test', + }) + child_partner = self.env['res.partner'].create({ + 'name': 'test', + 'parent_id': parent_partner.id, + 'type': 'delivery', + }) + self.create_invoice_partner(currency_id=self.currency_euro_id, partner_id=child_partner.id) + + bank_stmt = self.acc_bank_stmt_model.create({ + 'company_id': self.env.ref('base.main_company').id, + 'journal_id': self.bank_journal_euro.id, + 'date': time.strftime('%Y-07-15'), + 'name': 'test', + }) + + bank_stmt_line = self.acc_bank_stmt_line_model.create({ + 'name': 'testLine', + 'statement_id': bank_stmt.id, + 'amount': 100, + 'date': time.strftime('%Y-07-15'), + 'partner_name': 'test', + }) + + bkstmt_data = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids) + + self.assertEqual(len(bkstmt_data['lines']), 1) + self.assertEqual(bkstmt_data['lines'][0]['partner_id'], parent_partner.id) diff --git a/addons/account/tests/test_tax.py b/addons/account/tests/test_tax.py index 903699dc4d9..48119a58cba 100644 --- a/addons/account/tests/test_tax.py +++ b/addons/account/tests/test_tax.py @@ -348,12 +348,32 @@ class TestTax(AccountTestUsers): 'price_include': True, 'include_base_amount': True, 'sequence': 1, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_2 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_2', 'amount_type': 'percent', 'amount': 10, 'sequence': 2, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_3 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_3', @@ -361,12 +381,32 @@ class TestTax(AccountTestUsers): 'amount': 10, 'price_include': True, 'sequence': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_4 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_4', 'amount_type': 'percent', 'amount': 10, 'sequence': 4, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) tax_5 = self.env['account.tax'].create({ 'name': 'test_advanced_taxes_computation_0_5', @@ -374,21 +414,229 @@ class TestTax(AccountTestUsers): 'amount': 10, 'price_include': True, 'sequence': 5, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], }) taxes = tax_1 + tax_2 + tax_3 + tax_4 + tax_5 - res = taxes.compute_all(132.0) + + # Test with positive amount. self._check_compute_all_results( 154, # 'total_included' 100, # 'total_excluded' [ # base , amount | seq | amount | incl | incl_base # --------------------------------------------------- - (100.0, 10.0), # | 1 | 10% | t | t - (110.0, 11.0), # | 2 | 10% | | - (110.0, 11.0), # | 3 | 10% | t | - (110.0, 11.0), # | 4 | 10% | | - (110.0, 11.0), # | 5 | 10% | t | + (100.0, 5.0), # | 1 | 10% | t | t + (100.0, 5.0), # | 1 | 10% | t | t + (110.0, 5.5), # | 2 | 10% | | + (110.0, 5.5), # | 2 | 10% | | + (110.0, 5.5), # | 3 | 10% | t | + (110.0, 5.5), # | 3 | 10% | t | + (110.0, 5.5), # | 4 | 10% | | + (110.0, 5.5), # | 4 | 10% | | + (110.0, 5.5), # | 5 | 10% | t | + (110.0, 5.5), # | 5 | 10% | t | # --------------------------------------------------- ], - res + taxes.compute_all(132.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -154, # 'total_included' + -100, # 'total_excluded' + [ + # base , amount | seq | amount | incl | incl_base + # --------------------------------------------------- + (-100.0, -5.0), # | 1 | 10% | t | t + (-100.0, -5.0), # | 1 | 10% | t | t + (-110.0, -5.5), # | 2 | 10% | | + (-110.0, -5.5), # | 2 | 10% | | + (-110.0, -5.5), # | 3 | 10% | t | + (-110.0, -5.5), # | 3 | 10% | t | + (-110.0, -5.5), # | 4 | 10% | | + (-110.0, -5.5), # | 4 | 10% | | + (-110.0, -5.5), # | 5 | 10% | t | + (-110.0, -5.5), # | 5 | 10% | t | + # --------------------------------------------------- + ], + taxes.compute_all(-132.0) + ) + + def test_intracomm_taxes_computation_0(self): + ''' Test usage of intracomm taxes having e.g.+100%, -100% as repartition lines. ''' + intracomm_tax = self.env['account.tax'].create({ + 'name': 'test_intracomm_taxes_computation_0_1', + 'amount_type': 'percent', + 'amount': 21, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -100.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 100, # 'total_included' + 100, # 'total_excluded' + [ + # base , amount + # --------------- + (100.0, 21.0), + (100.0, -21.0), + # --------------- + ], + intracomm_tax.compute_all(100.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -100, # 'total_included' + -100, # 'total_excluded' + [ + # base , amount + # --------------- + (-100.0, -21.0), + (-100.0, 21.0), + # --------------- + ], + intracomm_tax.compute_all(-100.0) + ) + + def test_rounding_issues_0(self): + ''' Test taxes having a complex setup of repartition lines. ''' + tax = self.env['account.tax'].create({ + 'name': 'test_rounding_issues_0', + 'amount_type': 'percent', + 'amount': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 1.09, # 'total_included' + 1, # 'total_excluded' + [ + # base , amount + # --------------- + (1.0, 0.01), + (1.0, 0.01), + (1.0, 0.01), + (1.0, 0.02), + (1.0, 0.02), + (1.0, 0.02), + # --------------- + ], + tax.compute_all(1.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -1.09, # 'total_included' + -1, # 'total_excluded' + [ + # base , amount + # --------------- + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, -0.02), + (-1.0, -0.02), + (-1.0, -0.02), + # --------------- + ], + tax.compute_all(-1.0) + ) + + def test_rounding_issues_1(self): + ''' Test taxes having a complex setup of repartition lines. ''' + tax = self.env['account.tax'].create({ + 'name': 'test_advanced_taxes_repartition_lines_computation_1', + 'amount_type': 'percent', + 'amount': 3, + 'invoice_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'repartition_type': 'base', 'factor_percent': 100.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -50.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': 25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + (0, 0, {'repartition_type': 'tax', 'factor_percent': -25.0}), + ], + }) + + # Test with positive amount. + self._check_compute_all_results( + 1, # 'total_included' + 1, # 'total_excluded' + [ + # base , amount + # --------------- + (1.0, 0.02), + (1.0, -0.02), + (1.0, 0.01), + (1.0, 0.01), + (1.0, -0.01), + (1.0, -0.01), + # --------------- + ], + tax.compute_all(1.0) + ) + + # Test with negative amount. + self._check_compute_all_results( + -1, # 'total_included' + -1, # 'total_excluded' + [ + # base , amount + # --------------- + (-1.0, -0.02), + (-1.0, 0.02), + (-1.0, -0.01), + (-1.0, -0.01), + (-1.0, 0.01), + (-1.0, 0.01), + # --------------- + ], + tax.compute_all(-1.0) ) diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index bd0cbd95093..f5443190945 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -61,6 +61,9 @@ <field name="currency_id" invisible="1"/> <field name="amount_currency"/> </group> + <group string="Product" attrs="{'invisible': [('product_id', '=', False)]}"> + <field name="product_id"/> + </group> <group string="States"> <field name="blocked"/> </group> @@ -611,7 +614,7 @@ <label for="invoice_date" string="Bill Date" style="font-weight:bold;" attrs="{'invisible': [('type', 'not in', ('in_invoice', 'in_refund', 'in_receipt'))]}"/> </div> - <field name="invoice_date" nolabel="1" + <field name="invoice_date" nolabel="1" options="{'datepicker': {'warn_future': true}}" attrs="{'invisible': [('type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund', 'out_receipt', 'in_receipt'))]}"/> <field name="date" string="Accounting Date" @@ -880,6 +883,8 @@ attrs="{'invisible': [('type', '!=', 'entry')], 'readonly': [('reversed_entry_id', '!=', False)]}"/> <field name="reversed_entry_id" attrs="{'invisible': [('type', '!=', 'entry')]}"/> + <field name="to_check" + attrs="{'invisible': [('type', '!=', 'entry')]}" /> </group> <group> <field name="company_id" groups="base.group_multi_company" required="1"/> diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml index b1fc237d77c..4269adb3fa9 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -182,7 +182,7 @@ <p t-if="o.fiscal_position_id.note" name="note"> <span t-field="o.fiscal_position_id.note"/> </p> - <div id="qrcode" t-if="(o.partner_id.country_id.code in ['BE', 'AT', 'DE', 'FI', 'NL']) and (o.company_id.qr_code) and (o.currency_id.name == 'EUR') and (o.invoice_partner_bank_id.acc_number != False)"> + <div id="qrcode" t-if="(o.company_id.qr_code) and (o.currency_id.name == 'EUR') and (o.invoice_partner_bank_id.acc_number != False)"> <p t-if="(o.invoice_partner_bank_id.qr_code_valid)"> <strong class="text-center">Scan me with your banking app.</strong><br /><br /> <img class="border border-dark rounded" t-att-src="o.invoice_partner_bank_id.build_qr_code_url(o.amount_residual,(o.ref) if (o.ref) else o.name)"/> diff --git a/addons/account_cancel/views/account_views.xml b/addons/account_cancel/views/account_views.xml index 7d8591f1146..2a6162a64f9 100644 --- a/addons/account_cancel/views/account_views.xml +++ b/addons/account_cancel/views/account_views.xml @@ -5,6 +5,9 @@ <field name="model">account.journal</field> <field name="inherit_id" ref="account.view_account_journal_form"/> <field name="arch" type="xml"> + <xpath expr="//field[@name='loss_account_id']/parent::group" position="attributes"> + <attribute name="attrs">{'invisible': 0}</attribute> + </xpath> <field name="loss_account_id" position="after"> <field name="update_posted" groups="base.group_no_one"/> </field> diff --git a/addons/account_check_printing/models/account_payment.py b/addons/account_check_printing/models/account_payment.py index fdf0ecdf726..52bd7dc6149 100644 --- a/addons/account_check_printing/models/account_payment.py +++ b/addons/account_check_printing/models/account_payment.py @@ -99,6 +99,11 @@ class AccountPayment(models.Model): """ This method is a hook for l10n_xx_check_printing modules to implement actual check printing capabilities """ raise UserError(_("You have to choose a check layout. For this, go in Apps, search for 'Checks layout' and install one.")) + + def set_check_amount_in_words(self): + for payment in self: + payment.check_amount_in_words = payment.currency_id.amount_to_text(payment.amount) if payment.currency_id else '' + ####################### #CHECK PRINTING METHODS ####################### diff --git a/addons/account_check_printing/views/account_payment_views.xml b/addons/account_check_printing/views/account_payment_views.xml index df880212f36..91f27101fd2 100644 --- a/addons/account_check_printing/views/account_payment_views.xml +++ b/addons/account_check_printing/views/account_payment_views.xml @@ -8,6 +8,7 @@ <xpath expr="//button[@name='post']" position="before"> <button name="print_checks" class="oe_highlight" attrs="{'invisible': ['|', ('payment_method_code', '!=', 'check_printing'), ('state', '!=', 'posted')]}" string="Print Check" type="object"/> <button name="unmark_sent" attrs="{'invisible': ['|', ('payment_method_code', '!=', 'check_printing'), ('state', '!=', 'sent')]}" string="Unmark Sent" type="object"/> + <button name="set_check_amount_in_words" attrs="{'invisible': ['|', ('payment_method_code', '!=', 'check_printing'), ('check_amount_in_words', '!=', False)]}" string="Reset Amount in Words" type="object" groups="base.group_no_one"/> </xpath> <xpath expr="//div[@name='amount_div']" position="after"> <field name="check_amount_in_words" attrs="{'invisible': [('payment_method_code', '!=', 'check_printing')], 'readonly': [('state', '!=', 'draft')]}" groups="base.group_no_one"/> diff --git a/addons/account_tax_python/models/account_tax.py b/addons/account_tax_python/models/account_tax.py index 1870ea6b55e..dbd143a58b5 100644 --- a/addons/account_tax_python/models/account_tax.py +++ b/addons/account_tax_python/models/account_tax.py @@ -28,6 +28,8 @@ class AccountTaxPython(models.Model): def _compute_amount(self, base_amount, price_unit, quantity=1.0, product=None, partner=None): self.ensure_one() + if product and product._name == 'product.template': + product = product.product_variant_id if self.amount_type == 'code': company = self.env.company localdict = {'base_amount': base_amount, 'price_unit':price_unit, 'quantity': quantity, 'product':product, 'partner':partner, 'company': company} @@ -38,6 +40,8 @@ class AccountTaxPython(models.Model): def compute_all(self, price_unit, currency=None, quantity=1.0, product=None, partner=None, is_refund=False, handle_price_include=True): taxes = self.filtered(lambda r: r.amount_type != 'code') company = self.env.company + if product and product._name == 'product.template': + product = product.product_variant_id for tax in self.filtered(lambda r: r.amount_type == 'code'): localdict = self._context.get('tax_computation_context', {}) localdict.update({'price_unit': price_unit, 'quantity': quantity, 'product': product, 'partner': partner, 'company': company}) diff --git a/addons/auth_oauth/controllers/main.py b/addons/auth_oauth/controllers/main.py index 3b300070929..00eea3268b6 100644 --- a/addons/auth_oauth/controllers/main.py +++ b/addons/auth_oauth/controllers/main.py @@ -109,19 +109,10 @@ class OAuthLogin(Home): return response - @http.route() - def web_auth_signup(self, *args, **kw): - providers = self.list_providers() - response = super(OAuthLogin, self).web_auth_signup(*args, **kw) - response.qcontext.update(providers=providers) - return response - - @http.route() - def web_auth_reset_password(self, *args, **kw): - providers = self.list_providers() - response = super(OAuthLogin, self).web_auth_reset_password(*args, **kw) - response.qcontext.update(providers=providers) - return response + def get_auth_signup_qcontext(self): + result = super(OAuthLogin, self).get_auth_signup_qcontext() + result["providers"] = self.list_providers() + return result class OAuthController(http.Controller): diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py index c9ee67f2c7b..8540d916a5e 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -31,6 +31,7 @@ class Partner(models.Model): replacement_xml = """ <div> <field name="country_enforce_cities" invisible="1"/> + <field name="parent_id" invisible="1"/> <field name='city' placeholder="%(placeholder)s" class="o_address_city" attrs="{ 'invisible': [('country_enforce_cities', '=', True), ('city_id', '!=', False)], diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py index cb4bacbe3ce..9fc3feff1bd 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -445,7 +445,11 @@ class Import(models.TransientModel): 'date_format') else [] user_date_format = self.env['res.lang']._lang_get(self.env.user.lang).date_format if user_date_format: - date_patterns.append(user_date_format) + try: + to_re(user_date_format) + date_patterns.append(user_date_format) + except KeyError: + pass date_patterns.extend(DATE_PATTERNS) match = check_patterns(date_patterns, preview_values) if match: diff --git a/addons/bus/static/src/js/crosstab_bus.js b/addons/bus/static/src/js/crosstab_bus.js index 46643cad0de..9d0e413ab94 100644 --- a/addons/bus/static/src/js/crosstab_bus.js +++ b/addons/bus/static/src/js/crosstab_bus.js @@ -80,6 +80,12 @@ var CrossTabBus = Longpolling.extend({ this._callLocalStorage('setItem', 'channels', this._channels); }, /** + * @return {string} + */ + getTabId: function () { + return this._id; + }, + /** * Tells whether this bus is related to the master tab. * * @returns {boolean} diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py index 45125f8aab4..74073286b36 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -1051,11 +1051,13 @@ class Meeting(models.Model): meeting_attendees |= attendee meeting_partners |= partner - if meeting_attendees: + if meeting_attendees and not self._context.get('detaching'): to_notify = meeting_attendees.filtered(lambda a: a.email != current_user.email) to_notify._send_mail_to_attendees('calendar.calendar_template_meeting_invitation') + if meeting_attendees: meeting.write({'attendee_ids': [(4, meeting_attendee.id) for meeting_attendee in meeting_attendees]}) + if meeting_partners: meeting.message_subscribe(partner_ids=meeting_partners.ids) @@ -1397,7 +1399,7 @@ class Meeting(models.Model): # do not copy the id if data.get('id'): del data['id'] - return meeting_origin.copy(default=data) + return meeting_origin.with_context(detaching=True).copy(default=data) def action_detach_recurring_event(self): meeting = self.detach_recurring_event() diff --git a/addons/calendar/tests/test_calendar.py b/addons/calendar/tests/test_calendar.py index 11e38a09c1d..6b7d7f1a550 100644 --- a/addons/calendar/tests/test_calendar.py +++ b/addons/calendar/tests/test_calendar.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta, time from odoo import fields from odoo.tests.common import TransactionCase import pytz +import re class TestCalendar(TransactionCase): @@ -384,3 +385,60 @@ class TestCalendar(TransactionCase): }) self.assertEqual(str(activity_id.date_deadline), '2018-10-16') + + def test_event_creation_mail(self): + """ + Check that mail are sent to the attendees on event creation + Check that mail are sent to the added attendees on event edit + Check that mail are NOT sent to the attendees when detaching a recurring event + """ + + def _test_one_mail_per_attendee(self, m, partners): + # check that every attendee receive a (single) mail for the event + for partner in partners: + mail = self.env['mail.mail'].search([ + ('recipient_ids', 'in', partner.id), + ('subject', 'like', m.name), + ]) + self.assertEqual(len(mail), 1) + + partners = [ + self.env['res.partner'].create({'name':'testuser0','email': u'bob@example.com'}), + self.env['res.partner'].create({'name':'testuser1','email': u'alice@example.com'}), + ] + partner_ids = [(6, False, [p.id for p in partners]),] + now = fields.Datetime.now() + m = self.CalendarEvent.create({ + 'name': "mailTest1", + 'allday': False, + 'rrule': u'FREQ=DAILY;INTERVAL=1;COUNT=5', + 'duration': 0.5, + 'partner_ids': partner_ids, + 'start': fields.Datetime.to_string(now + timedelta(days=10)), + 'stop': fields.Datetime.to_string(now + timedelta(days=15)), + }) + + # every partner should have 1 mail sent + _test_one_mail_per_attendee(self, m, partners) + + # adding more partners to the event + partners.extend([ + self.env['res.partner'].create({'name':'testuser2','email': u'marc@example.com'}), + self.env['res.partner'].create({'name':'testuser3','email': u'carl@example.com'}), + self.env['res.partner'].create({'name':'testuser4','email': u'alain@example.com'}), + ]) + partner_ids = [(6, False, [p.id for p in partners]),] + m.write({'partner_ids': partner_ids}) + + # more email should be sent + _test_one_mail_per_attendee(self, m, partners) + + # calculate virtualid to detach one event + virtid = str(m.id) + '-' + ''.join(re.split('[\D]', fields.Datetime.to_string(now + timedelta(days=12)))) + + # detaching a virtual event in the chain + self.env['calendar.event'].browse(virtid).detach_recurring_event(values={'active':False}) + + # since the detach actually create an event in the backend + # we check that no mail notifications are sent to the attendees + _test_one_mail_per_attendee(self, m, partners) diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml index aae443953ee..b00fd2358c4 100644 --- a/addons/calendar/views/calendar_views.xml +++ b/addons/calendar/views/calendar_views.xml @@ -202,7 +202,7 @@ <page name="page_invitations" string="Invitations" groups="base.group_no_one"> <button name="action_sendmail" type="object" string="Send mail" icon="fa-envelope" class="oe_link"/> - <field name="attendee_ids" widget="one2many" > + <field name="attendee_ids" widget="one2many" mode="tree,kanban"> <tree string="Invitation details" editable="top" create="false" delete="false"> <field name="partner_id" /> <field name="state" /> @@ -212,6 +212,27 @@ <button name="do_accept" string="Accept" states="needsAction,tentative,declined" type="object" icon="fa-check text-success"/> <button name="do_decline" string="Decline" states="needsAction,tentative,accepted" type="object" icon="fa-times-circle text-danger"/> </tree> + <kanban class="o_kanban_mobile" create="false" delete="false"> + <field name="partner_id" /> + <field name="state" /> + <field name="email" widget="email"/> + + <templates> + <t t-name="kanban-box"> + <div class="d-flex flex-column justify-content-between"> + <field name="partner_id"/> + <field name="email" widget="email"/> + <span>Status: <field name="state" /></span> + + <div class="text-right"> + <button name="do_tentative" states="needsAction,declined,accepted" string="Uncertain" type="object" class="btn fa fa-asterisk"/> + <button name="do_accept" states="needsAction,tentative,declined" string="Accept" type="object" class="btn fa fa-check text-success"/> + <button name="do_decline" states="needsAction,tentative,accepted" string="Decline" type="object" class="btn fa fa-times-circle text-danger"/> + </div> + </div> + </t> + </templates> + </kanban> </field> </page> <page name="page_misc" string="Misc" groups="base.group_no_one"> diff --git a/addons/gamification/models/challenge.py b/addons/gamification/models/challenge.py index 08194e0d7ec..72103f89205 100644 --- a/addons/gamification/models/challenge.py +++ b/addons/gamification/models/challenge.py @@ -379,9 +379,9 @@ class Challenge(models.Model): # the goal is initialised over the limit to make sure we will compute it at least once if line.condition == 'higher': - values['current'] = line.target_goal - 1 + values['current'] = min(line.target_goal - 1, 0) else: - values['current'] = line.target_goal + 1 + values['current'] = max(line.target_goal + 1, 0) if challenge.remind_update_delay: values['remind_update_delay'] = challenge.remind_update_delay @@ -737,7 +737,7 @@ class Challenge(models.Model): all_reached = False if goal.definition_condition == 'higher': # can be over 100 - total_completeness += 100.0 * goal.current / goal.target_goal + total_completeness += (100.0 * goal.current / goal.target_goal) if goal.target_goal else 0 elif goal.state == 'reached': # for lower goals, can not get percentage so 0 or 100 total_completeness += 100 diff --git a/addons/gamification/models/goal.py b/addons/gamification/models/goal.py index 75db403647e..10452e3b850 100644 --- a/addons/gamification/models/goal.py +++ b/addons/gamification/models/goal.py @@ -195,7 +195,7 @@ class Goal(models.Model): if goal.current >= goal.target_goal: goal.completeness = 100.0 else: - goal.completeness = round(100.0 * goal.current / goal.target_goal, 2) + goal.completeness = round(100.0 * goal.current / goal.target_goal, 2) if goal.target_goal else 0 elif goal.current < goal.target_goal: # a goal 'lower than' has only two values possible: 0 or 100% goal.completeness = 100.0 diff --git a/addons/gamification/views/challenge.xml b/addons/gamification/views/challenge.xml index 745a9b5204c..48214d14774 100644 --- a/addons/gamification/views/challenge.xml +++ b/addons/gamification/views/challenge.xml @@ -15,7 +15,7 @@ <record id="goals_from_challenge_act" model="ir.actions.act_window"> <field name="res_model">gamification.goal</field> <field name="name">Related Goals</field> - <field name="view_mode">kanban,tree</field> + <field name="view_mode">kanban,tree,form</field> <field name="context">{'search_default_group_by_definition': True, 'search_default_inprogress': True, 'search_default_challenge_id': active_id, 'default_challenge_id': active_id}</field> <field name="help" type="html"> <p class="o_view_nocontent_empty_folder"> @@ -61,7 +61,7 @@ <label for="user_domain" class="oe_edit_only" string="Assign Challenge To"/> <div> <field name="user_domain" widget="domain" options="{'model': 'res.users'}" /> - <field name="user_ids" groups="base.group_no_one" widget="many2many_tags" readonly="True" /> + <field name="user_ids" widget="many2many_tags" /> </div> </div> @@ -171,7 +171,7 @@ <record id="challenge_list_action" model="ir.actions.act_window"> <field name="name">Challenges</field> <field name="res_model">gamification.challenge</field> - <field name="view_mode">kanban,tree,form</field> + <field name="view_mode">kanban,tree</field> <field name="context">{'search_default_inprogress':True, 'default_inprogress':True}</field> <field name="help" type="html"> <p class="o_view_nocontent_smiling_face"> diff --git a/addons/gamification/views/goal.xml b/addons/gamification/views/goal.xml index f571e28a4ec..26409c21306 100644 --- a/addons/gamification/views/goal.xml +++ b/addons/gamification/views/goal.xml @@ -135,6 +135,7 @@ <field name="completeness"/> <field name="state"/> <field name="target_goal"/> + <field name="definition_description"/> <field name="definition_condition"/> <field name="definition_suffix"/> <field name="definition_display"/> @@ -143,10 +144,12 @@ <field name="last_update"/> <templates> <t t-name="kanban-tooltip"> - <field name="definition_description"/> + <ul class="oe_kanban_tooltip"> + <li><t t-esc="record.definition_description.value" /></li> + </ul> </t> <t t-name="kanban-box"> - <div t-attf-class="oe_kanban_card o_kanban_gamification #{record.end_date.raw_value < record.last_update.raw_value & record.state.raw_value == 'failed' ? 'oe_kanban_color_2' : ''} #{record.end_date.raw_value < record.last_update.raw_value & record.state.raw_value == 'reached' ? 'oe_kanban_color_5' : ''}"> + <div t-attf-class="oe_kanban_card oe_kanban_global_click o_kanban_gamification #{record.end_date.raw_value < record.last_update.raw_value & record.state.raw_value == 'failed' ? 'oe_kanban_color_2' : ''} #{record.end_date.raw_value < record.last_update.raw_value & record.state.raw_value == 'reached' ? 'oe_kanban_color_5' : ''}"> <div class="o_kanban_content"> <p><strong><h4 class="oe_goal_name text-center" tooltip="kanban-tooltip"><field name="definition_id" /></h4></strong></p> <div class="float-left"> diff --git a/addons/hr_expense/models/hr_expense.py b/addons/hr_expense/models/hr_expense.py index 4f87c906429..d768e6227a9 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -33,10 +33,16 @@ class HrExpense(models.Model): if self.user_has_groups('hr_expense.group_hr_expense_user') or self.user_has_groups('account.group_account_user'): res = "['|', ('company_id', '=', False), ('company_id', '=', company_id)]" # Then, domain accepts everything elif self.user_has_groups('hr_expense.group_hr_expense_team_approver') and self.env.user.employee_ids: - employee = self.env.user.employee_ids[0] - res = ['|', '|', '|', ('department_id.manager_id.id', '=', employee.id), - ('parent_id.id', '=', employee.id), ('expense_manager_id.id', '=', employee.user_id.id), ('id', '=', employee.id), - '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id)] + user = self.env.user + employee = user.employee_ids[0] + res = [ + '|', '|', '|', + ('department_id.manager_id', '=', employee.id), + ('parent_id', '=', employee.id), + ('id', '=', employee.id), + ('expense_manager_id', '=', user.id), + '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id), + ] elif self.env.user.employee_ids: employee = self.env.user.employee_ids[0] res = [('id', '=', employee.id), '|', ('company_id', '=', False), ('company_id', '=', employee.company_id.id)] diff --git a/addons/hr_expense/tests/test_expenses.py b/addons/hr_expense/tests/test_expenses.py index 8f93144d25d..c4958f41108 100644 --- a/addons/hr_expense/tests/test_expenses.py +++ b/addons/hr_expense/tests/test_expenses.py @@ -187,6 +187,72 @@ class TestAccountEntry(TestExpenseCommon): self.assertEquals(expense.total_amount, 9876.0) self.assertTrue(expense.employee_id in user_demo.employee_ids) + def test_partial_payment_multiexpense(self): + bank_journal = self.env['account.journal'].create({ + 'name': 'Payment Journal', + 'code': 'PAY', + 'type': 'bank', + 'company_id': self.env.company.id, + }) + + outbound_pay_method = self.env['account.payment.method'].create({ + 'name': 'outbound', + 'code': 'out', + 'payment_type': 'outbound', + }) + + expense = self.env['hr.expense.sheet'].create({ + 'name': 'Expense for John Smith', + 'employee_id': self.employee.id, + }) + expense_line = self.env['hr.expense'].create({ + 'name': 'Car Travel Expenses', + 'employee_id': self.employee.id, + 'product_id': self.product_expense.id, + 'unit_amount': 200.00, + 'tax_ids': [(6, 0, [self.tax.id])], + 'sheet_id': expense.id, + 'analytic_account_id': self.analytic_account.id, + }) + expense_line.copy({ + 'sheet_id': expense.id + }) + expense.approve_expense_sheets() + expense.action_sheet_move_create() + + exp_move_lines = expense.account_move_id.line_ids + payable_move_lines = exp_move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEquals(len(payable_move_lines), 2) + + WizardRegister = self.env["hr.expense.sheet.register.payment.wizard"].with_context( + active_model=expense._name, active_id=expense.id, active_ids=expense.ids + ) + + register_pay1 = WizardRegister.create({ + 'journal_id': bank_journal.id, + 'payment_method_id': outbound_pay_method.id, + 'amount': 300, + }) + register_pay1.expense_post_payment() + + exp_move_lines = expense.account_move_id.line_ids + payable_move_lines = exp_move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEquals(len(payable_move_lines.filtered(lambda l: l.reconciled)), 1) + + register_pay2 = WizardRegister.create({ + 'journal_id': bank_journal.id, + 'payment_method_id': outbound_pay_method.id, + 'amount': 100, + }) + register_pay2.expense_post_payment() + exp_move_lines = expense.account_move_id.line_ids + payable_move_lines = exp_move_lines.filtered(lambda l: l.account_id.internal_type == 'payable') + self.assertEquals(len(payable_move_lines.filtered(lambda l: l.reconciled)), 2) + + full_reconcile = payable_move_lines.mapped('full_reconcile_id') + self.assertEquals(len(full_reconcile), 1) + + class TestExpenseRights(TestExpenseCommon): @classmethod diff --git a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py index 6eb28a2f7f5..be9583b423c 100644 --- a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py +++ b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py @@ -119,7 +119,7 @@ class HrExpenseSheetRegisterPaymentWizard(models.TransientModel): # Reconcile the payment and the expense, i.e. lookup on the payable account move lines account_move_lines_to_reconcile = self.env['account.move.line'] for line in payment.move_line_ids + expense_sheet.account_move_id.line_ids: - if line.account_id.internal_type == 'payable': + if line.account_id.internal_type == 'payable' and not line.reconciled: account_move_lines_to_reconcile |= line account_move_lines_to_reconcile.reconcile() diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 633b8370733..963fee9bd51 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -6,6 +6,8 @@ import logging import math +from collections import namedtuple + from datetime import datetime, time from pytz import timezone, UTC @@ -18,6 +20,9 @@ from odoo.tools.translate import _ _logger = logging.getLogger(__name__) +# Used to agglomerate the attendances in order to find the hour_from and hour_to +# See _onchange_request_parameters +DummyAttendance = namedtuple('DummyAttendance', 'hour_from, hour_to, dayofweek, day_period') class HolidaysRequest(models.Model): """ Leave Requests Access specifications @@ -279,7 +284,12 @@ class HolidaysRequest(models.Model): resource_calendar_id = self.employee_id.resource_calendar_id or self.env.company.resource_calendar_id domain = [('calendar_id', '=', resource_calendar_id.id), ('display_type', '=', False)] - attendances = self.env['resource.calendar.attendance'].search(domain, order='dayofweek, day_period DESC') + attendances = self.env['resource.calendar.attendance'].read_group(domain, ['ids:array_agg(id)', 'hour_from:min(hour_from)', 'hour_to:max(hour_to)', 'dayofweek', 'day_period'], ['dayofweek', 'day_period'], lazy=False) + + # Must be sorted by dayofweek ASC and day_period DESC + attendances = sorted([DummyAttendance(group['hour_from'], group['hour_to'], group['dayofweek'], group['day_period']) for group in attendances], key=lambda att: (att.dayofweek, att.day_period != 'morning')) + + default_value = DummyAttendance(0, 0, 0, 'morning') if resource_calendar_id.two_weeks_calendar: # find week type of start_date @@ -306,9 +316,9 @@ class HolidaysRequest(models.Model): attendance_to = attendance_filtred_reversed[0] else: # find first attendance coming after first_day - attendance_from = next((att for att in attendances if int(att.dayofweek) >= self.request_date_from.weekday()), attendances[0]) + attendance_from = next((att for att in attendances if int(att.dayofweek) >= self.request_date_from.weekday()), attendances[0] if attendances else default_value) # find last attendance coming before last_day - attendance_to = next((att for att in reversed(attendances) if int(att.dayofweek) <= self.request_date_to.weekday()), attendances[-1]) + attendance_to = next((att for att in reversed(attendances) if int(att.dayofweek) <= self.request_date_to.weekday()), attendances[-1] if attendances else default_value) if self.request_unit_half: if self.request_date_from_period == 'am': diff --git a/addons/hr_holidays/tests/__init__.py b/addons/hr_holidays/tests/__init__.py index 67c8327e999..3c15cd779a1 100644 --- a/addons/hr_holidays/tests/__init__.py +++ b/addons/hr_holidays/tests/__init__.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_access_rights +from . import test_automatic_leave_dates from . import test_holidays_flow from . import test_hr_leave_type from . import test_accrual_allocations diff --git a/addons/hr_holidays/tests/test_automatic_leave_dates.py b/addons/hr_holidays/tests/test_automatic_leave_dates.py new file mode 100644 index 00000000000..5b39259d053 --- /dev/null +++ b/addons/hr_holidays/tests/test_automatic_leave_dates.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- +from datetime import date, datetime + +from odoo.tests.common import Form, tagged + +from odoo.addons.hr_holidays.tests.common import TestHrHolidaysBase + + +@tagged('prout') +class TestAutomaticLeaveDates(TestHrHolidaysBase): + def setUp(self): + super(TestAutomaticLeaveDates, self).setUp() + + self.leave_type = self.env['hr.leave.type'].create({ + 'name': 'Automatic Test', + 'time_type': 'leave', + 'allocation_type': 'no', + 'validity_start': False, + }) + + def test_no_attendances(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'No Attendances', + 'attendance_ids': [(5, 0, 0)], + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + self.assertEqual(leave_form.number_of_days_display, 0) + self.assertEqual(leave_form.number_of_hours_display, 0) + + def test_single_attendance_on_morning_and_afternoon(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'simple morning + afternoon', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'monday morning', + 'hour_from': 8, + 'hour_to': 12, + 'day_period': 'morning', + 'dayofweek': '0', + }), + (0, 0, { + 'name': 'monday afternoon', + 'hour_from': 13, + 'hour_to': 17, + 'day_period': 'afternoon', + 'dayofweek': '0', + })] + }) + + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + self.assertEqual(leave_form.number_of_days_display, .5) + self.assertEqual(leave_form.number_of_hours_display, 4) + + leave_form.request_date_from_period = 'pm' + + self.assertEqual(leave_form.number_of_days_display, .5) + self.assertEqual(leave_form.number_of_hours_display, 4) + + def test_multiple_attendance_on_morning(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'multi morning', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'monday morning 1', + 'hour_from': 8, + 'hour_to': 10, + 'day_period': 'morning', + 'dayofweek': '0', + }), + (0, 0, { + 'name': 'monday morning 2', + 'hour_from': 10.25, + 'hour_to': 12.25, + 'day_period': 'morning', + 'dayofweek': '0', + }), + (0, 0, { + 'name': 'monday afternoon', + 'hour_from': 13, + 'hour_to': 17, + 'day_period': 'afternoon', + 'dayofweek': '0', + })] + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + self.assertEqual(leave_form.number_of_days_display, .5) + self.assertEqual(leave_form.number_of_hours_display, 4) + + leave_form.request_date_from_period = 'pm' + + self.assertEqual(leave_form.number_of_days_display, .5) + self.assertEqual(leave_form.number_of_hours_display, 4) + + def test_attendance_on_morning(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'Morning only', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'Monday All day', + 'hour_from': 8, + 'hour_to': 16, + 'day_period': 'morning', + 'dayofweek': '0', + })], + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + # Ask for morning + leave_form.request_date_from_period = 'am' + + self.assertEqual(leave_form.number_of_days_display, 1) + self.assertEqual(leave_form.number_of_hours_display, 8) + + # Ask for afternoon + leave_form.request_date_from_period = 'pm' + + self.assertEqual(leave_form.number_of_days_display, 1) + self.assertEqual(leave_form.number_of_hours_display, 8) + + def test_attendance_next_day(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'auto next day', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'tuesday morning', + 'hour_from': 8, + 'hour_to': 12, + 'day_period': 'morning', + 'dayofweek': '1', + })] + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 2) + leave_form.request_date_to = date(2019, 9, 2) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + + self.assertEqual(leave_form.number_of_days_display, 0) + self.assertEqual(leave_form.number_of_hours_display, 0) + self.assertEqual(leave_form.date_from, datetime(2019, 9, 2, 6, 0, 0)) + self.assertEqual(leave_form.date_to, datetime(2019, 9, 2, 10, 0, 0)) + + def test_attendance_previous_day(self): + calendar = self.env['resource.calendar'].create({ + 'name': 'auto next day', + 'attendance_ids': [(5, 0, 0), + (0, 0, { + 'name': 'monday morning', + 'hour_from': 8, + 'hour_to': 12, + 'day_period': 'morning', + 'dayofweek': '0', + })] + }) + employee = self.employee_emp + employee.resource_calendar_id = calendar + + with Form(self.env['hr.leave']) as leave_form: + leave_form.employee_id = employee + leave_form.holiday_status_id = self.leave_type + leave_form.request_date_from = date(2019, 9, 3) + leave_form.request_date_to = date(2019, 9, 3) + leave_form.request_unit_half = True + leave_form.request_date_from_period = 'am' + + + self.assertEqual(leave_form.number_of_days_display, 0) + self.assertEqual(leave_form.number_of_hours_display, 0) + self.assertEqual(leave_form.date_from, datetime(2019, 9, 3, 6, 0, 0)) + self.assertEqual(leave_form.date_to, datetime(2019, 9, 3, 10, 0, 0)) diff --git a/addons/hr_recruitment/views/hr_recruitment_views.xml b/addons/hr_recruitment/views/hr_recruitment_views.xml index 3b65a7eb8b0..5634abf2aac 100644 --- a/addons/hr_recruitment/views/hr_recruitment_views.xml +++ b/addons/hr_recruitment/views/hr_recruitment_views.xml @@ -275,6 +275,7 @@ <field name="user_email"/> <field name="partner_name"/> <field name="type_id"/> + <field name="partner_id"/> <field name="job_id"/> <field name="department_id"/> <field name="message_needaction_counter"/> diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py index 6803f637e93..ff12b4c2c4a 100644 --- a/addons/im_livechat/controllers/main.py +++ b/addons/im_livechat/controllers/main.py @@ -103,7 +103,6 @@ class LivechatController(http.Controller): @http.route('/im_livechat/feedback', type='json', auth='public', cors="*") def feedback(self, uuid, rate, reason=None, **kwargs): Channel = request.env['mail.channel'] - Rating = request.env['rating.rating'] channel = Channel.sudo().search([('uuid', '=', uuid)], limit=1) if channel: # limit the creation : only ONE rating per session @@ -124,7 +123,11 @@ class LivechatController(http.Controller): # if logged in user, set its partner on rating values['partner_id'] = request.env.user.partner_id.id if request.session.uid else False # create the rating - rating = Rating.sudo().create(values) + + channel.write({ + 'rating_ids': [(0, False, values)] + }) + rating = channel.rating_ids[0] else: rating = channel.rating_ids[0] rating.write(values) diff --git a/addons/im_livechat/static/src/js/im_livechat.js b/addons/im_livechat/static/src/js/im_livechat.js index 596357ef3d4..5a893f2499a 100644 --- a/addons/im_livechat/static/src/js/im_livechat.js +++ b/addons/im_livechat/static/src/js/im_livechat.js @@ -123,6 +123,14 @@ var LivechatButton = Widget.extend({ serverURL: this._serverURL, }); var message = new WebsiteLivechatMessage(this, data, options); + + var hasAlreadyMessage = _.some(this._messages, function (msg) { + return message.id === msg.id; + }); + if (hasAlreadyMessage) { + return; + } + if (this._livechat) { this._livechat.addMessage(message); } diff --git a/addons/l10n_ae/data/account_tax_template_data.xml b/addons/l10n_ae/data/account_tax_template_data.xml index b3cec92c1c5..2993be745de 100644 --- a/addons/l10n_ae/data/account_tax_template_data.xml +++ b/addons/l10n_ae/data/account_tax_template_data.xml @@ -1,11 +1,11 @@ <?xml version="1.0" encoding="utf-8"?> <odoo> <record id="uae_sale_tax_5_dubai" model="account.tax.template"> - <field name="name">TVA 5% (Dubai)</field> + <field name="name">VAT 5% (Dubai)</field> <field name="type_tax_use">sale</field> <field name="amount">5</field> <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> + <field name="description">VAT 5%</field> <field name="tax_group_id" ref="ae_tax_group_5"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -36,11 +36,11 @@ ]"/> </record> <record id="uae_sale_tax_5_abu_dhabi" model="account.tax.template"> - <field name="name">TVA 5% (Abu Dhabi)</field> + <field name="name">VAT 5% (Abu Dhabi)</field> <field name="type_tax_use">sale</field> <field name="amount">5</field> <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> + <field name="description">VAT 5%</field> <field name="tax_group_id" ref="ae_tax_group_5"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -71,11 +71,11 @@ ]"/> </record> <record id="uae_sale_tax_5_sharjah" model="account.tax.template"> - <field name="name">TVA 5% (Sharjah)</field> + <field name="name">VAT 5% (Sharjah)</field> <field name="type_tax_use">sale</field> <field name="amount">5</field> <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> + <field name="description">VAT 5%</field> <field name="tax_group_id" ref="ae_tax_group_5"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -106,11 +106,11 @@ ]"/> </record> <record id="uae_sale_tax_5_ajman" model="account.tax.template"> - <field name="name">TVA 5% (Ajman)</field> + <field name="name">VAT 5% (Ajman)</field> <field name="type_tax_use">sale</field> <field name="amount">5</field> <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> + <field name="description">VAT 5%</field> <field name="tax_group_id" ref="ae_tax_group_5"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -141,11 +141,11 @@ ]"/> </record> <record id="uae_sale_tax_5_umm_al_quwain" model="account.tax.template"> - <field name="name">TVA 5% (Umm Al Quwain)</field> + <field name="name">VAT 5% (Umm Al Quwain)</field> <field name="type_tax_use">sale</field> <field name="amount">5</field> <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> + <field name="description">VAT 5%</field> <field name="tax_group_id" ref="ae_tax_group_5"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -176,11 +176,11 @@ ]"/> </record> <record id="uae_sale_tax_5_ras_al_khaima" model="account.tax.template"> - <field name="name">TVA 5% (Ras Al-Khaima)</field> + <field name="name">VAT 5% (Ras Al-Khaima)</field> <field name="type_tax_use">sale</field> <field name="amount">5</field> <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> + <field name="description">VAT 5%</field> <field name="tax_group_id" ref="ae_tax_group_5"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -211,11 +211,11 @@ ]"/> </record> <record id="uae_sale_tax_5_fujairah" model="account.tax.template"> - <field name="name">TVA 5% (Fujairah)</field> + <field name="name">VAT 5% (Fujairah)</field> <field name="type_tax_use">sale</field> <field name="amount">5</field> <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> + <field name="description">VAT 5%</field> <field name="tax_group_id" ref="ae_tax_group_5"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -277,11 +277,11 @@ ]"/> </record> <record id="uae_sale_tax_0" model="account.tax.template"> - <field name="name">TVA 0%</field> + <field name="name">VAT 0%</field> <field name="type_tax_use">sale</field> <field name="amount">0</field> <field name="amount_type">percent</field> - <field name="description">TVA 0%</field> + <field name="description">VAT 0%</field> <field name="tax_group_id" ref="ae_tax_group_0"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -693,11 +693,11 @@ </record> <!-- purchase taxes --> <record id="uae_purchase_tax_5" model="account.tax.template"> - <field name="name">TVA 5%</field> + <field name="name">VAT 5%</field> <field name="type_tax_use">purchase</field> <field name="amount">5</field> <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> + <field name="description">VAT 5%</field> <field name="tax_group_id" ref="ae_tax_group_5"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -757,11 +757,11 @@ ]"/> </record> <record id="uae_purchase_tax_0" model="account.tax.template"> - <field name="name">TVA 0%</field> + <field name="name">VAT 0%</field> <field name="type_tax_use">purchase</field> <field name="amount">0</field> <field name="amount_type">percent</field> - <field name="description">TVA 0%</field> + <field name="description">VAT 0%</field> <field name="tax_group_id" ref="ae_tax_group_0"/> <field name="chart_template_id" ref="uae_chart_template_standard"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -817,28 +817,6 @@ }), ]"/> </record> - <record id="uae_purchase_tax_5_child_global" model="account.tax.template"> - <field name="name">TVA 5%</field> - <field name="type_tax_use">none</field> - <field name="amount">5</field> - <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> - <field name="account_id" ref="uae_account_3726"/> - <field name="refund_account_id" ref="uae_account_3726"/> - <field name="tax_group_id" ref="ae_tax_group_5"/> - <field name="chart_template_id" ref="uae_chart_template_standard"/> - </record> - <record id="uae_purchase_tax_5_child_reverse" model="account.tax.template"> - <field name="name">Reverse Charge TVA +5%(Purchase)</field> - <field name="type_tax_use">none</field> - <field name="amount">5</field> - <field name="amount_type">percent</field> - <field name="description">TVA 5%</field> - <field name="account_id" ref="uae_account_3726"/> - <field name="refund_account_id" ref="uae_account_3726"/> - <field name="tax_group_id" ref="ae_tax_group_5"/> - <field name="chart_template_id" ref="uae_chart_template_standard"/> - </record> <record id="uae_purchase_tax_reverse_charge" model="account.tax.template"> <field name="name">Reverse Charge Provision</field> <field name="type_tax_use">purchase</field> diff --git a/addons/l10n_ch/models/account_invoice.py b/addons/l10n_ch/models/account_invoice.py index 194ec3869e7..583f5580f12 100644 --- a/addons/l10n_ch/models/account_invoice.py +++ b/addons/l10n_ch/models/account_invoice.py @@ -189,3 +189,17 @@ class AccountMove(models.Model): if self.env.context.get('l10n_ch_mark_isr_as_sent'): self.filtered(lambda inv: not inv.l10n_ch_isr_sent).write({'l10n_ch_isr_sent': True}) return super(AccountMove, self.with_context(mail_post_autofollow=True)).message_post(**kwargs) + + def _get_reference_ch_invoice(self): + """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as + `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard + """ + self.ensure_one() + return self.l10n_ch_isr_number_spaced + + def _get_reference_ch_partner(self): + """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as + `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard + """ + self.ensure_one() + return self.l10n_ch_isr_number_spaced diff --git a/addons/l10n_ch/models/account_journal.py b/addons/l10n_ch/models/account_journal.py index a42e163a299..85646dfa396 100644 --- a/addons/l10n_ch/models/account_journal.py +++ b/addons/l10n_ch/models/account_journal.py @@ -14,6 +14,7 @@ class AccountJournal(models.Model): # creation of bank journals by giving the account number, allow craetion of the l10n_ch_postal = fields.Char(related='bank_account_id.l10n_ch_postal', readonly=False) + invoice_reference_model = fields.Selection(selection_add=[('ch', 'Switzerland')]) @api.model def create(self, vals): diff --git a/addons/l10n_es/data/account_tax_data.xml b/addons/l10n_es/data/account_tax_data.xml index dca71560db1..9461c865a72 100644 --- a/addons/l10n_es/data/account_tax_data.xml +++ b/addons/l10n_es/data/account_tax_data.xml @@ -445,7 +445,6 @@ <field name="description">P_IVA21_SP_IN</field> <field name="type_tax_use">purchase</field> <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_10_11'), ref('mod_303_14_15_purchase'), ref('mod_303_36_37'), ref('mod_303_40_41')])]"/> <field name="amount_type">percent</field> <field name="amount" eval="21"/> <field name="tax_group_id" ref="tax_group_iva_21"/> @@ -551,7 +550,6 @@ <field name="type_tax_use">purchase</field> <field name="amount_type">percent</field> <field name="amount" eval="21"/> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_38_39'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/> <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> <field name="tax_group_id" ref="tax_group_iva_21"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -718,7 +716,6 @@ <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> <field name="amount_type">percent</field> <field name="type_tax_use">purchase</field> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_12_13'), ref('mod_303_14_15_purchase'), ref('mod_303_28_29'), ref('mod_303_40_41')])]"/> <field name="name">IVA 4% Adquisición de servicios extracomunitarios</field> <field name="tax_group_id" ref="tax_group_iva_4"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -770,7 +767,6 @@ <field name="description">P_IVA10_SP_EX</field> <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> <field name="amount_type">percent</field> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_12_13'), ref('mod_303_14_15_purchase'), ref('mod_303_28_29'), ref('mod_303_40_41')])]"/> <field name="type_tax_use">purchase</field> <field name="name">IVA 10% Adquisición de servicios extracomunitarios</field> <field name="tax_group_id" ref="tax_group_iva_10"/> @@ -824,7 +820,6 @@ <field name="type_tax_use">purchase</field> <field name="amount" eval="21"/> <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_12_13'), ref('mod_303_14_15_purchase'), ref('mod_303_28_29'), ref('mod_303_40_41')])]"/> <field name="amount_type">percent</field> <field name="tax_group_id" ref="tax_group_iva_21"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), @@ -874,7 +869,6 @@ <field name="amount" eval="4"/> <field name="description">P_IVA4_IC_BC</field> <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_36_37'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/> <field name="amount_type">percent</field> <field name="type_tax_use">purchase</field> <field name="name">IVA 4% Adquisición Intracomunitario. Bienes corrientes</field> @@ -930,7 +924,6 @@ <field name="amount_type">percent</field> <field name="type_tax_use">purchase</field> <field name="name">IVA 4% Adquisición Intracomunitario. Bienes de inversión</field> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_38_39'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/> <field name="tax_group_id" ref="tax_group_iva_4"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -979,7 +972,6 @@ <field name="amount" eval="10"/> <field name="description">P_IVA10_IC_BC</field> <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_36_37'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/> <field name="amount_type">percent</field> <field name="type_tax_use">purchase</field> <field name="name">IVA 10% Adquisición Intracomunitario. Bienes corrientes</field> @@ -1034,7 +1026,6 @@ <field name="amount_type">percent</field> <field name="type_tax_use">purchase</field> <field name="name">IVA 10% Adquisición Intracomunitario. Bienes de inversión</field> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_38_39'), ref('mod_303_40_41'), ref('mod_303_10_11'), ref('mod_303_14_15_purchase')])]"/> <field name="tax_group_id" ref="tax_group_iva_10"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -3077,7 +3068,6 @@ <field name="amount_type">percent</field> <field name="type_tax_use">purchase</field> <field name="name">IVA 10% Adquisición de servicios intracomunitarios</field> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_10_11'), ref('mod_303_14_15_purchase'), ref('mod_303_36_37'), ref('mod_303_40_41')])]"/> <field name="tax_group_id" ref="tax_group_iva_10"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -3129,7 +3119,6 @@ <field name="amount_type">percent</field> <field name="type_tax_use">purchase</field> <field name="name">IVA 4% Adquisición de servicios intracomunitarios</field> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_10_11'), ref('mod_303_14_15_purchase'), ref('mod_303_36_37'), ref('mod_303_40_41')])]"/> <field name="tax_group_id" ref="tax_group_iva_4"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), (0,0, { @@ -3403,7 +3392,6 @@ <field name="description">P_IVA4_ISP</field> <field name="name">IVA 4% Compra con Inversión del Sujeto Pasivo Nacional</field> <field name="type_tax_use">purchase</field> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_28_29'), ref('mod_303_40_41'), ref('mod_303_12_13'), ref('mod_303_14_15_purchase')])]"/> <field name="amount_type">percent</field> <field name="amount" eval="4"/> <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> @@ -3455,7 +3443,6 @@ <field name="description">P_IVA10_ISP</field> <field name="name">IVA 10% Compra con Inversión del Sujeto Pasivo Nacional</field> <field name="type_tax_use">purchase</field> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_28_29'), ref('mod_303_40_41'), ref('mod_303_12_13'), ref('mod_303_14_15_purchase')])]"/> <field name="amount_type">percent</field> <field name="amount" eval="10"/> <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> @@ -3509,7 +3496,6 @@ <field name="type_tax_use">purchase</field> <field name="amount_type">percent</field> <field name="amount" eval="21"/> - <field name="tag_ids" eval="[(6, False, [ref('mod_303_28_29'), ref('mod_303_40_41'), ref('mod_303_12_13'), ref('mod_303_14_15_purchase')])]"/> <field name="chart_template_id" ref="l10n_es.account_chart_template_common"/> <field name="tax_group_id" ref="tax_group_iva_21"/> <field name="invoice_repartition_line_ids" eval="[(5, 0, 0), diff --git a/addons/l10n_in/views/report_invoice.xml b/addons/l10n_in/views/report_invoice.xml index 029e3bd726d..0871aed87d7 100644 --- a/addons/l10n_in/views/report_invoice.xml +++ b/addons/l10n_in/views/report_invoice.xml @@ -5,11 +5,11 @@ <xpath expr="//table[@name='invoice_line_table']" position="before"> <t t-if="o.company_id.country_id.code == 'IN'"> <!-- Tag wise Tax Calculation --> - <t t-set="tag_cgst_id" t-value="env.ref('l10n_in.cgst_tag_tax').id"/> - <t t-set="tag_sgst_id" t-value="env.ref('l10n_in.sgst_tag_tax').id"/> - <t t-set="tag_igst_id" t-value="env.ref('l10n_in.igst_tag_tax').id"/> - <t t-set="tag_cess_id" t-value="env.ref('l10n_in.cess_tag_tax').id"/> - <t t-set="tax_map" t-value="o.invoice_line_ids.mapped('tax_ids.tag_ids.id')"/> + <t t-set="tag_cgst_id" t-value="env.ref('l10n_in.tax_report_line_cgst').id"/> + <t t-set="tag_sgst_id" t-value="env.ref('l10n_in.tax_report_line_sgst').id"/> + <t t-set="tag_igst_id" t-value="env.ref('l10n_in.tax_report_line_igst').id"/> + <t t-set="tag_cess_id" t-value="env.ref('l10n_in.tax_report_line_cess').id"/> + <t t-set="tax_map" t-value="o.tax_line_ids.mapped('tag_ids.tax_report_line_ids').ids"/> <t t-set="is_igst" t-value="tag_igst_id in tax_map"/> <t t-set="is_cgst" t-value="tag_cgst_id in tax_map"/> <t t-set="is_cess" t-value="tag_cess_id in tax_map"/> @@ -43,21 +43,21 @@ <t t-if="o.company_id.country_id.code == 'IN'"> <td class="text-right" t-if="is_cgst"> <t t-foreach="tax_datas[line.id]" t-as="tax_data"> - <t t-if="tag_cgst_id in tax_data['tag_ids']"> + <t t-if="tag_cgst_id in tax_data['report_line_ids']"> <t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> </td> <td class="text-right" t-if="is_cgst"> <t t-foreach="tax_datas[line.id]" t-as="tax_data"> - <t t-if="tag_sgst_id in tax_data['tag_ids']"> + <t t-if="tag_sgst_id in tax_data['report_line_ids']"> <t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> </td> <td class="text-right" t-if="is_igst"> <t t-foreach="tax_datas[line.id]" t-as="tax_data"> - <t t-if="tag_igst_id in tax_data['tag_ids']"> + <t t-if="tag_igst_id in tax_data['report_line_ids']"> <t t-esc="tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> @@ -65,7 +65,7 @@ <td class="text-right" t-if="is_cess"> <t t-set="total_cess" t-value="0.0"/> <t t-foreach="tax_datas[line.id]" t-as="tax_data"> - <t t-if="tag_cess_id in tax_data['tag_ids']"> + <t t-if="tag_cess_id in tax_data['report_line_ids']"> <t t-set="total_cess" t-value="total_cess + tax_data['amount']" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/> </t> </t> diff --git a/addons/l10n_it_edi/models/ir_mail_server.py b/addons/l10n_it_edi/models/ir_mail_server.py index 7b030b32234..51e4a0a23a3 100644 --- a/addons/l10n_it_edi/models/ir_mail_server.py +++ b/addons/l10n_it_edi/models/ir_mail_server.py @@ -31,10 +31,10 @@ class FetchmailServer(models.Model): l10n_it_is_pec = fields.Boolean('PEC server', help="If PEC Server, only mail from '...@pec.fatturapa.it' will be processed.") l10n_it_last_uid = fields.Integer(string='Last message UID', default=1) - @api.constrains('l10n_it_is_pec', 'type') + @api.constrains('l10n_it_is_pec', 'server_type') def _check_pec(self): for record in self: - if record.l10n_it_is_pec and record.type != 'imap': + if record.l10n_it_is_pec and record.server_type != 'imap': raise ValidationError(_("PEC mail server must be of type IMAP.")) def fetch_mail(self): @@ -42,7 +42,7 @@ class FetchmailServer(models.Model): MailThread = self.env['mail.thread'] for server in self.filtered(lambda s: s.l10n_it_is_pec): - _logger.info('start checking for new emails on %s PEC server %s', server.type, server.name) + _logger.info('start checking for new emails on %s PEC server %s', server.server_type, server.name) count, failed = 0, 0 imap_server = None @@ -80,14 +80,14 @@ class FetchmailServer(models.Model): self._attachment_invoice(msg_txt) new_max_uid = max(new_max_uid, int(uid)) except Exception: - _logger.info('Failed to process mail from %s server %s.', server.type, server.name, exc_info=True) + _logger.info('Failed to process mail from %s server %s.', server.server_type, server.name, exc_info=True) failed += 1 self._cr.commit() count += 1 server.write({'l10n_it_last_uid': new_max_uid}) - _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.type, server.name, (count - failed), failed) + _logger.info("Fetched %d email(s) on %s server %s; %d succeeded, %d failed.", count, server.server_type, server.name, (count - failed), failed) except Exception: - _logger.info("General failure when trying to fetch mail from %s server %s.", server.type, server.name, exc_info=True) + _logger.info("General failure when trying to fetch mail from %s server %s.", server.server_type, server.name, exc_info=True) finally: if imap_server: imap_server.close() diff --git a/addons/l10n_lu/__init__.py b/addons/l10n_lu/__init__.py index 1947891c8f5..79508eb4f80 100644 --- a/addons/l10n_lu/__init__.py +++ b/addons/l10n_lu/__init__.py @@ -1,8 +1,15 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import api, SUPERUSER_ID + from . import models +def _post_init_hook(cr, registry): + _preserve_tag_on_taxes(cr, registry) + env = api.Environment(cr, SUPERUSER_ID, {}) + env.ref('l10n_lu.lu_2011_chart_1').process_coa_translations() + def _preserve_tag_on_taxes(cr, registry): from odoo.addons.account.models.chart_template import preserve_existing_tags_on_taxes preserve_existing_tags_on_taxes(cr, registry, 'l10n_lu') diff --git a/addons/l10n_lu/__manifest__.py b/addons/l10n_lu/__manifest__.py index c5457fd82d4..9eabf73f08d 100644 --- a/addons/l10n_lu/__manifest__.py +++ b/addons/l10n_lu/__manifest__.py @@ -43,5 +43,5 @@ Notes: 'data/account.chart.template.csv', 'data/account_chart_template_data.xml', ], - 'post_init_hook': '_preserve_tag_on_taxes', + 'post_init_hook': '_post_init_hook', } diff --git a/addons/l10n_ma/data/account_tax_data.xml b/addons/l10n_ma/data/account_tax_data.xml index 0842eeb1076..3309e6959f3 100644 --- a/addons/l10n_ma/data/account_tax_data.xml +++ b/addons/l10n_ma/data/account_tax_data.xml @@ -2,10 +2,10 @@ <odoo> <!-- account chart --> <record id="l10n_kzc_temp_chart" model="account.chart.template"> - <field name="property_account_receivable_id" ref="pcg_3488"/> - <field name="property_account_payable_id" ref="pcg_4488"/> + <field name="property_account_receivable_id" ref="pcg_34211"/> + <field name="property_account_payable_id" ref="pcg_4411"/> <field name="property_account_income_categ_id" ref="pcg_7111"/> - <field name="property_account_expense_categ_id" ref="pcg_1486"/> + <field name="property_account_expense_categ_id" ref="pcg_6111"/> <field name="income_currency_exchange_account_id" ref="pcg_733"/> <field name="expense_currency_exchange_account_id" ref="pcg_633"/> <field name="default_pos_receivable_account_id" ref="pcg_3489" /> diff --git a/addons/l10n_ma/data/l10n_ma_chart_data.xml b/addons/l10n_ma/data/l10n_ma_chart_data.xml index d15a3bc99c3..76c0d257799 100644 --- a/addons/l10n_ma/data/l10n_ma_chart_data.xml +++ b/addons/l10n_ma/data/l10n_ma_chart_data.xml @@ -1190,13 +1190,15 @@ <record id="pcg_34211" model="account.account.template"> <field name="name">Clients - catégorie A</field> <field name="code">34211</field> - <field name="user_type_id" ref="account.data_account_type_current_assets"/> + <field name="user_type_id" ref="account.data_account_type_receivable"/> + <field name="reconcile" eval='True'/> <field name="chart_template_id" ref="l10n_kzc_temp_chart"/> </record> <record id="pcg_34212" model="account.account.template"> <field name="name">Clients - catégorie B</field> <field name="code">34212</field> - <field name="user_type_id" ref="account.data_account_type_current_assets"/> + <field name="user_type_id" ref="account.data_account_type_receivable"/> + <field name="reconcile" eval='True'/> <field name="chart_template_id" ref="l10n_kzc_temp_chart"/> </record> <record id="pcg_3423" model="account.account.template"> @@ -1370,7 +1372,7 @@ <record id="pcg_3488" model="account.account.template"> <field name="name">Divers débiteurs</field> <field name="code">3488</field> - <field name="user_type_id" ref="account.data_account_type_receivable"/> + <field name="user_type_id" ref="account.data_account_type_current_assets"/> <field name="reconcile" eval='True'/> <field name="chart_template_id" ref="l10n_kzc_temp_chart"/> </record> @@ -1522,7 +1524,8 @@ <record id="pcg_4411" model="account.account.template"> <field name="name">Fournisseurs</field> <field name="code">4411</field> - <field name="user_type_id" ref="account.data_account_type_current_liabilities"/> + <field name="user_type_id" ref="account.data_account_type_payable"/> + <field name="reconcile" eval='True'/> <field name="chart_template_id" ref="l10n_kzc_temp_chart"/> </record> <record id="pcg_4413" model="account.account.template"> @@ -1768,7 +1771,7 @@ <record id="pcg_4488" model="account.account.template"> <field name="name">Divers créanciers</field> <field name="code">4488</field> - <field name="user_type_id" ref="account.data_account_type_payable"/> + <field name="user_type_id" ref="account.data_account_type_current_liabilities"/> <field name="chart_template_id" ref="l10n_kzc_temp_chart"/> <field name="reconcile" eval='True'/> </record> @@ -2072,6 +2075,12 @@ <field name="user_type_id" ref="account.data_account_type_expenses"/> <field name="chart_template_id" ref="l10n_kzc_temp_chart"/> </record> + <record id="pcg_61263" model="account.account.template"> + <field name="name">Achats des prestations de service</field> + <field name="code">61263</field> + <field name="user_type_id" ref="account.data_account_type_expenses"/> + <field name="chart_template_id" ref="l10n_kzc_temp_chart"/> + </record> <record id="pcg_6128" model="account.account.template"> <field name="name">Achats de matières et de fournitures des exercices antérieurs</field> <field name="code">6128</field> diff --git a/addons/l10n_multilang/models/l10n_multilang.py b/addons/l10n_multilang/models/l10n_multilang.py index 40433eaed92..3fbdd494a5e 100644 --- a/addons/l10n_multilang/models/l10n_multilang.py +++ b/addons/l10n_multilang/models/l10n_multilang.py @@ -12,6 +12,19 @@ _logger = logging.getLogger(__name__) class AccountChartTemplate(models.Model): _inherit = 'account.chart.template' + def load_for_current_company(self, sale_tax_rate, purchase_tax_rate): + res = super(AccountChartTemplate, self).load_for_current_company(sale_tax_rate, purchase_tax_rate) + # Copy chart of account translations when loading chart of account + for chart_template in self.filtered('spoken_languages'): + external_id = self.env['ir.model.data'].search([ + ('model', '=', 'account.chart.template'), + ('res_id', '=', chart_template.id), + ], order='id', limit=1) + module = external_id and self.env.ref('base.module_' + external_id.module) + if module and module.state == 'installed': + chart_template.process_coa_translations() + return res + def process_translations(self, langs, in_field, in_ids, out_ids): """ This method copies translations values of templates into new Accounts/Taxes/Journals for languages selected diff --git a/addons/l10n_uk/__manifest__.py b/addons/l10n_uk/__manifest__.py index d62be514bfe..e9c035fdcae 100644 --- a/addons/l10n_uk/__manifest__.py +++ b/addons/l10n_uk/__manifest__.py @@ -15,7 +15,7 @@ This is the latest UK Odoo localisation necessary to run Odoo accounting for UK - InfoLogic UK counties listing - a few other adaptations""", 'author': 'SmartMode LTD', - 'website': 'http://www.smartmode.co.uk', + 'website': 'https://www.odoo.com/page/accounting', 'depends': [ 'account', 'base_iban', diff --git a/addons/lunch/static/src/js/lunch_kanban_model.js b/addons/lunch/static/src/js/lunch_kanban_model.js index ca2e39e5767..7ab7b279e43 100644 --- a/addons/lunch/static/src/js/lunch_kanban_model.js +++ b/addons/lunch/static/src/js/lunch_kanban_model.js @@ -13,6 +13,7 @@ var LunchKanbanModel = KanbanModel.extend({ init: function () { this.locationId = false; this.userId = false; + this._promInitLocation = null; this._super.apply(this, arguments); }, @@ -27,15 +28,23 @@ var LunchKanbanModel = KanbanModel.extend({ getCurrentLocationId: function () { return this.locationId; }, + /** + * @return {Promise} resolved with the location domain + */ + getLocationDomain: function () { + var self = this; + return this._initUserLocation().then(function () { + return self._buildLocationDomainLeaf() ? [self._buildLocationDomainLeaf()]: []; + }); + }, load: function () { var self = this; var args = arguments; var _super = this._super; - return this._getUserLocation().then(function (locationId) { + return this._initUserLocation().then(function () { var params = args[0]; - self.locationId = locationId; - self._addOrUpdate(params.domain, ['is_available_at', 'in', [locationId]]); + self._addOrUpdate(params.domain, self._buildLocationDomainLeaf()); return _super.apply(self, args); }); @@ -43,7 +52,7 @@ var LunchKanbanModel = KanbanModel.extend({ reload: function (id, options) { var domain = options && options.domain || this.localData[id].domain; - this._addOrUpdate(domain, ['is_available_at', 'in', [this.locationId]]); + this._addOrUpdate(domain, this._buildLocationDomainLeaf()); options = _.extend(options, {domain: domain}); return this._super.apply(this, arguments); @@ -54,19 +63,35 @@ var LunchKanbanModel = KanbanModel.extend({ //-------------------------------------------------------------------------- _addOrUpdate: function (domain, subDomain) { - var key = subDomain[0]; - var index = _.findIndex(domain, function (val) { - return val[0] === key; - }); - - if (index < 0) { - domain.push(subDomain); - } else { - domain[index] = subDomain; + if (subDomain && subDomain.length) { + var key = subDomain[0]; + var index = _.findIndex(domain, function (val) { + return val[0] === key; + }); + + if (index < 0) { + domain.push(subDomain); + } else { + domain[index] = subDomain; + } + + return domain; } return domain; }, + /** + * Builds the domain leaf corresponding to the current user's location + * + * @private + * @return {Array} + */ + _buildLocationDomainLeaf: function () { + var locationId = this.getCurrentLocationId(); + if (locationId) { + return ['is_available_at', 'in', [locationId]]; + } + }, _getUserLocation: function () { return this._rpc({ route: '/lunch/user_location_get', @@ -76,18 +101,34 @@ var LunchKanbanModel = KanbanModel.extend({ }, }); }, + /** + * Gets the user location once. + * Can be triggered from anywhere + * Useful to inject the location domain in the search panel + * + * @private + * @return {Promise} + */ + _initUserLocation: function () { + var self = this; + if (!this._promInitLocation) { + this._promInitLocation = new Promise(function (resolve) { + self._getUserLocation().then(function (locationId) { + self.locationId = locationId; + resolve(); + }); + }); + } + return this._promInitLocation; + }, _updateLocation: function (locationId) { this.locationId = locationId; return Promise.resolve(); }, _updateUser: function (userId) { - var self = this; - this.userId = userId; - - return this._getUserLocation().then(function (locationId) { - self.locationId = locationId; - }); + this._promInitLocation = null; + return this._initUserLocation(); } }); diff --git a/addons/lunch/static/src/js/lunch_kanban_view.js b/addons/lunch/static/src/js/lunch_kanban_view.js index a0cd6d3f3e0..8dda159f006 100644 --- a/addons/lunch/static/src/js/lunch_kanban_view.js +++ b/addons/lunch/static/src/js/lunch_kanban_view.js @@ -18,6 +18,26 @@ var LunchKanbanView = KanbanView.extend({ Renderer: LunchKanbanRenderer, }), display_name: _lt('Lunch Kanban'), + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Injects an additional domain in the search panel + * + * @private + * @override + */ + _createSearchPanel: function (parent, params) { + var self = this; + var model = this.getModel(parent); + var _super = this._super.bind(this); + return model.getLocationDomain().then(function (domain) { + self.loadParams.domain = self.loadParams.domain.concat(domain); + return _super(parent, params); + }); + }, }); view_registry.add('lunch_kanban', LunchKanbanView); diff --git a/addons/lunch/static/tests/lunch_kanban_tests.js b/addons/lunch/static/tests/lunch_kanban_tests.js index 8437a4a8d43..80e7747cbdd 100644 --- a/addons/lunch/static/tests/lunch_kanban_tests.js +++ b/addons/lunch/static/tests/lunch_kanban_tests.js @@ -211,6 +211,56 @@ QUnit.module('LunchKanbanView', { kanban.destroy(); }); + QUnit.test('search panel domain location', async function (assert) { + assert.expect(10); + const locationId = this.data['lunch.location'].records[0].id; + const regularInfos = _.extend({}, this.regularInfos); + + const kanban = await createLunchKanbanView({ + View: LunchKanbanView, + model: 'product', + data: this.data, + arch: ` + <kanban> + <templates> + <t t-name="kanban-box"> + <div><field name="name"/></div> + </t> + </templates> + </kanban> + `, + mockRPC: function (route, args) { + assert.step(route); + + if (route.startsWith('/lunch')) { + return mockLunchRPC({ + infos: regularInfos, + userLocation: locationId, + }).apply(this, arguments); + } + if (args.method === 'search_panel_select_multi_range') { + assert.deepEqual(args.kwargs.search_domain, [["is_available_at", "in", [locationId]]], + 'The initial domain of the search panel must contain the user location'); + } + if (route === '/web/dataset/search_read') { + assert.deepEqual(args.domain, [["is_available_at", "in", [locationId]]], + 'The domain for fetching actual data should be correct'); + } + return this._super.apply(this, arguments); + } + }); + assert.verifySteps([ + '/lunch/user_location_get', + '/web/dataset/call_kw/product/search_panel_select_multi_range', + '/web/dataset/call_kw/product/search_panel_select_multi_range', + '/web/dataset/search_read', + '/lunch/infos', + '/web/dataset/call_kw/ir.model.data/xmlid_to_res_id', + ]) + + kanban.destroy(); + }); + QUnit.test('non-empty cart', async function (assert) { assert.expect(17); diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py index 9e194628211..a144112502b 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -143,7 +143,7 @@ class MailController(http.Controller): @http.route('/mail/read_followers', type='json', auth='user') def read_followers(self, follower_ids, res_model): followers = [] - is_editable = request.env.user.has_group('base.group_no_one') + is_editable = request.env['mail.followers'].user_has_groups('base.group_no_one') partner_id = request.env.user.partner_id follower_id = None follower_recs = request.env['mail.followers'].sudo().browse(follower_ids) diff --git a/addons/mail/static/src/js/models/threads/document_thread.js b/addons/mail/static/src/js/models/threads/document_thread.js index 0ef02e5f57a..cb860b70beb 100644 --- a/addons/mail/static/src/js/models/threads/document_thread.js +++ b/addons/mail/static/src/js/models/threads/document_thread.js @@ -62,9 +62,16 @@ var DocumentThread = Thread.extend({ * shared between tabs, and restored on F5. * * @override + * @param {Object} [options] + * @param {boolean} [options.skipCrossTabSync=false] if set, it should + * not notify other tabs from updating document thread state. This happens + * in case the `close` operation comes from local storage event. */ - close: function () { + close: function (options) { this._super.apply(this, arguments); + if (options && options.skipCrossTabSync) { + return; + } this.call('mail_service', 'updateDocumentThreadState', this._id, { name: this.getName(), windowState: 'closed', @@ -75,9 +82,16 @@ var DocumentThread = Thread.extend({ * shared between tabs, and restored on F5. * * @override + * @param {Object} [options] + * @param {boolean} [options.skipCrossTabSync=false] if set, it should + * not notify other tabs from updating document thread state. This happens + * in case the `detach` operation comes from local storage event. */ - detach: function () { + detach: function (options) { this._super.apply(this, arguments); + if (options && options.skipCrossTabSync) { + return; + } var windowState = this._folded ? 'folded' : 'open'; this.call('mail_service', 'updateDocumentThreadState', this._id, { name: this.getName(), @@ -105,9 +119,16 @@ var DocumentThread = Thread.extend({ * shared between tabs, and restored on F5. * * @override + * @param {Object} [options] + * @param {boolean} [options.skipCrossTabSync=false] if set, it should + * not notify other tabs from updating document thread state. This happens + * in case the `detach` operation comes from local storage event. */ - fold: function () { + fold: function (folded, options) { this._super.apply(this, arguments); + if (options && options.skipCrossTabSync) { + return; + } var windowState = this._folded ? 'folded' : 'open'; this.call('mail_service', 'updateDocumentThreadState', this._id, { name: this.getName(), diff --git a/addons/mail/static/src/js/models/threads/searchable_thread.js b/addons/mail/static/src/js/models/threads/searchable_thread.js index 74018af1bc0..c2a48a2d410 100644 --- a/addons/mail/static/src/js/models/threads/searchable_thread.js +++ b/addons/mail/static/src/js/models/threads/searchable_thread.js @@ -149,7 +149,11 @@ var SearchableThread = Thread.extend({ if (cache.messages[index] !== message) { cache.messages.splice(index, 0, message); } - if (!message.isMyselfAuthor() && options.incrementUnread) { + if ( + !message.isMyselfAuthor() && + options.incrementUnread && + message.getType() !== 'notification' + ) { this._incrementUnreadCounter(); } }, diff --git a/addons/mail/static/src/js/services/mail_document_thread_manager.js b/addons/mail/static/src/js/services/mail_document_thread_manager.js index acf06461a04..6bb66b1b439 100644 --- a/addons/mail/static/src/js/services/mail_document_thread_manager.js +++ b/addons/mail/static/src/js/services/mail_document_thread_manager.js @@ -21,17 +21,23 @@ MailManager.include({ start: function () { this._super.apply(this, arguments); - + this._documentThreadWindowStates = {}; + this._tabId = this.call('bus_service', 'getTabId'); // retrieve the open DocumentThreads from the localStorage - var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY); - if (!state) { - this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, {}); - } else { - this.isReady().then(this._updateDocumentThreadWindows.bind(this, state)); + var localStorageLength = this.call('local_storage', 'length'); + for (var i = 0; i < localStorageLength; i++) { + var key = this.call('local_storage', 'key', i); + if (key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) { + // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId + var documentThreadId = key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1); + this._documentThreadWindowStates[documentThreadId] = + this.call('local_storage', 'getItem', key); + } } + this.isReady().then(this._updateDocumentThreadWindows.bind(this, this._documentThreadWindowStates)); // listen to localStorage changes to synchronize DocumentThread's // windows between tabs - window.addEventListener('storage', this._onStorage.bind(this)); + this.call('local_storage', 'onStorage', this, this._onStorage); }, //-------------------------------------------------------------------------- @@ -60,7 +66,10 @@ MailManager.include({ options.postedFromDocumentThread ) { var key = this.DOCUMENT_THREAD_MESSAGE_KEY; - this.call('local_storage', 'setItem', key, data); + this.call('local_storage', 'setItem', key, { + messageData: data, + tabId: this._tabId, + }); } return this._super.apply(this, arguments); }, @@ -144,12 +153,11 @@ MailManager.include({ * @param {string} state.windowState ('closed', 'folded' or 'open') */ updateDocumentThreadState: function (threadID, state) { - var states = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY); - states = _.omit(states, function (state) { - return state.windowState === 'closed'; + this._documentThreadWindowStates[threadID] = state; + this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY + '/' + threadID, { + state: state, + tabId: this._tabId, }); - states[threadID] = state; - this.call('local_storage', 'setItem', this.DOCUMENT_THREAD_STATE_KEY, states); }, //-------------------------------------------------------------------------- @@ -194,10 +202,17 @@ MailManager.include({ resModel: info[0], }); if (state.windowState === 'closed') { - documentThread.close(); + documentThread.close({ + skipCrossTabSync: true, + }); } else { - documentThread.fold(state.windowState === 'folded'); - self.openThreadWindow(documentThread.getID(), { keepFoldState: true }); + documentThread.fold(state.windowState === 'folded', { + skipCrossTabSync: true, + }); + self.openThreadWindow(documentThread.getID(), { + keepFoldState: true, + skipCrossTabSync: true, + }); } }); }, @@ -213,13 +228,27 @@ MailManager.include({ * @param {StorageEvent} ev */ _onStorage: function (ev) { - if (ev.key === this.DOCUMENT_THREAD_STATE_KEY) { - var state = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_STATE_KEY); - this._updateDocumentThreadWindows(state); + var value; + try { + value = JSON.parse(ev.newValue); + } catch (err) { + return; + } + if (ev.key.indexOf(this.DOCUMENT_THREAD_STATE_KEY) === 0) { + if (value.tabId === this._tabId) { + return; + } + // key format: DOCUMENT_THREAD_STATE_KEY + '/' + threadId + var documentThreadId = ev.key.substring(this.DOCUMENT_THREAD_STATE_KEY.length+1); + var param = {}; + param[documentThreadId] = value.state; + this._updateDocumentThreadWindows(param); } else if (ev.key === this.DOCUMENT_THREAD_MESSAGE_KEY) { - var message = this.call('local_storage', 'getItem', this.DOCUMENT_THREAD_MESSAGE_KEY); - if (message) { - this.addMessage(message); + if (value.tabId === this._tabId) { + return; + } + if (value.messageData) { + this.addMessage(value.messageData); } } }, 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 a832b0b0793..d8174da5a73 100644 --- a/addons/mail/static/src/js/services/mail_window_manager.js +++ b/addons/mail/static/src/js/services/mail_window_manager.js @@ -94,6 +94,8 @@ MailManager.include({ * is not open yet, and do nothing otherwise. * @param {boolean} [options.keepFoldState=false] if set to true, keep the * fold state of the thread + * @param {boolean} [options.skipCrossTabSync=false] if set, thread + * should not notify other tabs from new document thread chat window state. * @returns {Promise} */ openThreadWindow: function (threadID, options) { @@ -132,7 +134,9 @@ MailManager.include({ // 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(); + thread.close({ + skipCrossTabSync: true, + }); thread.isCreatingWindow = false; }); } 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 16d5cd31c86..08671354a3e 100644 --- a/addons/mail/static/src/js/thread_windows/thread_window.js +++ b/addons/mail/static/src/js/thread_windows/thread_window.js @@ -3,7 +3,9 @@ odoo.define('mail.ThreadWindow', function (require) { var AbstractThreadWindow = require('mail.AbstractThreadWindow'); var BasicComposer = require('mail.composer.Basic'); +var ExtendedComposer = require('mail.composer.Extended'); +var config = require('web.config'); var core = require('web.core'); var QWeb = core.qweb; @@ -65,19 +67,27 @@ var ThreadWindow = AbstractThreadWindow.extend({ if (!this.hasThread()) { this._startWithoutThread(); } else if (this.needsComposer()) { - var basicComposer = new BasicComposer(this, { + var composer; + var composerParams = { mentionPartnersRestricted: this._thread.getType() !== 'document_thread', - isMini: true, thread: this._thread, - }); - basicComposer.on('post_message', this, this._postMessage); - basicComposer.once('input_focused', this, function () { + }; + if ( + config.device.isMobile && + this._thread.isMassMailing() + ) { + composer = new ExtendedComposer(this, composerParams); + } else { + composer = new BasicComposer(this, _.extend({ isMini: true }, composerParams)); + } + composer.on('post_message', this, this._postMessage); + composer.once('input_focused', this, function () { var commands = this._thread.getCommands(); var partners = this._thread.getMentionPartnerSuggestions(); - basicComposer.mentionSetCommands(commands); - basicComposer.mentionSetPrefetchedPartners(partners); + composer.mentionSetCommands(commands); + composer.mentionSetPrefetchedPartners(partners); }); - composerDef = basicComposer.replace(this.$('.o_thread_composer')); + composerDef = composer.replace(this.$('.o_thread_composer')); composerDef.then(function () { self.$input = self.$('.o_composer_text_field'); }); @@ -130,24 +140,6 @@ var ThreadWindow = AbstractThreadWindow.extend({ return this._passive; }, /** - * States whether the input of the thread window should be displayed or not. - * This is based on the type of the thread: - * - * Do not display the input in the following cases: - * - * - no thread related to this window - * - window of a mailbox (temp: let us have mailboxes in window mode) - * - window of a thread with mass mailing - * - * Any other threads show the input in the window. - * - * @override - * @returns {boolean} - */ - needsComposer: function () { - return this._super() && !this._thread.isMassMailing(); - }, - /** * Turn the thread window in active mode, so that when the bottom of the * thread is visible, it is automatically marked as read. */ diff --git a/addons/mail/static/src/js/utils.js b/addons/mail/static/src/js/utils.js index 14ac97def76..25e327b0428 100644 --- a/addons/mail/static/src/js/utils.js +++ b/addons/mail/static/src/js/utils.js @@ -8,7 +8,12 @@ var _t = core._t; function parseAndTransform(htmlString, transformFunction) { var openToken = "OPEN" + Date.now(); var string = htmlString.replace(/</g, openToken); - var children = $('<div>').html(string).contents(); + var children; + try { + children = $('<div>').html(string).contents(); + } catch (e) { + children = $('<div>').html('<pre>' + string + '</pre>').contents(); + } return _parseAndTransform(children, transformFunction) .replace(new RegExp(openToken, "g"), "<"); } diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index 90793c91e95..5a9bad8e2dc 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -251,6 +251,9 @@ .o_composer_send .o_composer_button_send, .o_chatter_avatar { display: none !important; } + .o_attachment { + width: 50%; + } } .o_file_drop_zone_container { @@ -292,6 +295,10 @@ flex-flow: row wrap; } + .o_attachment_wrap { + padding-right: 0px; + } + .o_attachments_previews { > .o_attachment { width: 50%; diff --git a/addons/mail/static/src/scss/thread.scss b/addons/mail/static/src/scss/thread.scss index 5654020c783..3931c06004a 100644 --- a/addons/mail/static/src/scss/thread.scss +++ b/addons/mail/static/src/scss/thread.scss @@ -183,6 +183,7 @@ } img { max-width: 100%; + height: auto; } .o_mail_body_long { diff --git a/addons/mail/static/tests/document_thread_window_tests.js b/addons/mail/static/tests/document_thread_window_tests.js index 86d06ba36c4..0748168a0cb 100644 --- a/addons/mail/static/tests/document_thread_window_tests.js +++ b/addons/mail/static/tests/document_thread_window_tests.js @@ -201,7 +201,7 @@ QUnit.test('post messages in a document thread window', async function (assert) testUtils.mock.intercept(messagingMenu, 'call_service', function (ev) { if (ev.data.service === 'local_storage' && ev.data.method === 'setItem' && ev.data.args[0] === 'mail.document_threads_last_message') { - assert.deepEqual(ev.data.args[1], newMessage, + assert.deepEqual(ev.data.args[1].messageData, newMessage, "should write sent message in local storage, to share info with other tabs"); } }, true); @@ -227,7 +227,7 @@ QUnit.test('post messages in a document thread window', async function (assert) }); QUnit.test('open, fold, unfold and close a document thread window', async function (assert) { - assert.expect(8); + assert.expect(24); var messagingMenu = new MessagingMenu(); testUtils.mock.addMockEnvironment(messagingMenu, { services: this.services, @@ -237,7 +237,13 @@ QUnit.test('open, fold, unfold and close a document thread window', async functi await testUtils.nextTick(); testUtils.mock.intercept(messagingMenu, 'call_service', function (ev) { if (ev.data.service === 'local_storage' && ev.data.method === 'setItem') { - assert.step(JSON.stringify(ev.data.args)); + const key = ev.data.args[0]; + const state = ev.data.args[1].state; + assert.strictEqual(key, 'mail.document_threads_state/some.res.model_1'); + assert.ok(state); + assert.ok(state.name); + assert.ok(state.windowState); + assert.step(`${key}: { name: "${state.name}", windowState: '${state.windowState}' }`); } }, true); await messagingMenu.appendTo($('#qunit-fixture')); @@ -263,10 +269,10 @@ QUnit.test('open, fold, unfold and close a document thread window', async functi await testUtils.nextTick(); assert.verifySteps([ - '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"open"}}]', - '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"folded"}}]', - '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"open"}}]', - '["mail.document_threads_state",{"some.res.model_1":{"name":"Some Record","windowState":"closed"}}]', + `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'open' }`, + `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'folded' }`, + `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'open' }`, + `mail.document_threads_state/some.res.model_1: { name: "Some Record", windowState: 'closed' }`, ]); messagingMenu.destroy(); diff --git a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js index 610e689ae4a..047d723a425 100644 --- a/addons/mail/static/tests/systray/systray_messaging_menu_tests.js +++ b/addons/mail/static/tests/systray/systray_messaging_menu_tests.js @@ -427,6 +427,88 @@ QUnit.test('update messaging preview on receiving a new message in channel previ messagingMenu.destroy(); }); +QUnit.test('new message of type "notification" are not considered as unread messages', async function (assert) { + assert.expect(8); + + const messagingMenu = new MessagingMenu(); + testUtils.mock.addMockEnvironment(messagingMenu, { + data: this.data, + services: this.services, + }); + await messagingMenu.appendTo($('#qunit-fixture')); + // open messaging menu + await testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + assert.containsOnce( + messagingMenu, + '.o_mail_preview', + "should display a single channel preview"); + assert.strictEqual( + messagingMenu + .$('.o_preview_name') + .text() + .trim(), + "general", + "should display channel preview of 'general' channel"); + assert.strictEqual( + messagingMenu + .$('.o_preview_counter') + .text() + .trim(), + "", + "should have unread counter of 0 for 'general' channel (no unread messages)"); + assert.strictEqual( + messagingMenu + .$('.o_last_message_preview') + .text() + .replace(/\s/g, ""), + "Me:test", + "should display author name and inline body of currently last message in the channel"); + // close messaging menu + await testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + + // simulate receiving a new message on the channel 'general' (id 1) + // of type 'notification' + const data = { + id: 100, + author_id: [42, "Someone"], + body: "<p>Left #general</p>", + channel_ids: [1], + message_type: 'notification', + }; + const notification = [[false, 'mail.channel', 1], data]; + messagingMenu.call('bus_service', 'trigger', 'notification', [notification]); + await testUtils.nextTick(); + // open messaging menu + await testUtils.dom.click(messagingMenu.$('.dropdown-toggle')); + assert.containsOnce( + messagingMenu, + '.o_mail_preview', + "should still display a single channel preview"); + assert.strictEqual( + messagingMenu + .$('.o_preview_name') + .text() + .trim(), + "general", + "should still display channel preview of 'general' channel"); + assert.strictEqual( + messagingMenu + .$('.o_preview_counter') + .text() + .trim(), + "", + "should still have unread counter of 0 for 'general' channel (no unread messages)"); + assert.strictEqual( + messagingMenu + .$('.o_last_message_preview') + .text() + .replace(/\s/g, ""), + "Someone:Left#general", + "should display author name and inline body of notification message (last message of channel)"); + + messagingMenu.destroy(); +}); + QUnit.test('preview of inbox message not linked to document + mark as read', async function (assert) { assert.expect(17); diff --git a/addons/mail/views/mail_activity_views.xml b/addons/mail/views/mail_activity_views.xml index fdbfa3a5bdd..1d9d9f5fcc5 100644 --- a/addons/mail/views/mail_activity_views.xml +++ b/addons/mail/views/mail_activity_views.xml @@ -15,21 +15,13 @@ <group name="activity_details"> <field name="active" invisible="1"/> <field name="category"/> + <field name="default_user_id" widget="selection"/> <field name="res_model_id" groups="base.group_no_one"/> <field name="res_model_change" invisible="1"/> <field name="initial_res_model_id" invisible="1"/> <field name="summary"/> <field name="icon" groups="base.group_no_one"/> <field name="decoration_type" groups="base.group_no_one"/> - <label for="delay_count"/> - <div> - <div class="o_row"> - <field name="delay_count"/> - <field name="delay_unit"/> - </div> - <field name="delay_from"/> - </div> - <field name="default_user_id" widget="selection"/> </group> <group name="activity_planning"> <field name="force_next"/> @@ -43,6 +35,14 @@ </group> <group> <field name="default_description"/> + <label for="delay_count"/> + <div> + <div class="o_row"> + <field name="delay_count"/> + <field name="delay_unit"/> + </div> + <field name="delay_from"/> + </div> </group> </group> <p class="alert alert-info" role="alert" attrs="{'invisible': [('res_model_change', '=', False)]}">Modifying the model can have an impact on existing activities using this activity type, be careful.</p> diff --git a/addons/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml index 923b32cc4d8..45254bcbacd 100644 --- a/addons/maintenance/views/maintenance_views.xml +++ b/addons/maintenance/views/maintenance_views.xml @@ -128,8 +128,8 @@ <templates> <t t-name="kanban-tooltip"> <ul class="oe_kanban_tooltip"> - <li><b>Category:</b> <field name="category_id"/></li> - <li><b>Request to:</b> <field name="user_id"/></li> + <li t-if="record.category_id.raw_value"><b>Category:</b> <t t-esc="record.category_id.value"/></li> + <li t-if="record.user_id.raw_value"><b>Request to:</b> <t t-esc="record.user_id.value"/></li> </ul> </t> <t t-name="kanban-box"> @@ -430,8 +430,8 @@ <templates> <t t-name="kanban-tooltip"> <ul class="oe_kanban_tooltip"> - <li><b>Serial Number:</b> <field name="serial_no"/></li> - <li><b>Model Number:</b> <field name="model"/></li> + <li t-if="record.serial_no.raw_value"><b>Serial Number:</b> <t t-esc="record.serial_no.value"/></li> + <li t-if="record.model.raw_value"><b>Model Number:</b> <t t-esc="record.model.value"/></li> </ul> </t> <t t-name="kanban-box"> diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 3ad0a565770..d72caa41b24 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -369,6 +369,7 @@ class MrpProduction(models.Model): order.post_visible = order.is_locked and any((x.quantity_done > 0 and x.state not in ['done', 'cancel']) for x in order.move_raw_ids | order.move_finished_ids) else: order.post_visible = order.is_locked and any((x.quantity_done > 0 and x.state not in ['done', 'cancel']) for x in order.move_finished_ids) + order.post_visible &= all(wo.state in ['done', 'cancel'] for wo in order.workorder_ids) or all(m.product_id.tracking == 'none' for m in order.move_raw_ids) @api.depends('workorder_ids.state', 'move_finished_ids', 'move_finished_ids.quantity_done', 'is_locked') def _get_produced_qty(self): @@ -669,6 +670,8 @@ class MrpProduction(models.Model): def open_produce_product(self): self.ensure_one() + if self.bom_id.type == 'phantom': + raise UserError(_('You cannot produce a MO with a bom kit product.')) action = self.env.ref('mrp.act_mrp_product_produce').read()[0] return action diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index bd60fcc530f..479fad8a5fc 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -234,6 +234,11 @@ class StockMove(models.Model): res = super(StockMove, self)._should_be_assigned() return bool(res and not (self.production_id or self.raw_material_production_id)) + def _key_assign_picking(self): + keys = super(StockMove, self)._key_assign_picking() + return keys + (self.created_production_id,) + + def _compute_kit_quantities(self, product_id, kit_qty, kit_bom, filters): """ Computes the quantity delivered or received when a kit is sold or purchased. A ratio 'qty_processed/qty_needed' is computed for each component, and the lowest one is kept @@ -267,4 +272,4 @@ class StockMove(models.Model): # doesn't make sense. return min(qty_ratios) // 1 else: - return 0.0 + return 0.0
\ No newline at end of file diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml index 1ea13889aa4..24fd3307adf 100644 --- a/addons/payment/views/payment_portal_templates.xml +++ b/addons/payment/views/payment_portal_templates.xml @@ -130,7 +130,7 @@ <div t-if="payment_tx_id.acquirer_id.provider == 'transfer' and reference"> <b>Communication: </b><span t-esc='reference'/> </div> - <div t-if="(payment_tx_id.partner_id.country_id.code in ['BE', 'AT', 'DE', 'FI', 'NL']) and (payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.currency_id.name == 'EUR')"> + <div t-if="(payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.currency_id.name == 'EUR')"> <div t-if="payment_tx_id.acquirer_id.journal_id.bank_account_id.qr_code_valid"> <h3>Or scan me with your banking app.</h3> <img class="border border-dark rounded" t-att-src="payment_tx_id.acquirer_id.journal_id.bank_account_id.build_qr_code_url(payment_tx_id.amount,payment_tx_id.reference)"/> diff --git a/addons/payment_sips/models/payment.py b/addons/payment_sips/models/payment.py index 7d37cf93c36..3f2087e5940 100644 --- a/addons/payment_sips/models/payment.py +++ b/addons/payment_sips/models/payment.py @@ -44,9 +44,9 @@ class AcquirerSips(models.Model): provider = fields.Selection(selection_add=[('sips', 'Sips')]) sips_merchant_id = fields.Char('Merchant ID', help="Used for production only", required_if_provider='sips', groups='base.group_user') sips_secret = fields.Char('Secret Key', size=64, required_if_provider='sips', groups='base.group_user') - sips_test_url = fields.Char("Test url", required_if_provider='sips', groups='base.group_no_one', default='https://payment-webinit.sips-atos.com/paymentInit') - sips_prod_url = fields.Char("Production url", required_if_provider='sips', groups='base.group_no_one', default='https://payment-webinit.simu.sips-atos.com/paymentInit') - sips_version = fields.Char("Interface Version", required_if_provider='sips', groups='base.group_no_one', default='HP_2.3') + sips_test_url = fields.Char("Test url", required_if_provider='sips', default='https://payment-webinit.simu.sips-atos.com/paymentInit') + sips_prod_url = fields.Char("Production url", required_if_provider='sips', default='https://payment-webinit.sips-atos.com/paymentInit') + sips_version = fields.Char("Interface Version", required_if_provider='sips', default='HP_2.3') def _sips_generate_shasign(self, values): """ Generate the shasign for incoming or outgoing communications. diff --git a/addons/payment_sips/views/payment_views.xml b/addons/payment_sips/views/payment_views.xml index bdd85f68132..b6b6c2fae1c 100644 --- a/addons/payment_sips/views/payment_views.xml +++ b/addons/payment_sips/views/payment_views.xml @@ -11,9 +11,9 @@ <group attrs="{'invisible': [('provider', '!=', 'sips')]}"> <field name="sips_merchant_id" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/> <field name="sips_secret" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/> - <field name="sips_test_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/> - <field name="sips_prod_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/> - <field name="sips_version" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/> + <field name="sips_test_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/> + <field name="sips_prod_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/> + <field name="sips_version" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/> </group> </xpath> </field> diff --git a/addons/point_of_sale/models/account_tax.py b/addons/point_of_sale/models/account_tax.py index 11375ec9ec8..8c130630d78 100644 --- a/addons/point_of_sale/models/account_tax.py +++ b/addons/point_of_sale/models/account_tax.py @@ -24,3 +24,12 @@ class AccountTax(models.Model): 'You must close the POS sessions before modifying the tax.' )) return super(AccountTax, self).write(vals) + + def get_real_tax_amount(self): + tax_list = [] + for tax in self: + tax_repartition_lines = tax.invoice_repartition_line_ids.filtered(lambda x: x.repartition_type == 'tax') + total_factor = sum(tax_repartition_lines.mapped('factor')) + real_amount = tax.amount * total_factor + tax_list.append({'id': tax.id, 'amount': real_amount}) + return tax_list diff --git a/addons/point_of_sale/static/src/css/customer_facing_display.css b/addons/point_of_sale/static/src/css/customer_facing_display.css index d9be0c89c35..bb699672ad8 100644 --- a/addons/point_of_sale/static/src/css/customer_facing_display.css +++ b/addons/point_of_sale/static/src/css/customer_facing_display.css @@ -122,8 +122,7 @@ body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_order box-shadow: none; animation: none; } -body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div, -body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child { +body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div, body .pos-customer_facing_display .pos_orderlines .pos_orderlines_item.pos_orderlines_header > div:last-child { border-left-width: 0; text-align: center; font-size: 70%; @@ -423,7 +422,7 @@ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .p text-align: center; } body .pos-customer_facing_display .pos-payment_info .pos-company_logo { - display: none!important; + display: none !important; } body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details { -webkit-box-flex: 1 0 100%; @@ -439,6 +438,7 @@ body .pos-customer_facing_display .pos-payment_info .pos-payment_info_details .p font-size: 6.5vw; } } + body .pos-hidden { opacity: 0; } @@ -460,7 +460,7 @@ body.editor_enable #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-gro } body.editor_enable #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group > .dropdown.oe_options.btn-group.snippet-option-pos_palette > .dropdown-menu > a { position: relative; - padding: 14px 0!important; + padding: 14px 0 !important; margin-bottom: 1px; } body.editor_enable #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group > .dropdown.oe_options.btn-group.snippet-option-pos_palette > .dropdown-menu > a:before { @@ -470,6 +470,7 @@ body.editor_enable #oe_manipulators .no_parent_options .snippet-option-pos_palet body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { display: none; } + .pos-palette_01 .pos-payment_info { background: #3E3E3E; color: #f6f6f6; @@ -506,6 +507,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_01:before { text-shadow: 0 0 1px #090909; } + .pos-palette_02 .pos-payment_info { background: #364152; color: #e6e7e8; @@ -542,6 +544,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_02:before { text-shadow: 0 0 1px #130d09; } + .pos-palette_03 .pos-payment_info { background: #1BA39C; color: #f6f6f6; @@ -578,6 +581,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_03:before { text-shadow: 0 0 1px #131313; } + .pos-palette_04 .pos-payment_info { background: #0b7b6c; color: #f6f6f6; @@ -614,6 +618,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_04:before { text-shadow: 0 0 1px #101113; } + .pos-palette_05 .pos-payment_info { background: #E26868; color: #f6f6f6; @@ -650,6 +655,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_05:before { text-shadow: 0 0 1px #131313; } + .pos-palette_06 .pos-payment_info { background: #9E373B; color: #f6f6f6; @@ -686,6 +692,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_06:before { text-shadow: 0 0 1px #090909; } + .pos-palette_07 .pos-payment_info { background: #ce9934; color: white; @@ -722,6 +729,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_07:before { text-shadow: 0 0 1px #131313; } + .pos-palette_08 .pos-payment_info { background: #a48c77; color: #f6f6f6; @@ -758,6 +766,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_08:before { text-shadow: 0 0 1px #131313; } + .pos-palette_09 .pos-payment_info { background: linear-gradient(30deg, #014d43, #127e71); color: #f6f6f6; @@ -794,6 +803,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_09:before { text-shadow: 0 0 1px #131313; } + .pos-palette_10 .pos-payment_info { background: linear-gradient(30deg, #e2316c, #ea4c89); color: white; @@ -830,6 +840,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_10:before { text-shadow: 0 0 1px #131313; } + .pos-palette_11 .pos-payment_info { background: linear-gradient(30deg, #362b3d, #5b4a63); color: white; @@ -866,6 +877,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_11:before { text-shadow: 0 0 1px #131313; } + .pos-palette_12 .pos-payment_info { background: #434343; color: #e6e6e6; @@ -902,6 +914,7 @@ body.editor_enable #oe_manipulators .no_parent_options .o_parent_editor_header { #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > .pos-palette_12:before { text-shadow: 0 0 1px #a4a4a4; } + .pos-palette_13 .pos-payment_info { background: linear-gradient(30deg, #1a1b1f, #3d3f45); color: white; diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index d413b9428b8..ab01cb1cb29 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -239,6 +239,19 @@ exports.PosModel = Backbone.Model.extend({ return self.taxes_by_id[child_tax_id]; }); }); + return new Promise(function (resolve, reject) { + var tax_ids = _.pluck(self.taxes, 'id'); + rpc.query({ + model: 'account.tax', + method: 'get_real_tax_amount', + args: [tax_ids], + }).then(function (taxes) { + _.each(taxes, function (tax) { + self.taxes_by_id[tax.id].amount = tax.amount; + resolve(); + }); + }); + }); }, },{ model: 'pos.session', @@ -848,7 +861,7 @@ exports.PosModel = Backbone.Model.extend({ self.flush_mutex.exec(function () { var flushed = self._flush_orders(self.db.get_orders(), opts); - flushed.then(resolve, resolve); + flushed.then(resolve, reject); return flushed; }); @@ -929,8 +942,9 @@ exports.PosModel = Backbone.Model.extend({ return this._save_to_server(orders, options).then(function (server_ids) { self.set_synch('connected'); return _.pluck(server_ids, 'id'); - }).catch(function(){ + }).catch(function(error){ self.set_synch(self.get('failed') ? 'error' : 'disconnected'); + return Promise.reject(error); }); }, diff --git a/addons/point_of_sale/static/src/scss/customer_facing_display.scss b/addons/point_of_sale/static/src/scss/customer_facing_display.scss index 25e0a0bc324..6677b6a572e 100644 --- a/addons/point_of_sale/static/src/scss/customer_facing_display.scss +++ b/addons/point_of_sale/static/src/scss/customer_facing_display.scss @@ -99,7 +99,8 @@ body { @include flex-display; @include flex-direction(row); - .pos-customer_products, .pos-payment_info { + .pos-customer_products, + .pos-payment_info { height: 100%; padding: 2%; @include flex-display; @@ -139,11 +140,14 @@ body { box-shadow: none; animation: none; - > div, > div:last-child { - border-left-width: 0; - text-align: center; - font-size: 70%; - font-weight: normal; + > div { + &, + &:last-child { + border-left-width: 0; + text-align: center; + font-size: 70%; + font-weight: normal; + } } > div:last-child { @@ -194,7 +198,8 @@ body { @include flex-direction(column); @include justify-content(space-between); - .pos-adv, .pos-company_logo { + .pos-adv, + .pos-company_logo { @include pos-bg; } @@ -212,7 +217,8 @@ body { } .pos-payment_info_details{ - .pos-total, .pos-paymentlines { + .pos-total, + .pos-paymentlines { @include flex-direction(row); @include flex-display; @include flex-wrap(wrap); @@ -221,6 +227,7 @@ body { > div { @include flex(1 0 48%); + &:nth-child(even) { font-weight: bold; font-size: 120%; @@ -433,7 +440,7 @@ body { } } - #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group> .dropdown.oe_options.btn-group.snippet-option-pos_palette > ul { + #oe_manipulators .oe_overlay > .oe_overlay_options > .btn-group> .dropdown.oe_options.btn-group.snippet-option-pos_palette > .dropdown-menu { min-width: 100px; max-width: 100px; @@ -441,7 +448,7 @@ body { display: none; } - > li > a { + > a { position: relative; padding: 14px 0!important; margin-bottom: 1px; @@ -453,7 +460,8 @@ body { } #oe_manipulators .no_parent_options { - .snippet-option-pos_palette, .o_parent_editor_header { + .snippet-option-pos_palette, + .o_parent_editor_header { display: none; } } @@ -462,7 +470,7 @@ body { // =========== PALETTE GENERATOR =========== -.palette_variant ( +@mixin palette-variant ( $bg-info: $color-gray-dark, $text-info: $color-gray-lighter, $bg-products: $color-gray-lighter, @@ -497,7 +505,7 @@ body { } // Editor dropdown buttons - #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > ul > li > & { + #oe_manipulators .oe_overlay > .oe_overlay_options .snippet-option-pos_palette > .dropdown-menu > & { background: $bg-products!important; &:after { @@ -511,7 +519,7 @@ body { } &:before { - text-shadow: 0 0 1px (#fff - $bg-products); + text-shadow: 0 0 1px invert($bg-products); } } }; diff --git a/addons/portal/__manifest__.py b/addons/portal/__manifest__.py index 1fb78f87335..b2e9843f8ca 100644 --- a/addons/portal/__manifest__.py +++ b/addons/portal/__manifest__.py @@ -15,7 +15,7 @@ portal. This module contains most code coming from odoo v10 website_portal. Purpose of this module is to allow the display of a customer portal without having a dependency towards website edition and customization capabilities.""", - 'depends': ['web', 'web_editor', 'http_routing', 'mail'], + 'depends': ['web', 'web_editor', 'http_routing', 'mail', 'auth_signup'], 'data': [ 'data/portal_data.xml', 'views/assets.xml', diff --git a/addons/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py index 225347df3fa..82fed4993c5 100644 --- a/addons/product/models/product_pricelist.py +++ b/addons/product/models/product_pricelist.py @@ -130,6 +130,7 @@ class Pricelist(models.Model): self.ensure_one() if not date: date = self._context.get('date') or fields.Date.context_today(self) + date = fields.Date.to_date(date) # handwritten query below does not work with non-dates (e.g. datetimes) if not uom_id and self._context.get('uom'): uom_id = self._context['uom'] if uom_id: @@ -305,10 +306,10 @@ class Pricelist(models.Model): list(zip(**products_by_qty_by_partner))) def _get_partner_pricelist_multi_search_domain_hook(self): - return [] + return [('active', '=', True)] def _get_partner_pricelist_multi_filter_hook(self): - return self + return self.filtered('active') def _get_partner_pricelist_multi(self, partner_ids, company_id=None): """ Retrieve the applicable pricelist for given partners in a given company. diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 012f41abc0b..75167a7184a 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -420,13 +420,20 @@ class ProductTemplate(models.Model): Product = self.env['product.product'] templates = self.browse([]) + domain_no_variant = [('product_variant_ids', '=', False)] while True: domain = templates and [('product_tmpl_id', 'not in', templates.ids)] or [] args = args if args is not None else [] products_ns = Product._name_search(name, args+domain, operator=operator, name_get_uid=name_get_uid) products = Product.browse([x[0] for x in products_ns]) templates |= products.mapped('product_tmpl_id') - if (not products) or (limit and (len(templates) > limit)): + current_round_templates = self.browse([]) + if not products: + domain_template = args + domain_no_variant + (templates and [('id', 'not in', templates.ids)] or []) + template_ns = super(ProductTemplate, self)._name_search(name=name, args=domain_template, operator=operator, limit=limit, name_get_uid=name_get_uid) + current_round_templates |= self.browse([ns[0] for ns in template_ns]) + templates |= current_round_templates + if (not products and not current_round_templates) or (limit and (len(templates) > limit)): break searched_ids = set(templates.ids) diff --git a/addons/product/tests/test_variants.py b/addons/product/tests/test_variants.py index a7d66176982..8c092ecfefe 100644 --- a/addons/product/tests/test_variants.py +++ b/addons/product/tests/test_variants.py @@ -827,6 +827,28 @@ class TestVariantsArchive(common.TestProductCommon): Product._revert_method('unlink') + def test_name_search_dynamic_attributes(self): + dynamic_attr = self.env['product.attribute'].create({ + 'name': 'Dynamic', + 'create_variant': 'dynamic', + 'value_ids': [(0, False, {'name': 'ValueDynamic'})], + }) + template = self.env['product.template'].create({ + 'name': 'cimanyd' + }) + self.assertEqual(len(template.product_variant_ids), 1) + + template.write({ + 'attribute_line_ids': [(0, False, { + 'attribute_id': dynamic_attr.id, + 'value_ids': [(4, dynamic_attr.value_ids[0].id, False)], + })] + }) + self.assertEqual(len(template.product_variant_ids), 0) + + name_searched = self.env['product.template'].name_search(name='cima') + self.assertIn(template.id, [ng[0] for ng in name_searched]) + def _update_color_vars(self, ptal): self.ptal_color = ptal self.assertEqual(self.ptal_color.attribute_id, self.pa_color) diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index aa3edd7d987..d55f7b00fd1 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -396,8 +396,6 @@ class PurchaseOrder(models.Model): 'default_company_id': self.company_id.id, 'default_purchase_id': self.id, } - if self.user_id: - result['context']['default_user_id'] = self.user_id.id # choose the view_mode accordingly if len(self.invoice_ids) > 1 and not create_bill: result['domain'] = "[('id', 'in', " + str(self.invoice_ids.ids) + ")]" diff --git a/addons/sale_coupon/models/sale_order.py b/addons/sale_coupon/models/sale_order.py index 899f203ba0a..25f0751ff4b 100644 --- a/addons/sale_coupon/models/sale_order.py +++ b/addons/sale_coupon/models/sale_order.py @@ -206,10 +206,12 @@ class SaleOrder(models.Model): def _get_reward_line_values(self, program): self.ensure_one() + self = self.with_context(lang=self.partner_id.lang) + program = program.with_context(lang=self.partner_id.lang) if program.reward_type == 'discount': - return self._get_reward_values_discount(program.with_context(lang=self.partner_id.lang)) + return self._get_reward_values_discount(program) elif program.reward_type == 'product': - return [self._get_reward_values_product(program.with_context(lang=self.partner_id.lang))] + return [self._get_reward_values_product(program)] def _create_reward_line(self, program): self.write({'order_line': [(0, False, value) for value in self._get_reward_line_values(program)]}) diff --git a/addons/sale_coupon/views/sale_coupon_program_views.xml b/addons/sale_coupon/views/sale_coupon_program_views.xml index db87e052f84..45871b72c29 100644 --- a/addons/sale_coupon/views/sale_coupon_program_views.xml +++ b/addons/sale_coupon/views/sale_coupon_program_views.xml @@ -21,7 +21,7 @@ <group name="conditions" string="Conditions"> <field name="active" invisible="1"/> <field name="program_type" invisible="1"/> - <field name="rule_products_domain" placeholder="Select product" widget="domain" options="{'model': 'product.product'}"/> + <field name="rule_products_domain" placeholder="Select product" widget="domain" options="{'model': 'product.product', 'in_dialog': true}"/> <label string="Quantity" for="rule_min_quantity" attrs="{'invisible': [('rule_products_domain', '=', False)]}"/> <div attrs="{'invisible': [('rule_products_domain', '=',False)]}"> <field name="rule_min_quantity" class="oe_inline"/> @@ -211,7 +211,7 @@ <field name="sequence" invisible="1"/> </xpath> <xpath expr="//field[@name='program_type']" position="after"> - <field name="rule_partners_domain" placeholder="Select customer" widget="domain" options="{'model': 'res.partner'}"/> + <field name="rule_partners_domain" placeholder="Select customer" widget="domain" options="{'model': 'res.partner', 'in_dialog': true}"/> </xpath> <xpath expr="//div[@name='rule_minimum_amount']" position="after"> <field name="promo_code_usage" widget="radio"/> diff --git a/addons/sale_purchase/models/sale_order.py b/addons/sale_purchase/models/sale_order.py index 5ad2d3e6c99..890316542a6 100644 --- a/addons/sale_purchase/models/sale_order.py +++ b/addons/sale_purchase/models/sale_order.py @@ -67,11 +67,11 @@ class SaleOrderLine(models.Model): _inherit = 'sale.order.line' purchase_line_ids = fields.One2many('purchase.order.line', 'sale_line_id', string="Generated Purchase Lines", readonly=True, help="Purchase line generated by this Sales item on order confirmation, or when the quantity was increased.") - purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count', compute_sudo=True) + purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count') @api.depends('purchase_line_ids') def _compute_purchase_count(self): - database_data = self.env['purchase.order.line'].read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id']) + database_data = self.env['purchase.order.line'].sudo().read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id']) mapped_data = dict([(db['sale_line_id'][0], db['sale_line_id_count']) for db in database_data]) for line in self: line.purchase_line_count = mapped_data.get(line.id, 0) diff --git a/addons/sale_stock/models/stock.py b/addons/sale_stock/models/stock.py index 3e4eb8c9180..7b1b9ee56a2 100644 --- a/addons/sale_stock/models/stock.py +++ b/addons/sale_stock/models/stock.py @@ -11,7 +11,7 @@ class StockLocationRoute(models.Model): class StockMove(models.Model): _inherit = "stock.move" - sale_line_id = fields.Many2one('sale.order.line', 'Sale Line') + sale_line_id = fields.Many2one('sale.order.line', 'Sale Line', index=True) @api.model def _prepare_merge_moves_distinct_fields(self): diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index a359140ae85..034f04774a2 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -1325,7 +1325,7 @@ class StockMove(models.Model): for result_package in moves_todo\ .mapped('move_line_ids.result_package_id')\ .filtered(lambda p: p.quant_ids and len(p.quant_ids) > 1): - if len(result_package.quant_ids.mapped('location_id')) > 1: + if len(result_package.quant_ids.filtered(lambda q: float_is_zero(abs(q.quantity) + abs(q.reserved_quantity), precision_rounding=q.product_uom_id.rounding)).mapped('location_id')) > 1: raise UserError(_('You cannot move the same package content more than once in the same transfer or split the same package into two location.')) picking = moves_todo.mapped('picking_id') moves_todo.write({'state': 'done', 'date': fields.Datetime.now()}) diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 3b5bab16629..2264891a7cc 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -549,7 +549,7 @@ class Picking(models.Model): # As the on_change in one2many list is WIP, we will overwrite the locations on the stock moves here # As it is a create the format will be a list of (0, 0, dict) - moves = vals.get('move_lines') or vals.get('move_ids_without_package') + moves = vals.get('move_lines', []) + vals.get('move_ids_without_package', []) if moves and vals.get('location_id') and vals.get('location_dest_id'): for move in moves: if len(move) == 3 and move[0] == 0: diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index ea7e26eec2f..ba8ea9cd119 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -50,7 +50,7 @@ class StockQuant(models.Model): product_id = fields.Many2one( 'product.product', 'Product', domain=lambda self: self._domain_product_id(), - ondelete='restrict', readonly=True, required=True, check_company=True) + ondelete='restrict', readonly=True, required=True, index=True, check_company=True) # so user can filter on template in webclient product_tmpl_id = fields.Many2one( 'product.template', string='Product Template', @@ -63,7 +63,7 @@ class StockQuant(models.Model): location_id = fields.Many2one( 'stock.location', 'Location', domain=lambda self: self._domain_location_id(), - auto_join=True, ondelete='restrict', readonly=True, required=True, check_company=True) + auto_join=True, ondelete='restrict', readonly=True, required=True, index=True, check_company=True) lot_id = fields.Many2one( 'stock.production.lot', 'Lot/Serial Number', ondelete='restrict', readonly=True, check_company=True, diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 240f589f76b..36acbf4d000 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -515,7 +515,7 @@ class Warehouse(models.Model): delivery_steps = vals.get('delivery_steps', def_values['delivery_steps']) code = vals.get('code') or code code = code.replace(' ', '').upper() - company_id = vals.get('company_id', self.company_id.id) + company_id = vals.get('company_id', self.default_get(['company_id'])['company_id']) sub_locations = { 'lot_stock_id': { 'name': _('Stock'), @@ -564,12 +564,13 @@ class Warehouse(models.Model): avoid mistakes during picking types and rules creation. """ for warehouse in self: - sub_locations = warehouse._get_locations_values(vals, warehouse.code) + company_id = vals.get('company_id', warehouse.company_id.id) + sub_locations = warehouse._get_locations_values(dict(vals, company_id=company_id), warehouse.code) missing_location = {} for location, location_values in sub_locations.items(): if not warehouse[location] and location not in vals: location_values['location_id'] = vals.get('view_location_id', warehouse.view_location_id.id) - location_values['company_id'] = vals.get('company_id', warehouse.company_id.id) + location_values['company_id'] = company_id missing_location[location] = self.env['stock.location'].create(location_values).id if missing_location: warehouse.write(missing_location) diff --git a/addons/stock/tests/test_packing.py b/addons/stock/tests/test_packing.py index c6f2d5f0462..4f135c70cb7 100644 --- a/addons/stock/tests/test_packing.py +++ b/addons/stock/tests/test_packing.py @@ -2,6 +2,7 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.tests.common import SavepointCase +from odoo.tools import float_round class TestPacking(SavepointCase): @@ -265,3 +266,49 @@ class TestPacking(SavepointCase): qp1 = pack2.quant_ids[0] qp2 = pack2.quant_ids[1] self.assertEqual(qp1.quantity + qp2.quantity, 12, 'The quant has not the good quantity') + + def test_move_picking_with_package(self): + """ + 355.4 rounded with 0.001 precision is 355.40000000000003. + check that nonetheless, moving a picking is accepted + """ + self.assertEqual(self.productA.uom_id.rounding, 0.001) + self.assertEqual( + float_round(355.4, precision_rounding=self.productA.uom_id.rounding), + 355.40000000000003, + ) + location_dict = { + 'location_id': self.stock_location.id, + } + quant = self.env['stock.quant'].create({ + **location_dict, + **{'product_id': self.productA.id, 'quantity': 355.4}, # important number + }) + package = self.env['stock.quant.package'].create({ + **location_dict, **{'quant_ids': [(6, 0, [quant.id])]}, + }) + location_dict.update({ + 'state': 'draft', + 'location_dest_id': self.ship_location.id, + }) + move = self.env['stock.move'].create({ + **location_dict, + **{ + 'name': "XXX", + 'product_id': self.productA.id, + 'product_uom': self.productA.uom_id.id, + 'product_uom_qty': 355.40000000000003, # other number + }}) + picking = self.env['stock.picking'].create({ + **location_dict, + **{ + 'picking_type_id': self.warehouse.in_type_id.id, + 'move_lines': [(6, 0, [move.id])], + }}) + + picking.action_confirm() + picking.action_assign() + move.quantity_done = move.reserved_availability + picking.action_done() + # if we managed to get there, there was not any exception + # complaining that 355.4 is not 355.40000000000003. Good job! diff --git a/addons/stock/tests/test_warehouse.py b/addons/stock/tests/test_warehouse.py index 8867f098761..c6e2093d50c 100644 --- a/addons/stock/tests/test_warehouse.py +++ b/addons/stock/tests/test_warehouse.py @@ -3,6 +3,7 @@ from odoo.addons.stock.tests.common2 import TestStockCommon from odoo.tests import Form from odoo.exceptions import AccessError +from odoo.tools import mute_logger class TestWarehouse(TestStockCommon): @@ -449,6 +450,19 @@ class TestWarehouse(TestStockCommon): # Ensure there still no quants in distribution warehouse self.assertEqual(sum(self.env['stock.quant']._gather(product, warehouse_distribution_wavre.lot_stock_id).mapped('quantity')), 0) + def test_noleak(self): + # non-regression test to avoid company_id leaking to other warehouses (see blame) + wh = self.env["stock.warehouse"].search([]) + + assert len(set(wh.mapped("company_id.id"))) > 1 + + companies_before = wh.mapped(lambda w: (w.id, w.company_id)) + # writing on any field should change the company of warehouses + wh.write({"name": "whatever"}) + companies_after = wh.mapped(lambda w: (w.id, w.company_id)) + + self.assertEqual(companies_after, companies_before) + def test_toggle_active_warehouse_1(self): """ Basic test that create a warehouse with classic configuration. Archive it and check that locations, picking types, routes, rules are diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py index 17cbe9df4cf..0a3e4368835 100644 --- a/addons/stock_landed_costs/models/stock_landed_cost.py +++ b/addons/stock_landed_costs/models/stock_landed_cost.py @@ -172,13 +172,13 @@ class LandedCost(models.Model): prec_digits = self.env.company.currency_id.decimal_places for landed_cost in self: total_amount = sum(landed_cost.valuation_adjustment_lines.mapped('additional_landed_cost')) - if not tools.float_compare(total_amount, landed_cost.amount_total, precision_digits=prec_digits) == 0: + if not tools.float_is_zero(total_amount - landed_cost.amount_total, precision_digits=prec_digits): return False val_to_cost_lines = defaultdict(lambda: 0.0) for val_line in landed_cost.valuation_adjustment_lines: val_to_cost_lines[val_line.cost_line_id] += val_line.additional_landed_cost - if any(tools.float_compare(cost_line.price_unit, val_amount, precision_digits=prec_digits) != 0 + if any(not tools.float_is_zero(cost_line.price_unit - val_amount, precision_digits=prec_digits) for cost_line, val_amount in val_to_cost_lines.items()): return False return True diff --git a/addons/web/models/ir_qweb.py b/addons/web/models/ir_qweb.py index e8a966647e3..1083916ebf5 100644 --- a/addons/web/models/ir_qweb.py +++ b/addons/web/models/ir_qweb.py @@ -52,6 +52,7 @@ class Image(models.AbstractModel): filename = options['filename'] else: filename = record.display_name + filename = filename.replace('/', '-').replace('\\', '-') src = '/web/image/%s/%s/%s%s/%s?unique=%s' % (record._name, record.id, options.get('preview_image', field_name), max_size, url_quote(filename), sha) diff --git a/addons/web/static/src/js/core/abstract_storage_service.js b/addons/web/static/src/js/core/abstract_storage_service.js index ef828b5dd5c..5d724f87911 100644 --- a/addons/web/static/src/js/core/abstract_storage_service.js +++ b/addons/web/static/src/js/core/abstract_storage_service.js @@ -45,6 +45,19 @@ var AbstractStorageService = AbstractService.extend({ return val ? JSON.parse(val) : defaultValue; }, /** + * @param {integer} index + * @return {string} + */ + key: function (index) { + return this.storage.key(index); + }, + /** + * @return {integer} + */ + length: function () { + return this.storage.length; + }, + /** * Removes the given key from the storage * * @param {string} key diff --git a/addons/web/static/src/js/core/local_storage.js b/addons/web/static/src/js/core/local_storage.js index 0ae98498b31..ecc0cb7b78f 100644 --- a/addons/web/static/src/js/core/local_storage.js +++ b/addons/web/static/src/js/core/local_storage.js @@ -35,12 +35,10 @@ try { var newValue = e.originalEvent.newValue; try { JSON.parse(newValue); - if (localStorage.getItem(key) === newValue) { - storage.trigger('storage', { - key: key, - newValue: newValue, - }); - } + storage.trigger('storage', { + key: key, + newValue: newValue, + }); } catch (error) {} }); return storage; diff --git a/addons/web/static/src/js/core/ram_storage.js b/addons/web/static/src/js/core/ram_storage.js index 9bf0fc29f93..abad86c4538 100644 --- a/addons/web/static/src/js/core/ram_storage.js +++ b/addons/web/static/src/js/core/ram_storage.js @@ -31,6 +31,7 @@ var RamStorage = Class.extend(mixins.EventDispatcherMixin, { */ clear: function () { this.storage = Object.create(null); + this.length = 0; }, /** * Returns the value associated with a given key in the storage @@ -42,11 +43,21 @@ var RamStorage = Class.extend(mixins.EventDispatcherMixin, { return this.storage[key]; }, /** + * @param {integer} index + * @return {string} + */ + key: function (index) { + return _.keys(this.storage)[index]; + }, + /** * Removes the given key from the storage * * @param {string} key */ removeItem: function (key) { + if (key in this.storage) { + this.length--; + } delete this.storage[key]; this.trigger('storage', {key: key, newValue: null}); }, @@ -58,6 +69,9 @@ var RamStorage = Class.extend(mixins.EventDispatcherMixin, { * @param {string} value */ setItem: function (key, value) { + if (!(key in this.storage)) { + this.length++; + } this.storage[key] = value; this.trigger('storage', {key: key, newValue: value}); }, diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js index f4337d0c8f9..b2d3379aef1 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -2576,7 +2576,7 @@ var FieldSelection = AbstractField.extend({ description: _lt("Selection"), template: 'FieldSelection', specialData: "_fetchSpecialRelation", - supportedFieldTypes: ['selection', 'many2one'], + supportedFieldTypes: ['selection'], events: _.extend({}, AbstractField.prototype.events, { 'change': '_onChange', }), @@ -2629,10 +2629,14 @@ var FieldSelection = AbstractField.extend({ */ _renderEdit: function () { this.$el.empty(); + var required = this.attrs.modifiersValue && this.attrs.modifiersValue.required; for (var i = 0 ; i < this.values.length ; i++) { + var disabled = required && this.values[i][0] === false; + this.$el.append($('<option/>', { value: JSON.stringify(this.values[i][0]), - text: this.values[i][1] + text: this.values[i][1], + style: disabled ? "display: none" : "", })); } var value = this.value; @@ -2671,9 +2675,7 @@ var FieldSelection = AbstractField.extend({ return v[0] === false && v[1] === ''; }); } - if (!this.attrs.modifiersValue || !this.attrs.modifiersValue.required) { - this.values = [[false, this.attrs.placeholder || '']].concat(this.values); - } + this.values = [[false, this.attrs.placeholder || '']].concat(this.values); }, //-------------------------------------------------------------------------- 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 614602a5791..2d239520dfb 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -2068,9 +2068,11 @@ var BasicModel = AbstractModel.extend({ if (field.onChange) { hasOnchange = true; } - _.each(fieldInfo.views, function (view) { - generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.'); - }); + if (field.type === 'one2many' || field.type === 'many2many') { + _.each(fieldInfo.views, function (view) { + generateSpecs(view.fieldsInfo[view.type], view.fields, key + '.'); + }); + } }); } return hasOnchange ? specs : false; 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 22de0e8d68b..bd31cddd50d 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -631,7 +631,9 @@ var FormController = BasicController.extend({ * @private */ _onEdit: function () { - this._setMode('edit'); + // wait for potential pending changes to be saved (done with widgets + // allowing to edit in readonly) + this.mutex.getUnlockedDef().then(this._setMode.bind(this, 'edit')); }, /** * This method is called when someone tries to freeze the order, most likely diff --git a/addons/web/static/src/js/views/form/form_view.js b/addons/web/static/src/js/views/form/form_view.js index 95b84423444..13d73cf37c2 100644 --- a/addons/web/static/src/js/views/form/form_view.js +++ b/addons/web/static/src/js/views/form/form_view.js @@ -123,6 +123,10 @@ var FormView = BasicView.extend({ var refinedContext = _.pick(self.loadParams.context, function (value, key) { return key.indexOf('_view_ref') === -1; }); + // Specify the main model to prevent access rights defined in the context + // (e.g. create: 0) to apply to subviews. We use here the same logic as + // the one applied by the server for inline views. + refinedContext.base_model_name = self.controllerParams.modelName; defs.push(parent.loadViews( field.relation, new Context(context, self.userContext, refinedContext).eval(), diff --git a/addons/web/static/src/js/views/graph/graph_view.js b/addons/web/static/src/js/views/graph/graph_view.js index f8125675d4e..c3ad22f6ced 100644 --- a/addons/web/static/src/js/views/graph/graph_view.js +++ b/addons/web/static/src/js/views/graph/graph_view.js @@ -48,6 +48,9 @@ var GraphView = AbstractView.extend({ this.arch.children.forEach(function (field) { var fieldName = field.attrs.name; + if (fieldName === "id") { + return; + } var interval = field.attrs.interval; if (interval) { fieldName = fieldName + ':' + interval; diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js index 701180a357f..0c4213c60b7 100644 --- a/addons/web/static/src/js/views/kanban/kanban_record.js +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -70,12 +70,14 @@ var KanbanRecord = Widget.extend({ * Called each time the record is attached to the DOM. */ on_attach_callback: function () { + this.isInDOM = true; _.invoke(this.subWidgets, 'on_attach_callback'); }, /** * Called each time the record is detached from the DOM. */ on_detach_callback: function () { + this.isInDOM = false; _.invoke(this.subWidgets, 'on_detach_callback'); }, @@ -256,6 +258,9 @@ var KanbanRecord = Widget.extend({ // a widget already exists for that field, so reset it with the new state widget.reset(self.state); $field.replaceWith(widget.$el); + if (self.isInDOM && widget.on_attach_callback) { + widget.on_attach_callback(); + } } } else { self._processField($field, field_name); @@ -342,6 +347,9 @@ var KanbanRecord = Widget.extend({ */ _render: function () { this.defs = []; + // call 'on_detach_callback' on each subwidget as they will be removed + // from the DOM at the next line + _.invoke(this.subWidgets, 'on_detach_callback'); this._replaceElement(this.qweb.render('kanban-box', this.qweb_context)); this.$el.addClass('o_kanban_record').attr("tabindex", 0); this.$el.attr('role', 'article'); diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js index 1e109d7c0b8..5ec42156000 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -622,6 +622,7 @@ var ListController = BasicController.extend({ _onEditGroupClicked: function (ev) { ev.stopPropagation(); this.do_action({ + context: {create: false}, type: 'ir.actions.act_window', views: [[false, 'form']], res_model: ev.data.record.model, @@ -704,6 +705,7 @@ var ListController = BasicController.extend({ * @param {OdooEvent} ev */ _onToggleGroup: function (ev) { + ev.stopPropagation(); var self = this; this.model .toggleGroup(ev.data.group.id) diff --git a/addons/web/static/src/js/widgets/auto_complete.js b/addons/web/static/src/js/widgets/auto_complete.js index 4da54cf8b5c..1bb00be8d38 100644 --- a/addons/web/static/src/js/widgets/auto_complete.js +++ b/addons/web/static/src/js/widgets/auto_complete.js @@ -140,12 +140,22 @@ return Widget.extend({ var $item = self.make_list_item(result).appendTo($list); result.$el = $item; }); + // IE9 doesn't support addEventListener with option { once: true } + this.el.onmousemove = function (ev) { + self.$('li').each(function (index, li) { + li.onmouseenter = self.focus_element.bind(self, $(li)); + }); + var targetFocus = ev.target.tagName === 'LI' ? + ev.target : + ev.target.closest('li'); + self.focus_element($(targetFocus)); + self.el.onmousemove = null; + }; this.show(); }, make_list_item: function (result) { var self = this; var $li = $('<li>') - .hover(function () {self.focus_element($li);}) .mousedown(function (ev) { if (ev.button === 0) { // left button self.select(ev, {item: {facet: result.facet}}); diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js index 5b4480838fe..ac8e83c8e30 100644 --- a/addons/web/static/src/js/widgets/date_picker.js +++ b/addons/web/static/src/js/widgets/date_picker.js @@ -92,8 +92,8 @@ var DateWidget = Widget.extend({ * set datetime value */ changeDatetime: function () { + var oldValue = this.getValue(); if (this.isValid()) { - var oldValue = this.getValue(); this._setValueFromUi(); var newValue = this.getValue(); var hasChanged = !oldValue !== !newValue; @@ -110,6 +110,9 @@ var DateWidget = Widget.extend({ } this.trigger("datetime_changed"); } + } else { + var formattedValue = oldValue ? this._formatClient(oldValue) : null; + this.$input.val(formattedValue); } }, /** diff --git a/addons/web/static/src/scss/ui.scss b/addons/web/static/src/scss/ui.scss index 44d639805be..9ab1729a4ac 100644 --- a/addons/web/static/src/scss/ui.scss +++ b/addons/web/static/src/scss/ui.scss @@ -83,3 +83,11 @@ .o_debounce_disabled { pointer-events: none; } + +span.o_force_ltr { + display: inline-block; +} +.o_force_ltr, .o_field_phone { + /*rtl:ignore*/ + direction: ltr; +} diff --git a/addons/web/static/tests/fields/basic_fields_tests.js b/addons/web/static/tests/fields/basic_fields_tests.js index e8060e23e58..eca412c036a 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -2848,8 +2848,32 @@ QUnit.module('basic_fields', { form.destroy(); }); - QUnit.test('date field should remove the date if the date is not valid', async function (assert) { - assert.expect(1); + QUnit.test('date field: set an invalid date when the field is already set', async function (assert) { + assert.expect(2); + + var form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form string="Partners"><field name="date"/></form>', + res_id: 1, + viewOptions: { + mode: 'edit', + }, + }); + + var $input = form.$('.o_field_widget[name=date] input'); + + assert.strictEqual($input.val(), "02/03/2017"); + + $input.val('mmmh').trigger('change'); + assert.strictEqual($input.val(), "02/03/2017", "should have reset the original value"); + + form.destroy(); + }); + + QUnit.test('date field: set an invalid date when the field is not set yet', async function (assert) { + assert.expect(2); var form = await createView({ View: FormView, @@ -2857,13 +2881,18 @@ QUnit.module('basic_fields', { data: this.data, arch: '<form string="Partners"><field name="date"/></form>', res_id: 4, + viewOptions: { + mode: 'edit', + }, }); - // switch to edit mode - await testUtils.form.clickEdit(form); - // set an invalid date + var $input = form.$('.o_field_widget[name=date] input'); + + assert.strictEqual($input.text(), ""); + $input.val('mmmh').trigger('change'); assert.strictEqual($input.text(), "", "The date field should be empty"); + form.destroy(); }); diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js index 7284b9099d8..7f3f54e3ad7 100644 --- a/addons/web/static/tests/fields/relational_fields_tests.js +++ b/addons/web/static/tests/fields/relational_fields_tests.js @@ -1191,7 +1191,7 @@ QUnit.module('relational_fields', { }); QUnit.test('required selection widget should not have blank option', async function (assert) { - assert.expect(3); + assert.expect(12); this.data.partner.fields.feedback_value = { type: "selection", @@ -1212,15 +1212,38 @@ QUnit.module('relational_fields', { }); await testUtils.form.clickEdit(form); - assert.strictEqual(form.$('.o_field_widget[name=color]')[0].options.length, 3, - "non required selection field must have 3 options 1 blank option and 2 value options"); - assert.strictEqual(form.$('.o_field_widget[name=feedback_value]')[0].options.length, 2, - "should have only 2 options without blank option"); + + var $colorField = form.$('.o_field_widget[name=color]'); + assert.containsN($colorField, 'option', 3, "Three options in non required field"); + + assert.hasAttrValue($colorField.find('option:first()'), 'style', "", + "Should not have display=none"); + assert.hasAttrValue($colorField.find('option:eq(1)'), 'style', "", + "Should not have display=none"); + assert.hasAttrValue($colorField.find('option:eq(2)'), 'style', "", + "Should not have display=none"); + + const $requiredSelect = form.$('.o_field_widget[name=feedback_value]'); + + assert.containsN($requiredSelect, 'option', 3, "Three options in required field"); + assert.hasAttrValue($requiredSelect.find('option:first()'), 'style', "display: none", + "Should have display=none"); + assert.hasAttrValue($requiredSelect.find('option:eq(1)'), 'style', "", + "Should not have display=none"); + assert.hasAttrValue($requiredSelect.find('option:eq(2)'), 'style', "", + "Should not have display=none"); // change value to update widget modifier values - await testUtils.fields.editSelect(form.$('.o_field_widget[name=feedback_value]'), '"bad"'); - assert.strictEqual(form.$('.o_field_widget[name=color]')[0].options.length, 2, - "should have only 2 options"); + await testUtils.fields.editSelect($requiredSelect, '"bad"'); + $colorField = form.$('.o_field_widget[name=color]'); + + assert.containsN($colorField, 'option', 3, "Three options in required field"); + assert.hasAttrValue($colorField.find('option:first()'), 'style', "display: none", + "Should have display=none"); + assert.hasAttrValue($colorField.find('option:eq(1)'), 'style', "", + "Should not have display=none"); + assert.hasAttrValue($colorField.find('option:eq(2)'), 'style', "", + "Should not have display=none"); form.destroy(); }); diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js index be6dd4c8e72..d77e8ceae79 100644 --- a/addons/web/static/tests/helpers/mock_server.js +++ b/addons/web/static/tests/helpers/mock_server.js @@ -85,7 +85,7 @@ var MockServer = Class.extend({ throw new Error('Model ' + model + ' was not defined in mock server data'); } var fields = $.extend(true, {}, this.data[model].fields); - var fvg = this._fieldsViewGet(params.arch, model, fields, viewOptions.context); + var fvg = this._fieldsViewGet(params.arch, model, fields, viewOptions.context || {}); if (toolbar) { fvg.toolbar = toolbar; } @@ -214,6 +214,17 @@ var MockServer = Class.extend({ var inTreeView = (doc.tagName === 'tree'); + // mock _postprocess_access_rights + const isBaseModel = !context.base_model_name || (model === context.base_model_name); + var views = ['kanban', 'tree', 'form', 'gantt', 'activity']; + if (isBaseModel && views.indexOf(doc.tagName) !== -1) { + for (let action of ['create', 'delete', 'edit', 'write']) { + if (!doc.getAttribute(action) && action in context && !context[action]) { + doc.setAttribute(action, 'false'); + } + } + } + this._traverse(doc, function (node) { if (node.nodeType === Node.TEXT_NODE) { return false; @@ -318,7 +329,7 @@ var MockServer = Class.extend({ relModel = field.relation; relFields = $.extend(true, {}, self.data[relModel].fields); field.views[children.tagName] = self._fieldsViewGet(children, relModel, - relFields, context); + relFields, _.extend({}, context, {base_model_name: model})); }); } @@ -338,9 +349,9 @@ var MockServer = Class.extend({ node._isProcessed = true; // postprocess simulation field.views.groupby = self._fieldsViewGet(node, relModel, relFields, context); - node.childNodes.forEach(function (child) { - node.removeChild(child); - }); + while (node.firstChild) { + node.removeChild(node.firstChild); + } }); var xmlSerializer = new XMLSerializer(); diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js index b8f42236c24..892a946bba0 100644 --- a/addons/web/static/tests/helpers/test_utils_create.js +++ b/addons/web/static/tests/helpers/test_utils_create.js @@ -140,6 +140,7 @@ async function createView(params) { _updateMVCParams: function () { this._super.apply(this, arguments); this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || []; + testUtilsMock.unpatch(View); }, }); if ('hasSelectors' in params) { @@ -178,8 +179,6 @@ async function createView(params) { delete view.destroy; widget.destroy(); $webClient.remove(); - - testUtilsMock.unpatch(View); }; // render the view in a fragment as they must be able to render correctly diff --git a/addons/web/static/tests/views/form_tests.js b/addons/web/static/tests/views/form_tests.js index 8e4b2e82d02..16524ba171d 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -490,8 +490,10 @@ QUnit.module('Views', { 'The correct _view_ref should have been sent to the server, first time'); } if (args.model === 'partner_type') { - assert.deepEqual(context, {tree_view_ref: 'some_other_tree_view'}, - 'The correct _view_ref should have been sent to the server for the subview'); + assert.deepEqual(context, { + base_model_name: 'product', + tree_view_ref: 'some_other_tree_view', + }, 'The correct _view_ref should have been sent to the server for the subview'); } } return this._super.apply(this, arguments); @@ -4757,6 +4759,35 @@ QUnit.module('Views', { }); }); + QUnit.test('non inline subview and create=0 in action context', async function (assert) { + // the create=0 should apply on the main view (form), but not on subviews + assert.expect(2); + + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: '<form><field name="product_ids" mode="kanban"/></form>', + archs: { + "product,false,kanban": `<kanban> + <templates><t t-name="kanban-box"> + <div><field name="name"/></div> + </t></templates> + </kanban>`, + }, + res_id: 1, + viewOptions: { + context: {create: false}, + mode: 'edit', + }, + }); + + assert.containsNone(form, '.o_form_button_create'); + assert.containsOnce(form, '.o-kanban-button-new'); + + form.destroy(); + }); + QUnit.test('readonly fields with modifiers may be saved', async function (assert) { // the readonly property on the field description only applies on view, // this is not a DB constraint. It should be seen as a default value, @@ -8032,6 +8063,46 @@ QUnit.module('Views', { form.destroy(); }); + QUnit.test('edit a record in readonly and switch to edit before it is actually saved', async function (assert) { + assert.expect(3); + + const prom = testUtils.makeTestPromise(); + const form = await createView({ + View: FormView, + model: 'partner', + data: this.data, + arch: `<form> + <field name="foo"/> + <field name="bar" widget="toggle_button"/> + </form>`, + mockRPC: function (route, args) { + const result = this._super.apply(this, arguments); + if (args.method === 'write') { // delay the write RPC + assert.deepEqual(args.args[1], {bar: false}); + return prom.then(_.constant(result)); + } + return result; + }, + res_id: 1, + }); + + // edit the record (in readonly) with toogle_button widget (and delay the write RPC) + await testUtils.dom.click(form.$('.o_field_widget[name=bar]')); + + // switch to edit mode + await testUtils.form.clickEdit(form); + + assert.hasClass(form.$('.o_form_view'), 'o_form_readonly'); // should wait for the RPC to return + + // make write RPC return + prom.resolve(); + await testUtils.nextTick(); + + assert.hasClass(form.$('.o_form_view'), 'o_form_editable'); + + form.destroy(); + }); + QUnit.test('"bare" buttons in template should not trigger button click', async function (assert) { assert.expect(3); diff --git a/addons/web/static/tests/views/graph_tests.js b/addons/web/static/tests/views/graph_tests.js index f958924061b..7cbab784829 100644 --- a/addons/web/static/tests/views/graph_tests.js +++ b/addons/web/static/tests/views/graph_tests.js @@ -176,6 +176,27 @@ QUnit.module('Views', { graph.destroy(); }); + QUnit.test('field id not in groupBy', async function (assert) { + assert.expect(1); + + var graph = await createView({ + View: GraphView, + model: "foo", + data: this.data, + arch: '<graph string="Partners">' + + '<field name="id"/>' + + '</graph>', + mockRPC: function (route, args) { + if (args.method === 'read_group') { + assert.deepEqual(args.kwargs.groupby, [], + 'groupby should not contain id field'); + } + return this._super.apply(this, arguments); + } + }); + graph.destroy(); + }); + QUnit.test('switching mode', async function (assert) { assert.expect(6); diff --git a/addons/web/static/tests/views/kanban_tests.js b/addons/web/static/tests/views/kanban_tests.js index ca04c9413a8..fcb7df4ff29 100644 --- a/addons/web/static/tests/views/kanban_tests.js +++ b/addons/web/static/tests/views/kanban_tests.js @@ -1,6 +1,7 @@ odoo.define('web.kanban_tests', function (require) { "use strict"; +var AbstractField = require('web.AbstractField'); var fieldRegistry = require('web.field_registry'); var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar'); var kanbanExamplesRegistry = require('web.kanban_examples_registry'); @@ -4595,6 +4596,60 @@ QUnit.module('Views', { delete widgetRegistry.map.test; }); + QUnit.test('subwidgets with on_attach_callback when changing record color', async function (assert) { + assert.expect(3); + + var counter = 0; + var MyTestWidget = AbstractField.extend({ + on_attach_callback: function () { + counter++; + }, + }); + fieldRegistry.add('test_widget', MyTestWidget); + + var kanban = await createView({ + View: KanbanView, + model: 'category', + data: this.data, + arch: '<kanban class="o_kanban_test">' + + '<field name="color"/>' + + '<templates>' + + '<t t-name="kanban-box">' + + '<div color="color">' + + '<div class="o_dropdown_kanban dropdown">' + + '<a class="dropdown-toggle o-no-caret btn" data-toggle="dropdown" href="#">' + + '<span class="fa fa-bars fa-lg"/>' + + '</a>' + + '<ul class="dropdown-menu" role="menu">' + + '<li>' + + '<ul class="oe_kanban_colorpicker"/>' + + '</li>' + + '</ul>' + + '</div>' + + '<field name="name" widget="test_widget"/>' + + '</div>' + + '</t>' + + '</templates>' + + '</kanban>', + }); + + // counter should be 2 as there are 2 records + assert.strictEqual(counter, 2, "on_attach_callback should have been called twice"); + + // set a color to kanban record + var $firstRecord = kanban.$('.o_kanban_record:first()'); + testUtils.kanban.toggleRecordDropdown($firstRecord); + await testUtils.dom.click($firstRecord.find('.oe_kanban_colorpicker a.oe_kanban_color_9')); + + // first record has replaced its $el with a new one + $firstRecord = kanban.$('.o_kanban_record:first()'); + assert.hasClass($firstRecord, 'oe_kanban_color_9'); + assert.strictEqual(counter, 3, "on_attach_callback method should be called 3 times"); + + delete fieldRegistry.map.test_widget; + kanban.destroy(); + }); + QUnit.test('column progressbars properly work', async function (assert) { assert.expect(2); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index e49bd499c42..c97b1916265 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2879,6 +2879,7 @@ QUnit.module('Views', { intercepts: { do_action: function (event) { assert.deepEqual(event.data.action, { + context: {create: false}, res_id: 2, res_model: 'res_currency', type: 'ir.actions.act_window', @@ -2892,6 +2893,42 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('groupby node with subfields, and onchange', async function (assert) { + assert.expect(1); + + this.data.foo.onchanges = { + foo: function () {}, + }; + + const list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: `<tree editable="bottom" expand="1"> + <field name="foo"/> + <field name="currency_id"/> + <groupby name="currency_id"> + <field name="position" invisible="1"/> + </groupby> + </tree>`, + groupBy: ['currency_id'], + mockRPC: function (route, args) { + if (args.method === 'onchange') { + assert.deepEqual(args.args[3], { + foo: "1", + currency_id: "", + }, 'onchange spec should not follow relation of many2one fields'); + } + return this._super.apply(this, arguments); + }, + }); + + await testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first')); + await testUtils.fields.editInput(list.$('.o_field_widget[name=foo]'), "new value"); + + list.destroy(); + }); + QUnit.test('list view, editable, without data', async function (assert) { assert.expect(12); @@ -4432,6 +4469,58 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('grouped list with another grouped list parent, click unfold', async function (assert) { + assert.expect(3); + this.data.bar.fields = { + cornichon: {string: 'cornichon', type: 'char'}, + }; + + var rec = this.data.bar.records[0]; + // create records to have the search more button + var newRecs = []; + for (var i=0; i<8; i++) { + var newRec = _.extend({}, rec); + newRec.id = 10 + i; + newRec.cornichon = 'extra fin'; + newRecs.push(newRec); + } + this.data.bar.records = newRecs; + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="top"><field name="foo"/><field name="m2o"/></tree>', + groupBy: ['bar'], + archs: { + 'bar,false,list': '<tree><field name="cornichon"/></tree>', + 'bar,false,search': '<search><filter context="{\'group_by\': \'cornichon\'}" string="cornichon"/></search>', + }, + }); + + await list.update({groupBy: []}); + + await testUtils.dom.click(list.$('.o_data_cell:eq(0)')); + + await testUtils.dom.click(list.$('.o_selected_row .o_data_cell .o_field_many2one input')); + await testUtils.dom.triggerEvents($('.ui-autocomplete a:contains(Search More)'), + ['mouseenter', 'click']); + + assert.containsOnce($('body'), '.modal-content'); + + assert.containsNone($('body'), '.modal-content .o_group_name', 'list in modal not grouped'); + + await testUtils.dom.click($('body .modal-content button:contains(Group By)')); + + await testUtils.dom.click($('body .modal-content .o_menu_item a:contains(cornichon)')); + + await testUtils.dom.click($('body .modal-content .o_group_header')); + + assert.containsOnce($('body'), '.modal-content .o_group_open'); + + list.destroy(); + }); + QUnit.test('field values are escaped', async function (assert) { assert.expect(1); var value = '<script>throw Error();</script>'; diff --git a/addons/web/static/tests/views/view_dialogs_tests.js b/addons/web/static/tests/views/view_dialogs_tests.js index 558caeeaf37..b68d676a125 100644 --- a/addons/web/static/tests/views/view_dialogs_tests.js +++ b/addons/web/static/tests/views/view_dialogs_tests.js @@ -411,8 +411,10 @@ QUnit.module('Views', { 'The correct _view_ref should have been sent to the server, first time'); } if (ev.data.modelName === 'badassery') { - assert.deepEqual(evaluatedContext, {tree_view_ref: 'some_other_tree_view'}, - 'The correct _view_ref should have been sent to the server for the subview'); + assert.deepEqual(evaluatedContext, { + base_model_name: 'instrument', + tree_view_ref: 'some_other_tree_view', + }, 'The correct _view_ref should have been sent to the server for the subview'); } }, }, diff --git a/addons/web_editor/static/src/js/editor/snippets.options.js b/addons/web_editor/static/src/js/editor/snippets.options.js index 63f0c38c7c3..f124d1841f0 100644 --- a/addons/web_editor/static/src/js/editor/snippets.options.js +++ b/addons/web_editor/static/src/js/editor/snippets.options.js @@ -767,7 +767,7 @@ registry.colorpicker = SnippetOption.extend({ if ($selected.length) { if ($selected.data('color')) { this.$target.addClass(this.colorPrefix + $selected.data('color')); - } else { + } else if ($selected.hasClass('o_custom_color')) { this.$target.css('background-color', $selected.css('background-color')); } } diff --git a/addons/website/models/ir_ui_view.py b/addons/website/models/ir_ui_view.py index 6b1efe5a717..a3aca36ced5 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -336,18 +336,19 @@ class View(models.Model): qcontext['main_object'] = self cur = Website.get_current_website() - qcontext['multi_website_websites_current'] = {'website_id': cur.id, 'name': cur.name, 'domain': cur.domain} - qcontext['multi_website_websites'] = [ - {'website_id': website.id, 'name': website.name, 'domain': website.domain} - for website in Website.search([]) if website != cur - ] - - cur_company = self.env.company - qcontext['multi_website_companies_current'] = {'company_id': cur_company.id, 'name': cur_company.name} - qcontext['multi_website_companies'] = [ - {'company_id': comp.id, 'name': comp.name} - for comp in self.env.user.company_ids if comp != cur_company - ] + if self.env.user.has_group('website.group_website_publisher') and self.env.user.has_group('website.group_multi_website'): + qcontext['multi_website_websites_current'] = {'website_id': cur.id, 'name': cur.name, 'domain': cur.domain} + qcontext['multi_website_websites'] = [ + {'website_id': website.id, 'name': website.name, 'domain': website.domain} + for website in Website.search([]) if website != cur + ] + + cur_company = self.env.company + qcontext['multi_website_companies_current'] = {'company_id': cur_company.id, 'name': cur_company.name} + qcontext['multi_website_companies'] = [ + {'company_id': comp.id, 'name': comp.name} + for comp in self.env.user.company_ids if comp != cur_company + ] qcontext.update(dict( self._context.copy(), diff --git a/addons/website/models/website.py b/addons/website/models/website.py index 338cdba591b..0ddca6810e2 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -740,9 +740,6 @@ class Website(models.Model): domain_part, url = rule.build(value, append_unknown=False) if not query_string or query_string.lower() in url.lower(): page = {'loc': url} - for key, val in value.items(): - if key.startswith('__'): - page[key[2:]] = val if url in ('/sitemap.xml',): continue if url in url_set: @@ -766,9 +763,9 @@ class Website(models.Model): for page in pages: record = {'loc': page['url'], 'id': page['id'], 'name': page['name']} if page.view_id and page.view_id.priority != 16: - record['__priority'] = min(round(page.view_id.priority / 32.0, 1), 1) + record['priority'] = min(round(page.view_id.priority / 32.0, 1), 1) if page['write_date']: - record['__lastmod'] = page['write_date'].date() + record['lastmod'] = page['write_date'].date() yield record def get_website_pages(self, domain=[], order='name', limit=None): diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js index 9d3d5621f65..1b6fa07a5c0 100644 --- a/addons/website/static/src/js/content/menu.js +++ b/addons/website/static/src/js/content/menu.js @@ -19,6 +19,7 @@ publicWidget.registry.affixMenu = publicWidget.Widget.extend({ this.$headerClone.insertAfter(this.$target); this.$headers = this.$target.add(this.$headerClone); this.$dropdowns = this.$headers.find('.dropdown'); + this.$dropdownMenus = this.$headers.find('.dropdown-menu'); this.$navbarCollapses = this.$headers.find('.navbar-collapse'); this._adaptDefaultOffset(); @@ -100,7 +101,7 @@ publicWidget.registry.affixMenu = publicWidget.Widget.extend({ this.$headerClone.toggleClass('affixed', wOffset > (hOffset + 300)); // Reset opened menus - this.$dropdowns.removeClass('show'); + this.$dropdowns.add(this.$dropdownMenus).removeClass('show'); this.$navbarCollapses.removeClass('show').attr('aria-expanded', false); }, }); diff --git a/addons/website/static/src/scss/website.scss b/addons/website/static/src/scss/website.scss index 34fcab86fc9..b5ce0126afb 100644 --- a/addons/website/static/src/scss/website.scss +++ b/addons/website/static/src/scss/website.scss @@ -914,8 +914,14 @@ table.table_desc tr td { background-color: inherit; } } - &.s_parallax_is_fixed > .s_parallax_bg { - background-attachment: fixed; + @include media-breakpoint-up(xl) { + // Fixed backgrounds are disabled when using a mobile/tablet device, + // which is not a big deal but, on some of them (iOS...), defining the + // background as fixed breaks the background-size/position props. + // So we enable this only for >= XL devices. + &.s_parallax_is_fixed > .s_parallax_bg { + background-attachment: fixed; + } } } diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index 258dd6d3fab..6cb36994dc5 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -364,7 +364,7 @@ <h5>Connect with us</h5> <ul class="list-unstyled"> <li><a href="/contactus">Contact us</a></li> - <li><i class="fa fa-phone"/> <span t-field="res_company.phone"/></li> + <li><i class="fa fa-phone"/> <span class="o_force_ltr" t-field="res_company.phone"/></li> <li><i class="fa fa-envelope"/> <span t-field="res_company.email"/></li> </ul> <p> diff --git a/addons/website_blog/controllers/main.py b/addons/website_blog/controllers/main.py index 2547cf6abd7..c7549a1209a 100644 --- a/addons/website_blog/controllers/main.py +++ b/addons/website_blog/controllers/main.py @@ -67,6 +67,10 @@ class WebsiteBlog(http.Controller): active_tag_ids = tags and [unslug(tag)[1] for tag in tags.split(',')] or [] if active_tag_ids: + fixed_tag_slug = ",".join(slug(t) for t in request.env['blog.tag'].browse(active_tag_ids)) + if fixed_tag_slug != tags: + return request.redirect(request.httprequest.full_path.replace("/tag/%s/" % tags, "/tag/%s/" % fixed_tag_slug, 1), 301) + domain += [('tag_ids', 'in', active_tag_ids)] if request.env.user.has_group('website.group_website_designer'): @@ -214,14 +218,16 @@ class WebsiteBlog(http.Controller): blog_url = QueryURL('', ['blog', 'tag'], blog=blog_post.blog_id, tag=tag, date_begin=date_begin, date_end=date_end) if not blog_post.blog_id.id == blog.id: - return request.redirect("/blog/%s/post/%s" % (slug(blog_post.blog_id), slug(blog_post))) + return request.redirect("/blog/%s/post/%s" % (slug(blog_post.blog_id), slug(blog_post)), code=301) tags = request.env['blog.tag'].search([]) # Find next Post - all_post = BlogPost.search([('blog_id', '=', blog.id)]) + blog_post_domain = [('blog_id', '=', blog.id)] if not request.env.user.has_group('website.group_website_designer'): - all_post = all_post.filtered(lambda r: r.post_date <= fields.Datetime.now()) + blog_post_domain += [('post_date', '<=', fields.Datetime.now())] + + all_post = BlogPost.search(blog_post_domain) if blog_post not in all_post: return request.redirect("/blog/%s" % (slug(blog_post.blog_id))) diff --git a/addons/website_blog/static/src/js/website_blog.js b/addons/website_blog/static/src/js/website_blog.js index 3004b28523f..246151922d5 100644 --- a/addons/website_blog/static/src/js/website_blog.js +++ b/addons/website_blog/static/src/js/website_blog.js @@ -63,31 +63,34 @@ publicWidget.registry.websiteBlog = publicWidget.Widget.extend({ */ _onShareArticle: function (ev) { var url = ''; + var articleURL; ev.preventDefault(); var $element = $(ev.currentTarget); if ($element.is('*[class*="_complete"]')) { - var blogTitleComplete = $('#blog_post_name').html() || ''; + var blogTitleComplete = encodeURIComponent($('#blog_post_name').html() || ''); + articleURL = encodeURIComponent(window.location.href); if ($element.hasClass('o_twitter_complete')) { - url = 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=Amazing blog article : ' + blogTitleComplete + "! Check it live: " + window.location.href; + url = 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=Amazing blog article : ' + blogTitleComplete + "! Check it live: " + articleURL; } else if ($element.hasClass('o_facebook_complete')) { - url = 'https://www.facebook.com/sharer/sharer.php?u=' + window.location.href; + url = 'https://www.facebook.com/sharer/sharer.php?u=' + articleURL; } else if ($element.hasClass('o_linkedin_complete')) { - url = 'https://www.linkedin.com/shareArticle?mini=true&url=' + window.location.href + '&title=' + blogTitleComplete; + url = 'https://www.linkedin.com/shareArticle?mini=true&url=' + articleURL + '&title=' + blogTitleComplete; } else { - url = 'https://plus.google.com/share?url=' + window.location.href; + url = 'https://plus.google.com/share?url=' + articleURL; } } else { var blogPost = $element.parents('[name="blog_post"]'); - var blogPostTitle = blogPost.find('.o_blog_post_title').html() || ''; + var blogPostTitle = encodeURIComponent(blogPost.find('.o_blog_post_title').html() || ''); var blogArticleLink = blogPost.find('.o_blog_post_title').parent('a').attr('href'); + articleURL = encodeURIComponent(window.location.host + blogArticleLink); if ($element.hasClass('o_twitter')) { - url = 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=Amazing blog article : ' + blogPostTitle + "! " + window.location.host + blogArticleLink; + url = 'https://twitter.com/intent/tweet?tw_p=tweetbutton&text=Amazing blog article : ' + blogPostTitle + "! " + articleURL; } else if ($element.hasClass('o_facebook')) { - url = 'https://www.facebook.com/sharer/sharer.php?u=' + window.location.host + blogArticleLink; + url = 'https://www.facebook.com/sharer/sharer.php?u=' + articleURL; } else if ($element.hasClass('o_linkedin')) { - url = 'https://www.linkedin.com/shareArticle?mini=true&url=' + window.location.host + blogArticleLink + '&title=' + blogPostTitle; + url = 'https://www.linkedin.com/shareArticle?mini=true&url=' + articleURL + '&title=' + blogPostTitle; } else if ($element.hasClass('o_google')) { - url = 'https://plus.google.com/share?url=' + window.location.host + blogArticleLink; + url = 'https://plus.google.com/share?url=' + articleURL; } } window.open(url, '', 'menubar=no, width=500, height=400'); diff --git a/addons/website_blog/views/website_blog_templates.xml b/addons/website_blog/views/website_blog_templates.xml index dac8c3ea9ff..20659dbb592 100644 --- a/addons/website_blog/views/website_blog_templates.xml +++ b/addons/website_blog/views/website_blog_templates.xml @@ -293,7 +293,7 @@ list of filtered posts (by date or tag). <div t-if="len(blogs) > 1">in <a t-attf-href="#{blog_url(blog=blog_post.blog_id)}"><b t-field="blog.name"/></a></div> <div t-if="len(blog_post.tag_ids) > 0"># <t t-foreach="blog_post.tag_ids" t-as="one_tag"> - <a class="badge border mr-1" t-attf-href="#{blog_url(tag=one_tag.id, date_begin=False, date_end=False)}" t-esc="one_tag.name"/> + <a class="badge border mr-1" t-attf-href="#{blog_url(tag=slug(one_tag), date_begin=False, date_end=False)}" t-esc="one_tag.name"/> </t> </div> </div> @@ -571,7 +571,7 @@ list of filtered posts (by date or tag). <title t-esc="blog.name"/> <link t-att-href="'%s/blog/%s' % (base_url ,blog.id)"/> <id t-esc="'%s/blog/%s' % (base_url, blog.id)"/> - <updated t-esc="str((posts[0] if posts else blog).post_date).replace(' ', 'T') + 'Z'"/> + <updated t-esc="str(posts[0].post_date).replace(' ', 'T') + 'Z' if posts else ''"/> <entry t-foreach="posts" t-as="post"> <title t-esc="post.name"/> <link t-att-href="'%s%s' % (base_url, post.website_url)"/> diff --git a/addons/website_event_sale/models/sale_order.py b/addons/website_event_sale/models/sale_order.py index a65b007a71f..d1c8183cef8 100644 --- a/addons/website_event_sale/models/sale_order.py +++ b/addons/website_event_sale/models/sale_order.py @@ -39,7 +39,10 @@ class SaleOrder(models.Model): values['product_id'] = ticket.product_id.id values['event_id'] = ticket.event_id.id values['event_ticket_id'] = ticket.id - values['price_unit'] = ticket.price_reduce + if order.pricelist_id.discount_policy == 'without_discount': + values['price_unit'] = ticket.price + else: + values['price_unit'] = ticket.price_reduce values['name'] = ticket.get_ticket_multiline_description_sale() # avoid writing related values that end up locking the product record diff --git a/addons/website_event_track/models/event_track.py b/addons/website_event_track/models/event_track.py index 1a2052c1eb1..195cad6870c 100644 --- a/addons/website_event_track/models/event_track.py +++ b/addons/website_event_track/models/event_track.py @@ -163,7 +163,7 @@ class Track(models.Model): def _message_get_suggested_recipients(self): recipients = super(Track, self)._message_get_suggested_recipients() for track in self: - if track.partner_email != track.partner_id.email: + if track.partner_email and track.partner_email != track.partner_id.email: track._message_add_suggested_recipient(recipients, email=track.partner_email, reason=_('Speaker Email')) return recipients diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index 6e1eab7c323..f14eb73e4e9 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -1625,7 +1625,7 @@ <div t-if="payment_tx_id.acquirer_id.provider == 'transfer' and order.reference" class="card-body"> <b>Communication: </b><span t-esc='order.reference'/> </div> - <div t-if="(payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.acquirer_id.provider == 'transfer') and (payment_tx_id.partner_id.country_id.code in ['BE', 'AT', 'DE', 'FI', 'NL']) and (payment_tx_id.currency_id.name == 'EUR')"> + <div t-if="(payment_tx_id.acquirer_id.qr_code == True) and (payment_tx_id.acquirer_id.provider == 'transfer') and (payment_tx_id.currency_id.name == 'EUR')"> <div class="card-body" t-if="payment_tx_id.acquirer_id.journal_id.bank_account_id.qr_code_valid"> <h3>Or scan me with your banking app.</h3> <img class="border border-dark rounded" t-att-src="payment_tx_id.acquirer_id.journal_id.bank_account_id.build_qr_code_url(order.amount_total,payment_tx_id.reference)"/> diff --git a/addons/website_sale_coupon/views/website_sale_templates.xml b/addons/website_sale_coupon/views/website_sale_templates.xml index ce3604e57a6..e1f91e25200 100644 --- a/addons/website_sale_coupon/views/website_sale_templates.xml +++ b/addons/website_sale_coupon/views/website_sale_templates.xml @@ -7,6 +7,16 @@ </xpath> </template> <template id="website_sale_coupon_cart_hide_qty" inherit_id="website_sale.cart_lines"> + <xpath expr="//td[hasclass('td-img')]/span" position="replace"> + <t t-if="line.is_reward_line and line.sudo().product_id.image_64"> + <span> + <img t-att-src="image_data_uri(line.sudo().product_id.image_64)" class="img img-fluid rounded" t-att-alt="line.name_short"/> + </span> + </t> + <t t-else=""> + <t>$0</t> + </t> + </xpath> <xpath expr="//td[hasclass('td-qty')]/div" position="attributes"> <attribute name="t-att-style">line.is_reward_line and "display:none;"</attribute> </xpath> @@ -24,6 +34,18 @@ <attribute name="t-if">not line.is_reward_line</attribute> </xpath> </template> +<template id="cart_popover" inherit_id="website_sale.cart_popover" name="Cart Popover"> + <xpath expr="//div[hasclass('cart_line')]/div[1]/span" position="replace"> + <t t-if="line.is_reward_line and line.sudo().product_id.image_64"> + <span> + <img t-att-src="image_data_uri(line.sudo().product_id.image_64)" class="img img-fluid rounded" t-att-alt="line.name_short"/> + </span> + </t> + <t t-else=""> + <t>$0</t> + </t> + </xpath> +</template> <template id="sale_coupon_result" inherit_id="website_sale.coupon_form"> <xpath expr="//form[@name='coupon_code']" position="after"> <t t-if="website_sale_order and website_sale_order.applied_coupon_ids"> 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 4210f3e5d85..6c316a70540 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 @@ -59,7 +59,7 @@ publicWidget.registry.websiteSaleDelivery = publicWidget.Widget.extend({ */ _handleCarrierUpdateResult: function (result) { this._handleCarrierUpdateResultBadge(result); - var $payButton = $('#o_payment_form_pay .monetary_field'); + var $payButton = $('#o_payment_form_pay'); var $amountDelivery = $('#order_delivery .monetary_field'); var $amountUntaxed = $('#order_total_untaxed .monetary_field'); var $amountTax = $('#order_total_taxes .monetary_field'); diff --git a/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js b/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js index 4ac148854f9..d99af8e56e1 100644 --- a/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js +++ b/addons/website_sale_delivery/static/tests/tours/website_free_delivery.js @@ -8,6 +8,7 @@ tour.register('check_free_delivery', { url: '/shop?search=office chair black', }, [ + // Part 1: Check free delivery { content: "select office chair black", trigger: '.oe_product_cart:first a:contains("Office Chair Black")', @@ -26,5 +27,21 @@ tour.register('check_free_delivery', { extra_trigger: '#delivery_carrier label:containsExact("Delivery Now Free Over 10")', trigger: "#delivery_carrier span:contains('0.0')" }, + // Part 2: check multiple delivery & price loaded asynchronously + { + content: "Ensure price was loaded asynchronously", + extra_trigger: '#delivery_carrier input[name="delivery_type"]:checked', + trigger: '#delivery_method .o_delivery_carrier_select:contains("20.0"):contains("The Poste")', + run: function () {}, // it's a check + }, + { + content: "Click on Pay Now", + trigger: 'button[id="o_payment_form_pay"]:visible:not(:disabled)', + }, + { + content: "Confirmation page should be shown", + trigger: '#oe_structure_website_sale_confirmation_1', + run: function () {}, // it's a check + } ]); }); diff --git a/addons/website_sale_delivery/tests/test_ui.py b/addons/website_sale_delivery/tests/test_ui.py index 34dcf086a48..6b1a2232f4a 100644 --- a/addons/website_sale_delivery/tests/test_ui.py +++ b/addons/website_sale_delivery/tests/test_ui.py @@ -12,4 +12,12 @@ class TestUi(odoo.tests.HttpCase): 'free_over': True, 'amount': 10, }) + self.env.ref("delivery.delivery_carrier").write({ + 'sequence': 9999, # ensure last to load price async + 'website_published': True, + }) + # Ensure "Wire Transfer" is the default acquirer. + # Acquirers are sorted by state, showing `test` acquirers first (don't ask why). + self.env.ref("payment.payment_acquirer_transfer").write({"state": "test"}) + self.start_tour("/", 'check_free_delivery', login="admin") diff --git a/addons/website_sale_digital/controllers/main.py b/addons/website_sale_digital/controllers/main.py index 717a96bb445..1184655e263 100644 --- a/addons/website_sale_digital/controllers/main.py +++ b/addons/website_sale_digital/controllers/main.py @@ -3,6 +3,8 @@ import base64 import io +import os +import mimetypes from werkzeug.utils import redirect from odoo import http @@ -71,7 +73,7 @@ class WebsiteSaleDigital(CustomerPortal): # Check if this is a valid attachment id attachment = request.env['ir.attachment'].sudo().search_read( [('id', '=', int(attachment_id))], - ["name", "datas", "file_type", "res_model", "res_id", "type", "url"] + ["name", "datas", "mimetype", "res_model", "res_id", "type", "url"] ) if attachment: @@ -105,6 +107,11 @@ class WebsiteSaleDigital(CustomerPortal): return request.not_found() elif attachment["datas"]: data = io.BytesIO(base64.standard_b64decode(attachment["datas"])) - return http.send_file(data, filename=attachment['name'], as_attachment=True) + # we follow what is done in ir_http's binary_content for the extension management + extension = os.path.splitext(attachment["name"] or '')[1] + extension = extension if extension else mimetypes.guess_extension(attachment["mimetype"] or '') + filename = attachment['name'] + filename = filename if os.path.splitext(filename)[1] else filename + extension + return http.send_file(data, filename=filename, as_attachment=True) else: return request.not_found() diff --git a/addons/website_sale_slides/controllers/i18n/so.po b/addons/website_sale_slides/controllers/i18n/so.po new file mode 100644 index 00000000000..3c154e53a86 --- /dev/null +++ b/addons/website_sale_slides/controllers/i18n/so.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: Somali (https://www.transifex.com/odoo/teams/41243/so/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: so\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\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/website_sale_stock/models/product_template.py b/addons/website_sale_stock/models/product_template.py index 0b16fbad1c2..0562e43ac61 100644 --- a/addons/website_sale_stock/models/product_template.py +++ b/addons/website_sale_stock/models/product_template.py @@ -16,10 +16,10 @@ class ProductTemplate(models.Model): available_threshold = fields.Float(string='Availability Threshold', default=5.0) custom_message = fields.Text(string='Custom Message', default='', translate=True) - def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, reference_product=False, only_template=False): + def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False): combination_info = super(ProductTemplate, self)._get_combination_info( combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist, - parent_combination=reference_product, only_template=only_template) + parent_combination=parent_combination, only_template=only_template) if not self.env.context.get('website_sale_stock_get_quantity'): return combination_info diff --git a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js index 250e78e3421..05ce9b73a7a 100644 --- a/addons/website_slides/static/src/js/slides_course_fullscreen_player.js +++ b/addons/website_slides/static/src/js/slides_course_fullscreen_player.js @@ -51,23 +51,21 @@ odoo.define('website_slides.fullscreen', function (require) { }, _loadYoutubeAPI: function () { var self = this; - var def = new Promise(function (resolve) { - self.resolveLoadYoutube = resolve; + var prom = new Promise(function (resolve, reject) { + if ($(document).find('script[src="' + self.youtubeUrl + '"]').length === 0) { + var $youtubeElement = $('<script/>', {src: self.youtubeUrl}); + $(document.head).append($youtubeElement); + + // function called when the Youtube asset is loaded + // see https://developers.google.com/youtube/iframe_api_reference#Requirements + onYouTubeIframeAPIReady = function () { + resolve(); + }; + } else { + resolve(); + } }); - if ($(document).find('script[src="' + this.youtubeUrl + '"]').length === 0) { - var $youtubeElement = $('<script/>', {src: this.youtubeUrl}); - $(document.head).append($youtubeElement); - - // function called when the Youtube asset is loaded - // see https://developers.google.com/youtube/iframe_api_reference#Requirements - onYouTubeIframeAPIReady = function () { - self.resolveLoadYoutube(); - }; - - } else { - self.resolveLoadYoutube(); - } - return def; + return prom; }, /** * Links the youtube api to the iframe present in the template |
