diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-09-11 09:39:33 +0000 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-09-11 09:39:33 +0000 |
| commit | 51354fadb0a1bf5fa797c2539e703cf97d768691 (patch) | |
| tree | 223535044b0df6473cd343079167c53c0f3673d0 /addons | |
| parent | da6f24bb4cea8b14eacc3040a06c5e451511240f (diff) | |
| parent | 50e571acf7c9696954d604de9b4501716fa26646 (diff) | |
[MERGE] forward port branch saas-12.3 up to 50e571acf7c
closes odoo/odoo#36491
Signed-off-by: Christophe Simonis <chs@odoo.com>
Diffstat (limited to 'addons')
165 files changed, 2408 insertions, 1763 deletions
diff --git a/addons/account/models/account.py b/addons/account/models/account.py index f55eb751b20..85896ad39d1 100644 --- a/addons/account/models/account.py +++ b/addons/account/models/account.py @@ -954,7 +954,7 @@ class AccountJournal(models.Model): @api.depends('company_id') def _belong_to_company(self): for journal in self: - journal.belong_to_company = (journal.company_id.id == self.env.company.id) + journal.belongs_to_company = (journal.company_id.id == self.env.company.id) @api.multi def _search_company_journals(self, operator, value): @@ -1361,20 +1361,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 @@ -1393,6 +1400,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 @@ -1402,11 +1412,12 @@ class AccountTax(models.Model): tax_amount = tax.with_context(force_price_include=False)._compute_amount( 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 @@ -1417,18 +1428,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, @@ -1445,16 +1462,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 de134656e7e..2b50e0ed318 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -1334,6 +1334,24 @@ class AccountMove(models.Model): raise UserError(message) return True + def _check_tax_lock_date(self): + 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) + ) + @api.multi def _check_move_consistency(self): for move in self: @@ -1342,6 +1360,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() # ------------------------------------------------------------------------- @@ -1971,6 +1990,7 @@ class AccountMove(models.Model): @api.multi def button_cancel(self): + self._check_tax_lock_date() AccountMoveLine = self.env['account.move.line'] excluded_move_ids = [] @@ -2033,6 +2053,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'): @@ -2715,7 +2736,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 @@ -2787,24 +2808,6 @@ class AccountMoveLine(models.Model): if control_type_failed or control_account_failed: raise UserError(_('You cannot use this general account in this journal, check the tab \'Entry Controls\' on the related journal.')) - @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)) - @api.multi 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""" @@ -2817,7 +2820,10 @@ class AccountMoveLine(models.Model): 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 # ------------------------------------------------------------------------- @@ -2976,7 +2982,6 @@ class AccountMoveLine(models.Model): @api.multi 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: @@ -3010,6 +3015,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: diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py index d04a4b05520..280869974e9 100644 --- a/addons/account/models/account_payment.py +++ b/addons/account/models/account_payment.py @@ -452,7 +452,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 94e7407521c..6c7b28d89c7 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -361,7 +361,7 @@ class AccountReconcileModel(models.Model): query += ' AND st_line.name NOT ILIKE %s' params += ['%%%s%%' % rule.match_label_param] elif rule.match_label == 'match_regex': - query += ' AND st_line.name ~ %s' + query += ' AND st_line.name ~* %s' params += [rule.match_label_param] # Filter on partners. @@ -621,8 +621,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 @api.multi diff --git a/addons/account/models/reconciliation_widget.py b/addons/account/models/reconciliation_widget.py index 5e49e94e272..8e503f07b80 100644 --- a/addons/account/models/reconciliation_widget.py +++ b/addons/account/models/reconciliation_widget.py @@ -116,7 +116,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)] @@ -520,17 +521,22 @@ 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]) # Domain factorized for all reconciliation use cases 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/tests/__init__.py b/addons/account/tests/__init__.py index c2612d50551..ff751752fcf 100644 --- a/addons/account/tests/__init__.py +++ b/addons/account/tests/__init__.py @@ -19,3 +19,4 @@ 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 diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py index 98f0f70a6e5..b8e8abb4001 100644 --- a/addons/account/tests/test_account_move_entry.py +++ b/addons/account/tests/test_account_move_entry.py @@ -69,33 +69,26 @@ 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.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}), @@ -103,7 +96,7 @@ class TestAccountMove(InvoiceTestCommon): ], }) - with self.assertRaises(ValidationError): + with self.assertRaises(UserError): self.test_move.unlink() self.cr.execute('ROLLBACK TO SAVEPOINT test_misc_tax_lock_date_1') 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 641ff761fa8..c8ffdb57365 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) @@ -1168,6 +1170,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 46dbaceca05..a588fccd42c 100644 --- a/addons/account/tests/test_reconciliation_widget.py +++ b/addons/account/tests/test_reconciliation_widget.py @@ -53,3 +53,80 @@ 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' + ) + mv_lines_ids = [l['id'] for l in mv_lines_rec] + + self.assertTrue(receivable1.id in mv_lines_ids) + self.assertTrue(receivable2.id in 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' + ) + mv_lines_ids = [l['id'] for l in mv_lines_rec] + + self.assertFalse(receivable1.id in mv_lines_ids) + self.assertTrue(receivable2.id in 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/report_invoice.xml b/addons/account/views/report_invoice.xml index 4a31cff037c..943f0d0edb9 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -176,7 +176,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 98bb3c25366..3f56070c2bc 100644 --- a/addons/account_check_printing/models/account_payment.py +++ b/addons/account_check_printing/models/account_payment.py @@ -104,6 +104,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 82a97e89edc..78429cd7bf5 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 0b559fb8360..1a57f797973 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} @@ -39,6 +41,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 04012e07138..dae5b383ca7 100644 --- a/addons/base_import/models/base_import.py +++ b/addons/base_import/models/base_import.py @@ -448,7 +448,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 d32b9178818..bd0b7b97a3d 100644 --- a/addons/calendar/models/calendar.py +++ b/addons/calendar/models/calendar.py @@ -1068,11 +1068,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) @@ -1420,7 +1422,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) @api.multi def action_detach_recurring_event(self): diff --git a/addons/calendar/tests/test_calendar.py b/addons/calendar/tests/test_calendar.py index 1d409d48dd7..d58a79e4908 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 dfd6f7a8e49..3e46daa8d1e 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/crm_iap_lead_website/models/crm_reveal_rule.py b/addons/crm_iap_lead_website/models/crm_reveal_rule.py index b1d6fd23cfb..ea541e0ce3d 100644 --- a/addons/crm_iap_lead_website/models/crm_reveal_rule.py +++ b/addons/crm_iap_lead_website/models/crm_reveal_rule.py @@ -7,7 +7,8 @@ import logging import re from dateutil.relativedelta import relativedelta -from odoo import api, fields, models, tools, _, _geoip_resolver +import odoo +from odoo import api, fields, models, tools, _ from odoo.addons.iap import jsonrpc from odoo.addons.crm.models import crm_stage from odoo.exceptions import ValidationError @@ -75,7 +76,7 @@ class CRMRevealRule(models.Model): @api.model def _assert_geoip(self): - if not _geoip_resolver: + if not odoo._geoip_resolver: message = _('Lead Generation requires a GeoIP resolver which could not be found on your system. Please consult https://pypi.org/project/GeoIP/.') self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), diff --git a/addons/delivery/models/sale_order.py b/addons/delivery/models/sale_order.py index 4310985c54a..43de9e86c03 100644 --- a/addons/delivery/models/sale_order.py +++ b/addons/delivery/models/sale_order.py @@ -49,6 +49,7 @@ class SaleOrder(models.Model): self._remove_delivery_line() for order in self: + order.carrier_id = carrier.id order._create_delivery_line(carrier, amount, price_unit_in_description=self.carrier_id.invoice_policy == 'real') return True diff --git a/addons/delivery/wizard/choose_delivery_carrier.py b/addons/delivery/wizard/choose_delivery_carrier.py index c7bac396c61..9953d15912e 100644 --- a/addons/delivery/wizard/choose_delivery_carrier.py +++ b/addons/delivery/wizard/choose_delivery_carrier.py @@ -74,6 +74,5 @@ class ChooseDeliveryCarrier(models.TransientModel): } def button_confirm(self): - self.order_id.carrier_id = self.carrier_id self.order_id.delivery_message = self.delivery_message self.order_id.set_delivery_line(self.carrier_id, self.delivery_price) diff --git a/addons/gamification/models/challenge.py b/addons/gamification/models/challenge.py index 5e2515e797c..519a86d5b2f 100644 --- a/addons/gamification/models/challenge.py +++ b/addons/gamification/models/challenge.py @@ -383,9 +383,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 @@ -743,7 +743,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 c5a280f13f2..736ed2da71b 100644 --- a/addons/gamification/models/goal.py +++ b/addons/gamification/models/goal.py @@ -196,7 +196,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 a5f9f4fe2ee..7621bcbb6ad 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 9af36db1948..cd17738869d 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 d2d68f018b0..8d456be3e0f 100644 --- a/addons/hr_expense/models/hr_expense.py +++ b/addons/hr_expense/models/hr_expense.py @@ -30,14 +30,19 @@ class HrExpense(models.Model): @api.model def _get_employee_id_domain(self): - res= [('id', '=', 0)] # Nothing accepted by domain, by default if self.user_has_groups('hr_expense.group_hr_expense_user') or self.user_has_groups('account.group_account_user'): res = [] # 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)] + 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), + ] elif self.env.user.employee_ids: employee = self.env.user.employee_ids[0] res = [('id', '=', employee.id)] diff --git a/addons/hr_expense/tests/test_expenses.py b/addons/hr_expense/tests/test_expenses.py index ab31f29c804..3d76478c83c 100644 --- a/addons/hr_expense/tests/test_expenses.py +++ b/addons/hr_expense/tests/test_expenses.py @@ -187,6 +187,70 @@ 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_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 9a98f40dc59..bf07f7926b1 100644 --- a/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py +++ b/addons/hr_expense/wizard/hr_expense_sheet_register_payment.py @@ -109,7 +109,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 d954e2e7dd9..c855e9bf6a0 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 @@ -286,12 +291,17 @@ class HolidaysRequest(models.Model): return domain = [('calendar_id', '=', self.employee_id.resource_calendar_id.id or self.env.company.resource_calendar_id.id)] - 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') # 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 5384d477bc4..d076fba8e71 100644 --- a/addons/hr_recruitment/views/hr_recruitment_views.xml +++ b/addons/hr_recruitment/views/hr_recruitment_views.xml @@ -286,6 +286,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 5703563e95d..1dc620edd49 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 086f02daa32..c6b59683316 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 02af23c0f7c..662dae71a6b 100644 --- a/addons/l10n_ch/models/account_invoice.py +++ b/addons/l10n_ch/models/account_invoice.py @@ -191,3 +191,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 bafdf1cb464..581274ac404 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.")) @api.multi @@ -43,7 +43,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 @@ -81,14 +81,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 c112839f15b..cb134570086 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"/> </record> diff --git a/addons/l10n_ma/data/l10n_ma_chart_data.xml b/addons/l10n_ma/data/l10n_ma_chart_data.xml index d72021c2ae4..cec91faca6f 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> @@ -1515,7 +1517,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"> @@ -1761,7 +1764,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> @@ -2065,6 +2068,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 2f6e3731faa..51ddb70c94c 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 + @api.multi def process_translations(self, langs, in_field, in_ids, out_ids): """ diff --git a/addons/l10n_uk/__manifest__.py b/addons/l10n_uk/__manifest__.py index 55168a9887c..fdd0323faf4 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_controller.js b/addons/lunch/static/src/js/lunch_kanban_controller.js index f1acf81b301..c7e49ca38b6 100644 --- a/addons/lunch/static/src/js/lunch_kanban_controller.js +++ b/addons/lunch/static/src/js/lunch_kanban_controller.js @@ -117,9 +117,11 @@ var LunchKanbanController = KanbanController.extend({ * @private */ _updateSearchPanel: function () { - var locationId = this.model.getCurrentLocationId(); - var domain = this.controlPanelDomain.concat([['is_available_at', 'in', [locationId]]]); - return this._searchPanel.update({searchDomain: domain}); + var self = this; + this.model.getLocationDomain().then(function (locationDomain) { + var domain = self.controlPanelDomain.concat(locationDomain); + return self._searchPanel.update({searchDomain: domain}); + }); }, //-------------------------------------------------------------------------- diff --git a/addons/lunch/static/src/js/lunch_kanban_model.js b/addons/lunch/static/src/js/lunch_kanban_model.js index c2386c7ace8..d94812a6c3c 100644 --- a/addons/lunch/static/src/js/lunch_kanban_model.js +++ b/addons/lunch/static/src/js/lunch_kanban_model.js @@ -12,6 +12,7 @@ var LunchKanbanModel = KanbanModel.extend({ init: function () { this.locationId = false; this.userId = false; + this._promInitLocation = null; this._super.apply(this, arguments); }, @@ -26,15 +27,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); }); @@ -42,7 +51,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); @@ -53,19 +62,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', @@ -74,18 +99,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..6be5e7f2c7b 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) { + 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); + }); + }, }); 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 8bc4879f9e5..8f64e7b0481 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 39b7c53ddf5..0e786eb4c4c 100644 --- a/addons/mail/controllers/main.py +++ b/addons/mail/controllers/main.py @@ -144,7 +144,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 511bc6348c8..2b9d64b2597 100644 --- a/addons/mail/static/src/js/models/threads/searchable_thread.js +++ b/addons/mail/static/src/js/models/threads/searchable_thread.js @@ -148,7 +148,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 89e5f220ea2..8adf2113b47 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/discuss_mobile_tests.js b/addons/mail/static/tests/discuss_mobile_tests.js index 84fd155522c..8f14f703b5d 100644 --- a/addons/mail/static/tests/discuss_mobile_tests.js +++ b/addons/mail/static/tests/discuss_mobile_tests.js @@ -13,6 +13,9 @@ QUnit.module('Discuss in mobile', { beforeEach: function () { this.services = mailTestUtils.getMailServices(); this.data = { + 'mail.channel': { + fields: {}, + }, 'mail.message': { fields: {}, }, @@ -80,27 +83,65 @@ QUnit.test('mobile basic rendering', async function (assert) { discuss.destroy(); }); -QUnit.test('on_{attach/detach}_callback', function (assert) { +QUnit.test('on_{attach/detach}_callback', async function (assert) { assert.expect(2); - var done = assert.async(); - createDiscuss({ + var discuss = await createDiscuss({ id: 1, context: {}, params: {}, data: this.data, services: this.services, - }).then(function (discuss) { - try { - discuss.on_attach_callback(); - assert.ok(true, 'should not crash on attach callback'); - discuss.on_detach_callback(); - assert.ok(true, 'should not crash on detach callback'); - } finally { - discuss.destroy(); - done(); - } }); + + discuss.on_attach_callback(); + assert.ok(true, 'should not crash on attach callback'); + discuss.on_detach_callback(); + assert.ok(true, 'should not crash on detach callback'); + discuss.destroy(); +}); + +QUnit.test('extended composer in mass mailing channel', async function (assert) { + assert.expect(4); + + this.data.initMessaging = { + channel_slots: { + channel_channel: [{ + id: 1, + channel_type: "channel", + name: "General", + mass_mailing: true, + }], + }, + }; + + var discuss = await createDiscuss({ + id: 1, + context: {}, + params: {}, + data: this.data, + services: this.services, + }); + + await testUtils.dom.click(discuss.$('.o_mail_mobile_tab[data-type="multi_user_channel"]')); + await testUtils.dom.click(discuss.$('.o_mail_preview[data-preview-id="1"]')); + assert.containsOnce( + $, + '.o_thread_window', + "should display a chat window"); + assert.strictEqual( + $('.o_thread_window').data('thread-id'), + 1, + "chat window should be from channel General"); + assert.containsOnce( + $('.o_thread_window'), + '.o_thread_composer', + "chat window should have a composer"); + assert.ok( + $('.o_thread_window .o_thread_composer').hasClass('o_thread_composer_extended'), + "chat window composer should be extended composer"); + + discuss.destroy(); }); }); 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/maintenance/views/maintenance_views.xml b/addons/maintenance/views/maintenance_views.xml index 14f17c71c3e..f652e0b6575 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"> @@ -427,8 +427,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 93db2240207..76e547c6916 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -338,6 +338,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.multi @api.depends('workorder_ids.state', 'move_finished_ids', 'move_finished_ids.quantity_done', 'is_locked') @@ -619,6 +620,8 @@ class MrpProduction(models.Model): @api.multi 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/res_config_settings.py b/addons/mrp/models/res_config_settings.py index 087d8050b40..41cf3015089 100644 --- a/addons/mrp/models/res_config_settings.py +++ b/addons/mrp/models/res_config_settings.py @@ -15,7 +15,6 @@ class ResConfigSettings(models.TransientModel): module_mrp_plm = fields.Boolean("Product Lifecycle Management (PLM)") module_mrp_workorder = fields.Boolean("Work Orders") module_quality_control = fields.Boolean("Quality") - module_mrp_subcontracting = fields.Boolean("Subcontracting") group_mrp_routings = fields.Boolean("MRP Work Orders", implied_group='mrp.group_mrp_routings') diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index f2058420815..a65cf3ca9fb 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -233,6 +233,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 @@ -266,4 +271,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/mrp/views/res_config_settings_views.xml b/addons/mrp/views/res_config_settings_views.xml index a40a1d150ee..96378ad693b 100644 --- a/addons/mrp/views/res_config_settings_views.xml +++ b/addons/mrp/views/res_config_settings_views.xml @@ -44,17 +44,6 @@ </div> </div> </div> - <div class="col-lg-6 col-12 o_setting_box"> - <div class="o_setting_left_pane"> - <field name="module_mrp_subcontracting"/> - </div> - <div class="o_setting_right_pane"> - <label for="module_mrp_subcontracting"/> - <div class="text-muted"> - Subcontract the production of some products - </div> - </div> - </div> </div> <h2>Products</h2> <div class="row mt16 o_settings_container"> diff --git a/addons/mrp_subcontracting/__init__.py b/addons/mrp_subcontracting/__init__.py deleted file mode 100644 index be29e5c5628..00000000000 --- a/addons/mrp_subcontracting/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import models -from odoo import api, SUPERUSER_ID - -def _create_subcontracting_rules(cr, registry): - """ This hook is used to add the default subcontracting rules on every warehouse/company. It is - necessary if the mrp_subcontracting module is installed after some warehouses - were already created. - """ - env = api.Environment(cr, SUPERUSER_ID, {}) - warehouse_ids = env['stock.warehouse'].search(['|', ('subcontracting_mto_pull_id', '=', False), ('subcontracting_pull_id', '=', False)]) - for warehouse_id in warehouse_ids: - warehouse_id.write({'subcontracting_to_resupply': warehouse_id.subcontracting_to_resupply}) - company_ids = env['res.company'].search([('subcontracting_location_id', '!=', False)]) - for company_id in company_ids: - wh = env['stock.warehouse'].search([('company_id', '=', company_id.id), ('active', '=', True)], limit=1) - production_location = wh._get_production_location() - rsoo_rule = env['stock.rule'].search([ - ('location_src_id', '=', company_id.subcontracting_location_id.id), - ('location_id', '=', production_location.id), - ('picking_type_id', '=', wh.manu_type_id.id) - ]) - if not rsoo_rule: - company_id._create_resupply_subcontractor_rules() diff --git a/addons/mrp_subcontracting/__manifest__.py b/addons/mrp_subcontracting/__manifest__.py deleted file mode 100644 index d6fbd927bdb..00000000000 --- a/addons/mrp_subcontracting/__manifest__.py +++ /dev/null @@ -1,19 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -{ - 'name': "mrp_subcontracting", - 'version': '0.1', - 'summary': "Subcontract Productions", - 'description': "", - 'website': 'https://www.odoo.com/page/manufacturing', - 'category': 'Manufacturing/Manufacturing', - 'depends': ['mrp', 'contacts'], - 'data': [ - 'data/mrp_subcontracting_data.xml', - 'views/stock_warehouse_views.xml', - 'views/stock_picking_views.xml', - 'views/mrp_bom_views.xml', - ], - 'post_init_hook': '_create_subcontracting_rules', -} diff --git a/addons/mrp_subcontracting/data/mrp_subcontracting_data.xml b/addons/mrp_subcontracting/data/mrp_subcontracting_data.xml deleted file mode 100644 index 3966da5b9f2..00000000000 --- a/addons/mrp_subcontracting/data/mrp_subcontracting_data.xml +++ /dev/null @@ -1,12 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - <data noupdate="1"> - <record id="route_resupply_subcontractor_mto" model='stock.location.route'> - <field name="name">Resupply Subcontractor on Order</field> - <field name="company_id"></field> - <field name="sequence">5</field> - </record> - <function model="res.company" name="create_missing_subcontracting_location" /> - </data> -</odoo> - diff --git a/addons/mrp_subcontracting/models/__init__.py b/addons/mrp_subcontracting/models/__init__.py deleted file mode 100644 index 98238bd9a1b..00000000000 --- a/addons/mrp_subcontracting/models/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import mrp_bom -from . import res_company -from . import res_partner -from . import stock_move -from . import stock_picking -from . import stock_warehouse - diff --git a/addons/mrp_subcontracting/models/mrp_bom.py b/addons/mrp_subcontracting/models/mrp_bom.py deleted file mode 100644 index 8f0d87d0b73..00000000000 --- a/addons/mrp_subcontracting/models/mrp_bom.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import fields, models - - -class MrpBom(models.Model): - _inherit = 'mrp.bom' - - type = fields.Selection(selection_add=[('subcontract', 'Subcontracting')]) - subcontractor_ids = fields.One2many('res.partner', 'bom_id', domain=[('type', '=', 'subcontractor')], string='Subcontractors') - - def _bom_subcontract_find(self, product_tmpl=None, product=None, picking_type=None, company_id=False, bom_type='subcontract', subcontractor=False): - domain = self._bom_find_domain(product_tmpl=product_tmpl, product=product, picking_type=picking_type, company_id=company_id, bom_type=bom_type) - if subcontractor: - domain += ['|', ('subcontractor_ids', 'in', subcontractor.id), ('subcontractor_ids', '=', False)] - else: - domain += [('subcontractor_ids', '=', False)] - return self.search(domain, order='subcontractor_ids, sequence, product_id', limit=1) - diff --git a/addons/mrp_subcontracting/models/res_company.py b/addons/mrp_subcontracting/models/res_company.py deleted file mode 100644 index 93687fa4d27..00000000000 --- a/addons/mrp_subcontracting/models/res_company.py +++ /dev/null @@ -1,47 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ - - -class ResCompany(models.Model): - _inherit = 'res.company' - - subcontracting_location_id = fields.Many2one('stock.location') - - def _create_per_company_locations(self): - super(ResCompany, self)._create_per_company_locations() - self._create_subcontracting_location() - - def _create_subcontracting_location(self): - for company in self: - parent_location = self.env.ref('stock.stock_location_locations_partner', raise_if_not_found=False) - subcontracting_location = self.env['stock.location'].create({ - 'name': _('%s: Subcontracting Location') % company.name, - 'usage': 'internal', - 'location_id': parent_location.id, - 'company_id': company.id, - }) - company.write({'subcontracting_location_id': subcontracting_location.id}) - - @api.model - def create_missing_subcontracting_location(self): - company_without_subcontracting_loc = self.env['res.company'].search( - [('subcontracting_location_id', '=', False)]) - for company in company_without_subcontracting_loc: - company._create_subcontracting_location() - - def _create_resupply_subcontractor_rules(self): - self.ensure_one() - wh = self.env['stock.warehouse'].search([('company_id', '=', self.id), ('active', '=', True)], limit=1) - subcontracting_location = self.subcontracting_location_id - production_location = wh._get_production_location() - resupply_route = wh._find_global_route('mrp_subcontracting.route_resupply_subcontractor_mto', _('Resupply Subcontractor on Order')) - resupply_route.rule_ids = [(0, 0, { - 'name': '%s → %s' % (subcontracting_location.name, production_location.name), - 'action': 'pull', - 'picking_type_id': wh.subcontracting_type_id, - 'location_src_id': subcontracting_location, - 'location_id': production_location, - })], - diff --git a/addons/mrp_subcontracting/models/res_partner.py b/addons/mrp_subcontracting/models/res_partner.py deleted file mode 100644 index 925296fee0c..00000000000 --- a/addons/mrp_subcontracting/models/res_partner.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ - - -class ResPartner(models.Model): - _inherit = 'res.partner' - - type = fields.Selection(selection_add=[('subcontractor', 'Subcontractor')]) - bom_id = fields.Many2one('mrp.bom', domain=[('type', '=', 'subcontract')]) - - def _get_name(self): - name = super(ResPartner, self)._get_name() - if self.type == 'subcontractor': - subcontractor = _('Subcontractor') - if name.endswith(', '): - name = "%s%s" % (name, subcontractor) - else: - name = '%s, %s' % (name, subcontractor) - return name - - @api.onchange('type') - def _onchange_partner_type(self): - if self.type == 'subcontractor': - company = self.company_id or self.parent_id.company_id - subcontracting_location = company.subcontracting_location_id - self.update({ - 'property_stock_customer': subcontracting_location, - 'property_stock_supplier': subcontracting_location - }) - else: - self.update({ - 'property_stock_customer': self.env.ref('stock.stock_location_customers'), - 'property_stock_supplier': self.env.ref('stock.stock_location_suppliers') - }) - - @api.model_create_multi - def create(self, vals_list): - for vals in vals_list: - if vals.get('type') == 'subcontractor': - if not vals.get('property_stock_supplier') and not vals.get('property_stock_customer'): - company = vals.get('company_id') and self.env['res.company'].browse(vals['company_id']) or \ - self.browse(vals.get('parent_id')).company_id - subcontracting_location = company.subcontracting_location_id - vals['property_stock_supplier'] = subcontracting_location - vals['property_stock_customer'] = subcontracting_location - return super(ResPartner, self).create(vals_list) - - def write(self, vals): - if vals.get('type') == 'subcontractor': - subcontracting_location = self.env.company.subcontracting_location_id - if not vals.get('property_stock_supplier') and not vals.get('property_stock_customer'): - vals['property_stock_supplier'] = subcontracting_location.id - vals['property_stock_customer'] = subcontracting_location.id - return super(ResPartner, self).write(vals) diff --git a/addons/mrp_subcontracting/models/stock_move.py b/addons/mrp_subcontracting/models/stock_move.py deleted file mode 100644 index daf847891bf..00000000000 --- a/addons/mrp_subcontracting/models/stock_move.py +++ /dev/null @@ -1,41 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from collections import defaultdict - -from odoo import models, _ -from odoo.exceptions import UserError - - -class StockMove(models.Model): - _inherit = 'stock.move' - - def _get_subcontract_bom(self): - self.ensure_one() - bom = self.env['mrp.bom']._bom_subcontract_find( - product=self.product_id, - picking_type=self.picking_type_id, - company_id=self.company_id.id, - bom_type='subcontract', - subcontractor=self.picking_id.partner_id, - ) - return bom - - def _action_confirm(self, merge=True, merge_into=False): - res = super(StockMove, self)._action_confirm(merge=merge, merge_into=merge_into) - subcontract_details_per_picking = defaultdict(list) - for move in res: - if not move.picking_id: - continue - if not move.picking_id._is_subcontract(): - continue - bom = move._get_subcontract_bom() - if not bom: - error_message = _('Please define a BoM of type subcontracting for the product "%s". If you don\'t want to subcontract the product "%s", do not select a partner of type subcontractor.') - error_message += '\n\n' - error_message += _('If there is well a BoM of type subcontracting defined, check if you have set the correct subcontractors on it.') - raise UserError(error_message % (move.product_id.name, move.product_id.name)) - subcontract_details_per_picking[move.picking_id].append((move, bom)) - for picking, subcontract_details in subcontract_details_per_picking.items(): - picking._subcontracted_produce(subcontract_details) - return res diff --git a/addons/mrp_subcontracting/models/stock_picking.py b/addons/mrp_subcontracting/models/stock_picking.py deleted file mode 100644 index b3c93fb8b7e..00000000000 --- a/addons/mrp_subcontracting/models/stock_picking.py +++ /dev/null @@ -1,133 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models - - -class StockPicking(models.Model): - _inherit = 'stock.picking' - - display_action_record_components = fields.Boolean(compute='_compute_display_action_record_components') - display_view_subcontracted_move_lines = fields.Boolean(compute='_compute_display_view_subcontracted_move_lines') - - @api.depends('state') - def _compute_display_action_record_components(self): - for picking in self: - # Hide if not encoding state - if picking.state in ('draft', 'cancel', 'done'): - continue - if not picking._is_subcontract(): - continue - # Hide if no move is tracked - subcontracted_productions = picking._get_subcontracted_productions() - subcontracted_moves = subcontracted_productions.mapped('move_raw_ids') - subcontracted_moves |= subcontracted_productions.mapped('move_finished_ids') - if all(subcontracted_move.has_tracking == 'none' for subcontracted_move in subcontracted_moves): - continue - # Hide if the production is to close - if not subcontracted_productions.filtered(lambda mo: mo.state not in ('to_close', 'done')): - continue - picking.display_action_record_components = True - - @api.depends('state') - def _compute_display_view_subcontracted_move_lines(self): - for picking in self: - # Hide if not encoding state - if picking.state in ('draft', 'cancel'): - continue - if not picking._is_subcontract(): - continue - # Hide until state done if no move is tracked, if tracked until something was produced - subcontracted_productions = picking._get_subcontracted_productions() - subcontracted_moves = subcontracted_productions.mapped('move_raw_ids') - subcontracted_moves |= subcontracted_productions.mapped('move_finished_ids') - if all(subcontracted_move.has_tracking == 'none' for subcontracted_move in subcontracted_moves): - if picking.state != 'done': - continue - # Hide if nothing was produced - if all(subcontracted_move.quantity_done == 0 for subcontracted_move in subcontracted_moves): - continue - picking.display_view_subcontracted_move_lines = True - - # ------------------------------------------------------------------------- - # Action methods - # ------------------------------------------------------------------------- - def action_done(self): - for picking in self: - subcontracted_productions = picking._get_subcontracted_productions() - for subcontracted_production in subcontracted_productions: - subcontracted_production.button_mark_done() - return super(StockPicking, self).action_done() - - def action_view_subcontracted_move_lines(self): - """ Returns a list view with the move lines of the subcontracted products. To find them, we - look on the origin moves of the move lines of the picking if there is a manufacturing order. - """ - self.ensure_one() - subcontracted_productions = self._get_subcontracted_productions() - subcontracted_move_lines = self.env['stock.move.line'] - for subcontracted_production in subcontracted_productions: - subcontracted_move_lines |= subcontracted_production.move_raw_ids.mapped('move_line_ids') - subcontracted_move_lines |= subcontracted_production.move_finished_ids.mapped('move_line_ids') - action = self.env.ref('stock.stock_move_line_action').read()[0] - action['context'] = {} - action['domain'] = [('id', 'in', subcontracted_move_lines.ids)] - return action - - def action_record_components(self): - self.ensure_one() - subcontracted_productions = self._get_subcontracted_productions() - to_register = subcontracted_productions.filtered(lambda mo: mo.state not in ('to_close', 'done')) - if to_register: - mo = to_register[0] - action = self.env.ref('mrp.act_mrp_product_produce').read()[0] - action['context'] = dict(self.env.context, active_id=mo.id) - return action - - # ------------------------------------------------------------------------- - # Subcontract helpers - # ------------------------------------------------------------------------- - def _is_subcontract(self): - self.ensure_one() - if self.partner_id.type == 'subcontractor' and \ - self.picking_type_id.code == 'incoming': - return True - return False - - def _get_subcontracted_productions(self): - self.ensure_one() - return self.move_lines.mapped('move_orig_ids.production_id') - - def _prepare_subcontract_mo_vals(self, subcontract_move, bom): - subcontract_move.ensure_one() - product = subcontract_move.product_id - warehouse = subcontract_move.warehouse_id or self.picking_type_id.warehouse_id - vals = { - 'product_id': product.id, - 'product_uom_id': subcontract_move.product_uom.id, - 'bom_id': bom.id, - 'location_src_id': subcontract_move.picking_id.partner_id.property_stock_supplier.id, - 'location_dest_id': subcontract_move.location_id.id, - 'product_qty': subcontract_move.product_uom_qty, - 'picking_type_id': warehouse.subcontracting_type_id.id - } - return vals - - def _subcontracted_produce(self, subcontract_details): - self.ensure_one() - for move, bom in subcontract_details: - mo = self.env['mrp.production'].create(self._prepare_subcontract_mo_vals(move, bom)) - self.env['stock.move'].create(mo._get_moves_raw_values()) - mo.action_confirm() - - # Link the finished to the receipt move. - finished_move = mo.move_finished_ids.filtered(lambda m: m.product_id == move.product_id) - finished_move.write({'move_dest_ids': [(4, move.id, False)]}) - mo.action_assign() - - # Only skip the produce wizard if no moves is tracked - moves = mo.mapped('move_raw_ids') + mo.mapped('move_finished_ids') - if any(move.has_tracking != 'none' for move in moves): - continue - for m in moves: - m.quantity_done = m.product_uom_qty diff --git a/addons/mrp_subcontracting/models/stock_warehouse.py b/addons/mrp_subcontracting/models/stock_warehouse.py deleted file mode 100644 index a9d0cd1713b..00000000000 --- a/addons/mrp_subcontracting/models/stock_warehouse.py +++ /dev/null @@ -1,139 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ - - -class StockWarehouse(models.Model): - _inherit = 'stock.warehouse' - - subcontracting_to_resupply = fields.Boolean( - 'Resupply Subcontractors', default=True, - help="Resupply subcontractors with components") - - subcontracting_mto_pull_id = fields.Many2one( - 'stock.rule', 'Subcontracting MTO Rule') - subcontracting_pull_id = fields.Many2one( - 'stock.rule', 'Subcontracting MTS Rule' - ) - - subcontracting_route_id = fields.Many2one('stock.location.route', 'Resupply Subcontractor', ondelete='restrict') - - subcontracting_type_id = fields.Many2one( - 'stock.picking.type', 'Subcontracting Operation Type', - domain=[('code', '=', 'mrp_operation')]) - - def get_rules_dict(self): - result = super(StockWarehouse, self).get_rules_dict() - subcontract_location_id = self._get_subcontracting_location() - for warehouse in self: - result[warehouse.id].update({ - 'subcontract': [ - self.Routing(warehouse.lot_stock_id, subcontract_location_id, warehouse.out_type_id, 'pull'), - ] - }) - return result - - def _get_routes_values(self): - routes = super(StockWarehouse, self)._get_routes_values() - routes.update({ - 'subcontracting_route_id': { - 'routing_key': 'subcontract', - 'depends': ['subcontracting_to_resupply'], - 'route_create_values': { - 'product_categ_selectable': False, - 'warehouse_selectable': True, - 'product_selectable': False, - 'company_id': self.company_id.id, - 'sequence': 10, - 'name': self._format_routename(name=_('Resupply Subcontractor')) - }, - 'route_update_values': { - 'active': self.subcontracting_to_resupply, - }, - 'rules_values': { - 'active': self.subcontracting_to_resupply, - } - } - }) - return routes - - def _get_global_route_rules_values(self): - rules = super(StockWarehouse, self)._get_global_route_rules_values() - subcontract_location_id = self._get_subcontracting_location() - production_location_id = self._get_production_location() - rules.update({ - 'subcontracting_mto_pull_id': { - 'depends': ['subcontracting_to_resupply'], - 'create_values': { - 'procure_method': 'make_to_order', - 'company_id': self.company_id.id, - 'action': 'pull', - 'auto': 'manual', - 'route_id': self._find_global_route('stock.route_warehouse0_mto', _('Make To Order')).id, - 'name': self._format_rulename(self.lot_stock_id, subcontract_location_id, 'MTO'), - 'location_id': subcontract_location_id.id, - 'location_src_id': self.lot_stock_id.id, - 'picking_type_id': self.out_type_id.id - }, - 'update_values': { - 'active': self.subcontracting_to_resupply - } - }, - 'subcontracting_pull_id': { - 'depends': ['subcontracting_to_resupply'], - 'create_values': { - 'procure_method': 'make_to_order', - 'company_id': self.company_id.id, - 'action': 'pull', - 'auto': 'manual', - 'route_id': self._find_global_route('mrp_subcontracting.route_resupply_subcontractor_mto', - _('Resupply Subcontractor on Order')).id, - 'name': self._format_rulename(self.lot_stock_id, subcontract_location_id, False), - 'location_id': production_location_id.id, - 'location_src_id': subcontract_location_id.id, - 'picking_type_id': self.out_type_id.id - }, - 'update_values': { - 'active': self.subcontracting_to_resupply - } - }, - }) - return rules - - def _get_picking_type_create_values(self, max_sequence): - data, next_sequence = super(StockWarehouse, self)._get_picking_type_create_values(max_sequence) - data.update({ - 'subcontracting_type_id': { - 'name': _('Subcontracting'), - 'code': 'mrp_operation', - 'use_create_lots': True, - 'use_existing_lots': True, - 'sequence': next_sequence + 2 - }, - }) - return data, max_sequence + 4 - - def _get_sequence_values(self): - values = super(StockWarehouse, self)._get_sequence_values() - values.update({ - 'subcontracting_type_id': {'name': self.name + ' ' + _('Sequence subcontracting'), 'prefix': self.code + '/SBC/', 'padding': 5}, - }) - return values - - def _get_picking_type_update_values(self): - data = super(StockWarehouse, self)._get_picking_type_update_values() - subcontract_location_id = self._get_subcontracting_location() - production_location_id = self._get_production_location() - data.update({ - 'subcontracting_type_id': { - 'active': False, - 'default_location_src_id': subcontract_location_id.id, - 'default_location_dest_id': production_location_id.id, - }, - }) - return data - - def _get_subcontracting_location(self): - return self.company_id.subcontracting_location_id - diff --git a/addons/mrp_subcontracting/tests/__init__.py b/addons/mrp_subcontracting/tests/__init__.py deleted file mode 100644 index c8d5649e39d..00000000000 --- a/addons/mrp_subcontracting/tests/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import test_subcontracting - diff --git a/addons/mrp_subcontracting/tests/test_subcontracting.py b/addons/mrp_subcontracting/tests/test_subcontracting.py deleted file mode 100644 index 032f319284a..00000000000 --- a/addons/mrp_subcontracting/tests/test_subcontracting.py +++ /dev/null @@ -1,526 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo.tests import Form -from odoo.tests.common import SavepointCase, TransactionCase - -from odoo.tests import tagged - -@tagged('post_install', '-at_install') -class TestSubcontractingBasic(TransactionCase): - def test_partner_1(self): - """ Checks the `display_name` of subcontracted partners. """ - main_partner = self.env['res.partner'].create({'name': 'main_partner'}) - - # When a name is set to the subcontracted partner. - subcontractor_partner1 = self.env['res.partner'].create({ - 'name': 'subcontractor_partner', - 'type': 'subcontractor', - 'parent_id': main_partner.id, - }) - self.assertEqual(subcontractor_partner1.display_name, "main_partner, subcontractor_partner, Subcontractor") - - # When a name is not set to the subcontracted partner. - subcontractor_partner1 = self.env['res.partner'].create({ - 'name': '', - 'type': 'subcontractor', - 'parent_id': main_partner.id, - }) - self.assertEqual(subcontractor_partner1.display_name, "main_partner, Subcontractor") - - def test_partner_2(self): - """ Open an existing partner and add a contact of type subcontractor (simple form view). - Checks the customer and supplier locations are set to the subcontracting one onces saved. - """ - that_company = self.env['res.partner'].create({ - 'is_company': True, - 'name': "That Company", - }) - partner_form = Form(that_company) - with partner_form.child_ids.new() as partner: - partner.name = 'That Guy' - partner.type = 'subcontractor' - partner_form.save() - subcontractor = that_company.child_ids - self.assertEquals(subcontractor.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertEquals(subcontractor.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - - def test_partner_3(self): - """ Create a new partner, select a parent company, and choose type subcontractor (debug - active). Checks the onchange correctly set the customer and supplier locations to the - subcontracting ones. Change them to another one, save, checks they are rightly applied. - """ - # FIXME sle: this field should not be writeable without the debug mode but it is. ask xmo - group_no_one = self.env.ref('base.group_no_one') - self.env.user.write({'groups_id': [(3, group_no_one.id)]}) - partner_form = Form(self.env['res.partner']) - partner_form.name = 'subcontractor partner' - self.assertNotEqual(partner_form.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertNotEqual(partner_form.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - partner_form.type = 'subcontractor' - self.assertEqual(partner_form.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertEqual(partner_form.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - subcontracting_location2 = self.env.user.company_id.subcontracting_location_id.copy() - partner_form.property_stock_supplier = subcontracting_location2 - partner_form.property_stock_customer = subcontracting_location2 - partner = partner_form.save() - self.assertEqual(partner.property_stock_supplier, subcontracting_location2) - self.assertEqual(partner.property_stock_customer, subcontracting_location2) - - def test_partner_4(self): - """ Create a new partner, select a parent company, and choose type subcontractor (debug - not active). Checks the customer and supplier locations are set to the subcontracting one - onces saved. - """ - that_company = self.env['res.partner'].create({ - 'is_company': True, - 'name': "That Company", - }) - partner_form = Form(self.env['res.partner']) - partner_form.name = 'That Guy' - partner_form.parent_id = that_company - partner_form.type = 'subcontractor' - subcontractor = partner_form.save() - self.assertEquals(subcontractor.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertEquals(subcontractor.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - - def test_partner_5(self): - """ Create a new partner with a child partner of type contact, - then write the type 'subcontractor' on it - """ - that_company = self.env['res.partner'].create({ - 'is_company': True, - 'name': "That Company", - }) - partner_form = Form(that_company) - with partner_form.child_ids.new() as partner: - partner.name = 'That Guy' - partner.type = 'contact' - partner_form.save() - - # We write a new type on the created child partner - subcontractor = that_company.child_ids - self.assertEquals(len(that_company.child_ids), 1) - subcontractor.type = 'subcontractor' - - self.assertEquals(subcontractor.property_stock_supplier, self.env.user.company_id.subcontracting_location_id) - self.assertEquals(subcontractor.property_stock_customer, self.env.user.company_id.subcontracting_location_id) - - def test_subcontracting_location_1(self): - """ Checks the creation and presence of the subcontracting location. """ - self.assertTrue(self.env.user.company_id.subcontracting_location_id) - self.assertTrue(self.env.user.company_id.subcontracting_location_id.active) - company2 = self.env['res.company'].create({'name': 'Test Company'}) - self.assertTrue(company2.subcontracting_location_id) - - -class TestSubcontractingFlows(SavepointCase): - @classmethod - def setUpClass(cls): - super(TestSubcontractingFlows, cls).setUpClass() - # 1: Create a subcontracting partner - main_partner = cls.env['res.partner'].create({'name': 'main_partner'}) - cls.subcontractor_partner1 = cls.env['res.partner'].create({ - 'name': 'subcontractor_partner', - 'type': 'subcontractor', - 'parent_id': main_partner.id, - }) - - # 2. Create a BOM of subcontracting type - cls.comp1 = cls.env['product.product'].create({ - 'name': 'Component1', - 'type': 'product', - 'categ_id': cls.env.ref('product.product_category_all').id, - }) - cls.comp2 = cls.env['product.product'].create({ - 'name': 'Component2', - 'type': 'product', - 'categ_id': cls.env.ref('product.product_category_all').id, - }) - cls.finished = cls.env['product.product'].create({ - 'name': 'finished', - 'type': 'product', - 'categ_id': cls.env.ref('product.product_category_all').id, - }) - bom_form = Form(cls.env['mrp.bom']) - bom_form.type = 'subcontract' - bom_form.product_tmpl_id = cls.finished.product_tmpl_id - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = cls.comp1 - bom_line.product_qty = 1 - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = cls.comp2 - bom_line.product_qty = 1 - cls.bom = bom_form.save() - - # Create a BoM for cls.comp2 - cls.comp2comp = cls.env['product.product'].create({ - 'name': 'component for Component2', - 'type': 'product', - 'categ_id': cls.env.ref('product.product_category_all').id, - }) - bom_form = Form(cls.env['mrp.bom']) - bom_form.product_tmpl_id = cls.comp2.product_tmpl_id - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = cls.comp2comp - bom_line.product_qty = 1 - cls.comp2_bom = bom_form.save() - - def test_flow_1(self): - """ Don't tick any route on the components and trigger the creation of the subcontracting - manufacturing order through a receipt picking. Create a reordering rule in the - subcontracting locations for a component and run the scheduler to resupply. Checks if the - resupplying actually works - """ - # Create a receipt picking from the subcontractor - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt = picking_form.save() - picking_receipt.action_confirm() - - # Nothing should be tracked - self.assertFalse(picking_receipt.display_action_record_components) - self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) - - # Check the created manufacturing order - mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)]) - self.assertEqual(len(mo), 1) - self.assertEqual(len(mo.picking_ids), 0) - wh = picking_receipt.picking_type_id.warehouse_id - self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) - self.assertFalse(mo.picking_type_id.active) - - # Create a RR - pg1 = self.env['procurement.group'].create({}) - self.env['stock.warehouse.orderpoint'].create({ - 'name': 'xxx', - 'product_id': self.comp1.id, - 'product_min_qty': 0, - 'product_max_qty': 0, - 'location_id': self.env.user.company_id.subcontracting_location_id.id, - 'group_id': pg1.id, - }) - - # Run the scheduler and check the created picking - self.env['procurement.group'].run_scheduler() - picking = self.env['stock.picking'].search([('group_id', '=', pg1.id)]) - self.assertEqual(len(picking), 1) - self.assertEquals(picking.picking_type_id, wh.out_type_id) - picking_receipt.move_lines.quantity_done = 1 - picking_receipt.button_validate() - self.assertEquals(mo.state, 'done') - - # Now that the picking is done, the details stat button should be visible - self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) - - # Available quantities should be negative at the subcontracting location for each components - avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished, wh.lot_stock_id) - self.assertEquals(avail_qty_comp1, -1) - self.assertEquals(avail_qty_comp2, -1) - self.assertEquals(avail_qty_finished, 1) - - def test_flow_2(self): - """ Tick "Resupply Subcontractor on Order" on the components and trigger the creation of - the subcontracting manufacturing order through a receipt picking. Checks if the resupplying - actually works. - """ - # Tick "resupply subconractor on order" - resupply_sub_on_order_route = self.env['stock.location.route'].search([('name', '=', 'Resupply Subcontractor on Order')]) - (self.comp1 + self.comp2).write({'route_ids': [(4, resupply_sub_on_order_route.id, None)]}) - - # Create a receipt picking from the subcontractor - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt = picking_form.save() - picking_receipt.action_confirm() - - # Nothing should be tracked - self.assertFalse(picking_receipt.display_action_record_components) - self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) - - # Pickings should directly be created - mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)]) - self.assertEqual(len(mo.picking_ids), 1) - self.assertEquals(mo.state, 'to_close') - self.assertEqual(len(mo.picking_ids.move_lines), 2) - - picking = mo.picking_ids - wh = picking.picking_type_id.warehouse_id - - # The picking should be a delivery order - self.assertEquals(picking.picking_type_id, wh.out_type_id) - - self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) - self.assertFalse(mo.picking_type_id.active) - - # No manufacturing order for `self.comp2` - comp2mo = self.env['mrp.production'].search([('bom_id', '=', self.comp2_bom.id)]) - self.assertEqual(len(comp2mo), 0) - - picking_receipt.move_lines.quantity_done = 1 - picking_receipt.button_validate() - self.assertEquals(mo.state, 'done') - - # Now that the picking is done, the details stat button should be visible - self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) - - # Available quantities should be negative at the subcontracting location for each components - avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished, wh.lot_stock_id) - self.assertEquals(avail_qty_comp1, -1) - self.assertEquals(avail_qty_comp2, -1) - self.assertEquals(avail_qty_finished, 1) - - def test_flow_3(self): - """ Tick "Resupply Subcontractor on Order" and "MTO" on the components and trigger the - creation of the subcontracting manufacturing order through a receipt picking. Checks if the - resupplying actually works. One of the component has also "manufacture" set and a BOM - linked. Checks that an MO is created for this one. - """ - # Tick "resupply subconractor on order" - resupply_sub_on_order_route = self.env['stock.location.route'].search([('name', '=', 'Resupply Subcontractor on Order')]) - (self.comp1 + self.comp2).write({'route_ids': [(4, resupply_sub_on_order_route.id, None)]}) - - # Tick "manufacture" and MTO on self.comp2 - mto_route = self.env['stock.location.route'].search([('name', '=', 'Replenish on Order (MTO)')]) - manufacture_route = self.env['stock.location.route'].search([('name', '=', 'Manufacture')]) - self.comp2.write({'route_ids': [(4, manufacture_route.id, None)]}) - self.comp2.write({'route_ids': [(4, mto_route.id, None)]}) - - # Create a receipt picking from the subcontractor - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt = picking_form.save() - picking_receipt.action_confirm() - - # Nothing should be tracked - self.assertFalse(picking_receipt.display_action_record_components) - self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) - - # Pickings should directly be created - mo = self.env['mrp.production'].search([('bom_id', '=', self.bom.id)]) - self.assertEqual(len(mo.picking_ids), 1) - self.assertEquals(mo.state, 'to_close') - self.assertEqual(len(mo.picking_ids.move_lines), 2) - - # The picking should be a delivery order - wh = picking_receipt.picking_type_id.warehouse_id - self.assertEquals(mo.picking_ids.picking_type_id, wh.out_type_id) - - self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) - self.assertFalse(mo.picking_type_id.active) - - # As well as a manufacturing order for `self.comp2` - comp2mo = self.env['mrp.production'].search([('bom_id', '=', self.comp2_bom.id)]) - self.assertEqual(len(comp2mo), 1) - picking_receipt.move_lines.quantity_done = 1 - picking_receipt.button_validate() - self.assertEquals(mo.state, 'done') - - # Now that the picking is done, the details stat button should be visible - self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) - - # Available quantities should be negative at the subcontracting location for each components - avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished, wh.lot_stock_id) - self.assertEquals(avail_qty_comp1, -1) - self.assertEquals(avail_qty_comp2, -1) - self.assertEquals(avail_qty_finished, 1) - - def test_flow_4(self): - """ Check that the correct BoM is chosen accordingly to the partner - """ - # We create a second partner of type subcontractor - main_partner_2 = self.env['res.partner'].create({'name': 'main_partner'}) - subcontractor_partner2 = self.env['res.partner'].create({ - 'name': 'subcontractor_partner', - 'type': 'subcontractor', - 'parent_id': main_partner_2.id, - }) - - # We create a different BoM for the same product - comp3 = self.env['product.product'].create({ - 'name': 'Component1', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - }) - - bom_form = Form(self.env['mrp.bom']) - bom_form.type = 'subcontract' - bom_form.product_tmpl_id = self.finished.product_tmpl_id - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = self.comp1 - bom_line.product_qty = 1 - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = comp3 - bom_line.product_qty = 1 - bom2 = bom_form.save() - - # We assign the second BoM to the new partner - self.bom.write({'subcontractor_ids': [(4, self.subcontractor_partner1.id, None)]}) - bom2.write({'subcontractor_ids': [(4, subcontractor_partner2.id, None)]}) - - # Create a receipt picking from the subcontractor1 - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt1 = picking_form.save() - picking_receipt1.action_confirm() - - # Create a receipt picking from the subcontractor2 - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = subcontractor_partner2 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished - move.product_uom_qty = 1 - picking_receipt2 = picking_form.save() - picking_receipt2.action_confirm() - - mo_pick1 = picking_receipt1.move_lines.mapped('move_orig_ids.production_id') - mo_pick2 = picking_receipt2.move_lines.mapped('move_orig_ids.production_id') - self.assertEquals(len(mo_pick1), 1) - self.assertEquals(len(mo_pick2), 1) - self.assertEquals(mo_pick1.bom_id, self.bom) - self.assertEquals(mo_pick2.bom_id, bom2) - - -class TestSubcontractingTracking(TransactionCase): - def setUp(self): - super(TestSubcontractingTracking, self).setUp() - # 1: Create a subcontracting partner - main_company_1 = self.env['res.partner'].create({'name': 'main_partner'}) - self.subcontractor_partner1 = self.env['res.partner'].create({ - 'name': 'Subcontractor 1', - 'type': 'subcontractor', - 'parent_id': main_company_1.id, - }) - - # 2. Create a BOM of subcontracting type - # 2.1. Comp1 has tracking by lot - self.comp1_sn = self.env['product.product'].create({ - 'name': 'Component1', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - 'tracking': 'serial' - }) - self.comp2 = self.env['product.product'].create({ - 'name': 'Component2', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - }) - - # 2.2. Finished prodcut has tracking by serial number - self.finished_lot = self.env['product.product'].create({ - 'name': 'finished', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - 'tracking': 'lot' - }) - bom_form = Form(self.env['mrp.bom']) - bom_form.type = 'subcontract' - bom_form.product_tmpl_id = self.finished_lot.product_tmpl_id - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = self.comp1_sn - bom_line.product_qty = 1 - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = self.comp2 - bom_line.product_qty = 1 - self.bom_tracked = bom_form.save() - - def test_flow_tracked_1(self): - """ This test mimics test_flow_1 but with a BoM that has tracking included in it. - """ - # Create a receipt picking from the subcontractor - picking_form = Form(self.env['stock.picking']) - picking_form.picking_type_id = self.env.ref('stock.picking_type_in') - picking_form.partner_id = self.subcontractor_partner1 - with picking_form.move_ids_without_package.new() as move: - move.product_id = self.finished_lot - move.product_uom_qty = 1 - picking_receipt = picking_form.save() - picking_receipt.action_confirm() - - # We should be able to call the 'record_components' button - self.assertTrue(picking_receipt.display_action_record_components) - self.assertFalse(picking_receipt.display_view_subcontracted_move_lines) - - # Check the created manufacturing order - mo = self.env['mrp.production'].search([('bom_id', '=', self.bom_tracked.id)]) - self.assertEqual(len(mo), 1) - self.assertEquals(mo.state, 'confirmed') - self.assertEqual(len(mo.picking_ids), 0) - wh = picking_receipt.picking_type_id.warehouse_id - self.assertEquals(mo.picking_type_id, wh.subcontracting_type_id) - self.assertFalse(mo.picking_type_id.active) - - # Create a RR - pg1 = self.env['procurement.group'].create({}) - self.env['stock.warehouse.orderpoint'].create({ - 'name': 'xxx', - 'product_id': self.comp1_sn.id, - 'product_min_qty': 0, - 'product_max_qty': 0, - 'location_id': self.env.user.company_id.subcontracting_location_id.id, - 'group_id': pg1.id, - }) - - # Run the scheduler and check the created picking - self.env['procurement.group'].run_scheduler() - picking = self.env['stock.picking'].search([('group_id', '=', pg1.id)]) - self.assertEqual(len(picking), 1) - self.assertEquals(picking.picking_type_id, wh.out_type_id) - - lot_id = self.env['stock.production.lot'].create({ - 'name': 'lot1', - 'product_id': self.finished_lot.id - }) - serial_id = self.env['stock.production.lot'].create({ - 'name': 'lot1', - 'product_id': self.comp1_sn.id - }) - produce_form = Form(self.env['mrp.product.produce'].with_context({ - 'active_id': mo.id, - 'active_ids': [mo.id], - })) - produce_form.finished_lot_id = lot_id - produce_form.raw_workorder_line_ids._records[0]['lot_id'] = serial_id.id - wiz_produce = produce_form.save() - wiz_produce.do_produce() - - # We should not be able to call the 'record_components' button - self.assertFalse(picking_receipt.display_action_record_components) - # We should see the move lines stat button - self.assertTrue(picking_receipt.display_view_subcontracted_move_lines) - - picking_receipt.move_lines.quantity_done = 1 - picking_receipt.move_lines.move_line_ids.lot_name = 'lot00001test' - picking_receipt.button_validate() - self.assertEquals(mo.state, 'done') - - # Available quantities should be negative at the subcontracting location for each components - avail_qty_comp1 = self.env['stock.quant']._get_available_quantity(self.comp1_sn, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_comp2 = self.env['stock.quant']._get_available_quantity(self.comp2, self.subcontractor_partner1.property_stock_supplier, allow_negative=True) - avail_qty_finished = self.env['stock.quant']._get_available_quantity(self.finished_lot, wh.lot_stock_id) - self.assertEquals(avail_qty_comp1, -1) - self.assertEquals(avail_qty_comp2, -1) - self.assertEquals(avail_qty_finished, 1) diff --git a/addons/mrp_subcontracting/views/mrp_bom_views.xml b/addons/mrp_subcontracting/views/mrp_bom_views.xml deleted file mode 100644 index d384f1ab4ea..00000000000 --- a/addons/mrp_subcontracting/views/mrp_bom_views.xml +++ /dev/null @@ -1,14 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - <record id="mrp_bom_form_view" model="ir.ui.view"> - <field name="name">mrp.bom.form.view</field> - <field name="model">mrp.bom</field> - <field name="inherit_id" ref="mrp.mrp_bom_form_view" /> - <field name="arch" type="xml"> - <xpath expr="//field[@name='type']" position="after"> - <field name="subcontractor_ids" widget="many2many_tags" context="{'default_type': 'subcontractor'}" attrs="{'invisible': [('type', '!=', 'subcontract')]}"/> - </xpath> - </field> - </record> -</odoo> - diff --git a/addons/mrp_subcontracting/views/stock_picking_views.xml b/addons/mrp_subcontracting/views/stock_picking_views.xml deleted file mode 100644 index 23145eeabbf..00000000000 --- a/addons/mrp_subcontracting/views/stock_picking_views.xml +++ /dev/null @@ -1,23 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - <record id="stock_picking_form_view" model="ir.ui.view"> - <field name="name">stock.picking.form.view</field> - <field name="model">stock.picking</field> - <field name="inherit_id" ref="stock.view_picking_form" /> - <field name="arch" type="xml"> - <xpath expr="//div[@name='button_box']" position="inside"> - <field name="display_view_subcontracted_move_lines" invisible="1" /> - <button string="Subcontracted Moves" type="object" - name="action_view_subcontracted_move_lines" - class="oe_stat_button" icon="fa-exchange" - attrs="{'invisible': [('display_view_subcontracted_move_lines', '=', False)]}" /> - </xpath> - - <xpath expr="//button[@name='button_validate'][hasclass('o_btn_validate')]" position="before"> - <field name="display_action_record_components" invisible="1" /> - <button name="action_record_components" class="oe_highlight" attrs="{'invisible': [('display_action_record_components', '=', False)]}" string="Record components" type="object"/> - </xpath> - </field> - </record> -</odoo> - diff --git a/addons/mrp_subcontracting/views/stock_warehouse_views.xml b/addons/mrp_subcontracting/views/stock_warehouse_views.xml deleted file mode 100644 index a2428f64246..00000000000 --- a/addons/mrp_subcontracting/views/stock_warehouse_views.xml +++ /dev/null @@ -1,13 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - <record id="view_warehouse_inherit_mrp_subcontracting" model="ir.ui.view"> - <field name="name">Stock Warehouse Inherit Subcontracting</field> - <field name="model">stock.warehouse</field> - <field name="inherit_id" ref="mrp.view_warehouse_inherit_mrp"/> - <field name="arch" type="xml"> - <xpath expr="//field[@name='manufacture_to_resupply']" position="before"> - <field name="subcontracting_to_resupply" /> - </xpath> - </field> - </record> -</odoo> diff --git a/addons/payment/views/payment_portal_templates.xml b/addons/payment/views/payment_portal_templates.xml index 9eea5737022..e477b10ba94 100644 --- a/addons/payment/views/payment_portal_templates.xml +++ b/addons/payment/views/payment_portal_templates.xml @@ -138,7 +138,7 @@ <div t-if="thanks_msg and 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_authorize/models/authorize_request.py b/addons/payment_authorize/models/authorize_request.py index e3e65648ee0..0ac1a90c071 100644 --- a/addons/payment_authorize/models/authorize_request.py +++ b/addons/payment_authorize/models/authorize_request.py @@ -238,7 +238,12 @@ class AuthorizeAPI(): etree.SubElement(root_profile, "customerProfileId").text = res['profile_id'] etree.SubElement(root_profile, "customerPaymentProfileId").text = res['payment_profile_id'] response_profile = self._authorize_request(root_profile) - res['name'] = response_profile.find('paymentProfile/payment/creditCard/cardNumber').text + card_number = response_profile.find('paymentProfile/payment/creditCard/cardNumber') + if card_number: + res['name'] = card_number.text + else: + res['name'] = response_profile.find('paymentProfile/payment/bankAccount/accountNumber').text + return res def credit(self, token, amount, transaction_id): diff --git a/addons/payment_sips/models/payment.py b/addons/payment_sips/models/payment.py index 7d46d56da2b..90f0d0aad8d 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 aa3b20bd92e..856ba551c45 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"/> <field name="sips_secret"/> - <field name="sips_test_url"/> - <field name="sips_prod_url"/> - <field name="sips_version"/> + <field name="sips_test_url" groups='base.group_no_one'/> + <field name="sips_prod_url" groups='base.group_no_one'/> + <field name="sips_version" 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 48a7ecbe0c6..37cf71324e1 100644 --- a/addons/point_of_sale/models/account_tax.py +++ b/addons/point_of_sale/models/account_tax.py @@ -25,3 +25,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 7a22aa34a9f..48add60f493 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -248,6 +248,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', @@ -828,7 +841,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; }); @@ -912,13 +925,14 @@ exports.PosModel = Backbone.Model.extend({ }); return server_ids; - }).catch(function(){ + }).catch(function(error){ var pending = self.db.get_orders().length; if (self.get('failed')) { self.set('synch', { state: 'error', pending: pending }); } else { self.set('synch', { state: 'disconnected', pending: pending }); } + return Promise.reject(error); }); }, @@ -981,6 +995,7 @@ exports.PosModel = Backbone.Model.extend({ self.set('failed',error); } console.error('Failed to send orders:', orders); + throw 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/product/models/product_pricelist.py b/addons/product/models/product_pricelist.py index 730d972405c..0797b05d4a5 100644 --- a/addons/product/models/product_pricelist.py +++ b/addons/product/models/product_pricelist.py @@ -139,6 +139,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: @@ -329,10 +330,10 @@ class Pricelist(models.Model): return res[partner_id].id 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 4d1db8a9a4f..5867cce9092 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -413,13 +413,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 40d95171449..0959bfb242b 100644 --- a/addons/product/tests/test_variants.py +++ b/addons/product/tests/test_variants.py @@ -817,3 +817,25 @@ class TestVariantsArchive(common.TestProductCommon): "We should have re-activated one variant only.") 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]) diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index fabc0dc4196..336ab7b94f4 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -405,8 +405,6 @@ class PurchaseOrder(models.Model): 'default_type': 'in_invoice', '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/purchase_mrp_subcontracting/__init__.py b/addons/purchase_mrp_subcontracting/__init__.py deleted file mode 100644 index dc5e6b693d1..00000000000 --- a/addons/purchase_mrp_subcontracting/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from . import models diff --git a/addons/purchase_mrp_subcontracting/__manifest__.py b/addons/purchase_mrp_subcontracting/__manifest__.py deleted file mode 100644 index 5fca667e462..00000000000 --- a/addons/purchase_mrp_subcontracting/__manifest__.py +++ /dev/null @@ -1,18 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - - -{ - 'name': 'Purchase and Subcontracting Management', - 'version': '0.1', - 'category': 'Hidden', - 'description': """ - This bridge module allows to manage subcontracting with the purchase module. - """, - 'depends': ['purchase_stock', 'mrp_subcontracting'], - 'data': [ - 'views/supplier_info_views.xml', - ], - 'installable': True, - 'auto_install': True, -} diff --git a/addons/purchase_mrp_subcontracting/models/__init__.py b/addons/purchase_mrp_subcontracting/models/__init__.py deleted file mode 100644 index 8585b3cca70..00000000000 --- a/addons/purchase_mrp_subcontracting/models/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from . import product -from . import stock_picking diff --git a/addons/purchase_mrp_subcontracting/models/product.py b/addons/purchase_mrp_subcontracting/models/product.py deleted file mode 100644 index 18bd7a53bd5..00000000000 --- a/addons/purchase_mrp_subcontracting/models/product.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models - - -class SupplierInfo(models.Model): - _inherit = 'product.supplierinfo' - - is_subcontractor = fields.Boolean('Subcontracted', compute='_compute_is_subcontractor', help="Choose a vendor of type subcontractor if you want to subcontract the product") - - @api.depends('name') - def _compute_is_subcontractor(self): - for supplierinfo in self: - supplierinfo.is_subcontractor = True if supplierinfo.name.type == 'subcontractor' else False - diff --git a/addons/purchase_mrp_subcontracting/models/stock_picking.py b/addons/purchase_mrp_subcontracting/models/stock_picking.py deleted file mode 100644 index 57c4441b54e..00000000000 --- a/addons/purchase_mrp_subcontracting/models/stock_picking.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import api, fields, models, _ -from odoo.exceptions import UserError - - -class StockPicking(models.Model): - _inherit = 'stock.picking' - - def _prepare_subcontract_mo_vals(self, subcontract_move, bom): - vals = super(StockPicking, self)._prepare_subcontract_mo_vals(subcontract_move, bom) - if bom.product_tmpl_id.cost_method in ('fifo', 'average'): - vals = dict(vals, extra_cost=subcontract_move._get_price_unit()) - return vals - diff --git a/addons/purchase_mrp_subcontracting/tests/__init__.py b/addons/purchase_mrp_subcontracting/tests/__init__.py deleted file mode 100644 index 76fe74ec58b..00000000000 --- a/addons/purchase_mrp_subcontracting/tests/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# -*- coding: utf-8 -*- - -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from . import test_purchase_subcontracting - diff --git a/addons/purchase_mrp_subcontracting/tests/test_purchase_subcontracting.py b/addons/purchase_mrp_subcontracting/tests/test_purchase_subcontracting.py deleted file mode 100644 index eb1ce6efbf2..00000000000 --- a/addons/purchase_mrp_subcontracting/tests/test_purchase_subcontracting.py +++ /dev/null @@ -1,112 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -from datetime import datetime - -from odoo.tests import Form -from odoo.tests.common import TransactionCase -from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT - - -class TestPurchaseSubcontractingFlows(TransactionCase): - def setUp(self): - super(TestPurchaseSubcontractingFlows, self).setUp() - - # 1. Create a BOM of subcontracting type - self.comp1 = self.env['product.product'].create({ - 'name': 'Component1', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - }) - self.comp2 = self.env['product.product'].create({ - 'name': 'Component2', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - }) - self.finished_product = self.env['product.product'].create({ - 'name': 'finished', - 'type': 'product', - 'categ_id': self.env.ref('product.product_category_all').id, - }) - bom_form = Form(self.env['mrp.bom']) - bom_form.type = 'subcontract' - bom_form.product_tmpl_id = self.finished_product.product_tmpl_id - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = self.comp1 - bom_line.product_qty = 1 - with bom_form.bom_line_ids.new() as bom_line: - bom_line.product_id = self.comp2 - bom_line.product_qty = 1 - self.bom_finished_product = bom_form.save() - - # 2: Create a subcontracting partner - company_partner = self.env['res.partner'].create({'name': 'main_partner', 'is_company': True}) - self.subcontractor_partner1 = self.env['res.partner'].create({ - 'name': 'subcontractor_partner', - 'type': 'subcontractor', - 'parent_id': company_partner.id, - }) - - self.stock_location = self.env.ref('stock.stock_location_stock') - self.customer_location = self.env.ref('stock.stock_location_customers') - self.supplier_location = self.env.ref('stock.stock_location_suppliers') - self.uom_unit = self.env.ref('uom.product_uom_unit') - - def test_purchase_1(self): - self.env.ref('product.product_category_all').property_cost_method = 'fifo' - - # IN 10@10 comp1 10@20 comp2 - move1 = self.env['stock.move'].create({ - 'name': 'IN 10 units @ 10.00 per unit', - 'location_id': self.supplier_location.id, - 'location_dest_id': self.env.user.company_id.subcontracting_location_id.id, - 'product_id': self.comp1.id, - 'product_uom': self.uom_unit.id, - 'product_uom_qty': 10.0, - 'price_unit': 10.0, - }) - move1._action_confirm() - move1._action_assign() - move1.move_line_ids.qty_done = 10.0 - move1._action_done() - move1 = self.env['stock.move'].create({ - 'name': 'IN 10 units @ 20.00 per unit', - 'location_id': self.supplier_location.id, - 'location_dest_id': self.env.user.company_id.subcontracting_location_id.id, - 'product_id': self.comp2.id, - 'product_uom': self.uom_unit.id, - 'product_uom_qty': 10.0, - 'price_unit': 20.0, - }) - move1._action_confirm() - move1._action_assign() - move1.move_line_ids.qty_done = 10.0 - move1._action_done() - - po1 = self.env['purchase.order'].create({ - 'partner_id': self.subcontractor_partner1.id, - 'order_line': [ - (0, 0, { - 'name': self.finished_product.name, - 'product_id': self.finished_product.id, - 'product_qty': 1, - 'product_uom': self.finished_product.uom_po_id.id, - 'price_unit': 30.0, - 'date_planned': datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT), - }), - ], - }) - po1.button_confirm() - - picking1 = po1.picking_ids[0] - - # Check the created manufacturing order - mo = self.env['mrp.production'].search([('bom_id', '=', self.bom_finished_product.id)]) - self.assertEqual(len(mo), 1) - - move1 = picking1.move_lines[0] - move1.quantity_done = 1 - picking1.button_validate() - self.assertEqual(mo.move_finished_ids.stock_valuation_layer_ids.value, 60) - self.assertEqual(mo.move_finished_ids.product_id.value_svl, 60) - diff --git a/addons/purchase_mrp_subcontracting/views/supplier_info_views.xml b/addons/purchase_mrp_subcontracting/views/supplier_info_views.xml deleted file mode 100644 index 65a6ad3ac98..00000000000 --- a/addons/purchase_mrp_subcontracting/views/supplier_info_views.xml +++ /dev/null @@ -1,25 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<odoo> - <record id="product_supplierinfo_subcontractor_tree_view" model="ir.ui.view"> - <field name="name">product.supplierinfo.subcontractor.tree.view</field> - <field name="model">product.supplierinfo</field> - <field name="inherit_id" ref="product.product_supplierinfo_tree_view"/> - <field name="arch" type="xml"> - <xpath expr="//field[@name='name']" position="after"> - <field name="is_subcontractor" readonly="1"/> - </xpath> - </field> - </record> - - <record id="product_supplierinfo_subcontracting_form_view" model="ir.ui.view"> - <field name="name">product.supplierinfo.subcontracting.form.view</field> - <field name="model">product.supplierinfo</field> - <field name="inherit_id" ref="product.product_supplierinfo_form_view"/> - <field name="arch" type="xml"> - <xpath expr="//group[@name='vendor']" position="inside"> - <field name="is_subcontractor" readonly="1"/> - </xpath> - </field> - </record> -</odoo> - diff --git a/addons/sale_coupon/models/sale_order.py b/addons/sale_coupon/models/sale_order.py index 4bb7ee0217a..c609148d7a4 100644 --- a/addons/sale_coupon/models/sale_order.py +++ b/addons/sale_coupon/models/sale_order.py @@ -208,10 +208,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 90eb2c0bde1..43b8764b919 100644 --- a/addons/sale_coupon/views/sale_coupon_program_views.xml +++ b/addons/sale_coupon/views/sale_coupon_program_views.xml @@ -22,7 +22,7 @@ <group> <group name="conditions" string="Conditions"> <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"/> @@ -210,7 +210,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 9a5388ab51b..2790b1a2528 100644 --- a/addons/sale_purchase/models/sale_order.py +++ b/addons/sale_purchase/models/sale_order.py @@ -71,12 +71,12 @@ 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.multi @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 f7342a5fee8..1011ad3abb5 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 d2ca45f7fb4..a4f6141ac37 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -1140,7 +1140,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 8b62d829ce1..653f77eef16 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -493,7 +493,7 @@ class Picking(models.Model): # TDE FIXME: what ? # 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 db3ded04691..6263ae7e49d 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -34,7 +34,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) + ondelete='restrict', readonly=True, required=True, index=True) # so user can filter on template in webclient product_tmpl_id = fields.Many2one( 'product.template', string='Product Template', @@ -47,7 +47,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) + auto_join=True, ondelete='restrict', readonly=True, required=True, index=True) lot_id = fields.Many2one( 'stock.production.lot', 'Lot/Serial Number', ondelete='restrict', readonly=True) diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 343a8840200..5cc064ace4f 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -499,7 +499,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'), @@ -548,12 +548,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 93bd42c301a..4bf7658bcfd 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): @@ -261,3 +262,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 a0b99724aed..1659e303086 100644 --- a/addons/stock/tests/test_warehouse.py +++ b/addons/stock/tests/test_warehouse.py @@ -2,6 +2,7 @@ from odoo.addons.stock.tests.common2 import TestStockCommon from odoo.tests import Form +from odoo.tools import mute_logger class TestWarehouse(TestStockCommon): @@ -403,6 +404,20 @@ 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 + with mute_logger("odoo.models"): + wh.write({"whatever": "no one cares"}) + 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 fd524beaee0..5d2ff862293 100644 --- a/addons/stock_landed_costs/models/stock_landed_cost.py +++ b/addons/stock_landed_costs/models/stock_landed_cost.py @@ -134,13 +134,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 c0dc8ec65e5..be35395aab7 100644 --- a/addons/web/static/src/js/fields/relational_fields.js +++ b/addons/web/static/src/js/fields/relational_fields.js @@ -2503,7 +2503,7 @@ var FieldSelection = AbstractField.extend({ description: _lt("Selection"), template: 'FieldSelection', specialData: "_fetchSpecialRelation", - supportedFieldTypes: ['selection', 'many2one'], + supportedFieldTypes: ['selection'], events: _.extend({}, AbstractField.prototype.events, { 'change': '_onChange', }), @@ -2556,10 +2556,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; @@ -2598,9 +2602,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 6d1b31ea8ea..8fd365368d6 100644 --- a/addons/web/static/src/js/views/basic/basic_model.js +++ b/addons/web/static/src/js/views/basic/basic_model.js @@ -2001,9 +2001,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 5aef3d54bfa..6ffcb81b3f4 100644 --- a/addons/web/static/src/js/views/form/form_controller.js +++ b/addons/web/static/src/js/views/form/form_controller.js @@ -588,7 +588,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 0703d9713d5..4889a27f9e5 100644 --- a/addons/web/static/src/js/views/form/form_view.js +++ b/addons/web/static/src/js/views/form/form_view.js @@ -127,6 +127,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 4a91bd67f19..f0ac97538d0 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 b643b8355b6..753a1d80ea0 100644 --- a/addons/web/static/src/js/views/kanban/kanban_record.js +++ b/addons/web/static/src/js/views/kanban/kanban_record.js @@ -69,12 +69,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'); }, @@ -255,6 +257,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); @@ -341,6 +346,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 ffeef454716..b864063ee92 100644 --- a/addons/web/static/src/js/views/list/list_controller.js +++ b/addons/web/static/src/js/views/list/list_controller.js @@ -598,6 +598,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, @@ -679,6 +680,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 4e5deb56780..a593505e9c2 100644 --- a/addons/web/static/tests/fields/basic_fields_tests.js +++ b/addons/web/static/tests/fields/basic_fields_tests.js @@ -2579,8 +2579,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, @@ -2588,13 +2612,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 9c649cc0173..8b0c59fb6e6 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 33278a4e632..196ebcad122 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 ef890e92e90..a529a29cb95 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) { @@ -173,8 +174,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 39737e720a1..622a713b3e0 100644 --- a/addons/web/static/tests/views/form_tests.js +++ b/addons/web/static/tests/views/form_tests.js @@ -477,8 +477,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); @@ -4586,6 +4588,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, @@ -7901,6 +7932,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 ca969fb9ac9..a179c265ca1 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 5e2c4e0a8f6..113960768c0 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'); @@ -4493,6 +4494,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 2677fb13216..0cf3279aa54 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -2293,6 +2293,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', @@ -2306,6 +2307,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); @@ -3815,6 +3852,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/wysiwyg/plugin/text.js b/addons/web_editor/static/src/js/wysiwyg/plugin/text.js index 2b7d10d02fd..f770b8121bf 100644 --- a/addons/web_editor/static/src/js/wysiwyg/plugin/text.js +++ b/addons/web_editor/static/src/js/wysiwyg/plugin/text.js @@ -679,7 +679,12 @@ var TextPlugin = AbstractPlugin.extend({ */ _removeIllegalClipboardElements: function ($clipboardData) { var root = true; - $clipboardData = $clipboardData.not('meta').not('style').not('script'); + $clipboardData.each(function (i, node) { + var tag = node.tagName; + if (tag === 'meta' || tag === 'style' || tag === 'script') { + $(node).remove(); + } + }); var $badNodes = this._filterIllegalClipboardElements($clipboardData); do { diff --git a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js index f6eee549bfc..9515bf19ab6 100644 --- a/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js +++ b/addons/web_editor/static/src/js/wysiwyg_snippets/snippets.options.js @@ -776,7 +776,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 a36bc717c7c..3d66d2feb06 100644 --- a/addons/website/models/ir_ui_view.py +++ b/addons/website/models/ir_ui_view.py @@ -330,18 +330,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 2e10d7d1cc4..43095eec6d3 100644 --- a/addons/website/models/website.py +++ b/addons/website/models/website.py @@ -741,9 +741,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: @@ -767,9 +764,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 @api.multi diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js index 1a16b828d09..e26e2fc0bb3 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 1c9792b91ab..ef2edf98b35 100644 --- a/addons/website/static/src/scss/website.scss +++ b/addons/website/static/src/scss/website.scss @@ -908,8 +908,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 4dd19528c0d..ae76927b373 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -346,7 +346,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 1d09540e8cf..947ae83b06d 100644 --- a/addons/website_blog/controllers/main.py +++ b/addons/website_blog/controllers/main.py @@ -109,6 +109,9 @@ class WebsiteBlog(http.Controller): # retrocompatibility to accept tag as slug active_tag_ids = tag and [int(unslug(t)[1]) for t in tag.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 != tag: + return request.redirect(request.httprequest.full_path.replace("/tag/%s/" % tag, "/tag/%s/" % fixed_tag_slug, 1), 301) domain += [('tag_ids', 'in', active_tag_ids)] if blog: domain += [('blog_id', '=', blog.id)] @@ -230,14 +233,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/s_latest_posts_frontend.js b/addons/website_blog/static/src/js/s_latest_posts_frontend.js index 87f3ffc5f6f..8402f18fdd7 100644 --- a/addons/website_blog/static/src/js/s_latest_posts_frontend.js +++ b/addons/website_blog/static/src/js/s_latest_posts_frontend.js @@ -116,7 +116,7 @@ publicWidget.registry.js_get_posts = publicWidget.Widget.extend({ $progress.appendTo($loadingContainer); $post.appendTo(self.$target); - var m = $thumb.css('background-image').match(/url\(["']?(.+)["']?\)/); + var m = $thumb.css('background-image').match(/url\(["']?(.+?)["']?\)/); var bg = m ? m[1] : 'none'; var loaded = false; diff --git a/addons/website_blog/static/src/js/website_blog.js b/addons/website_blog/static/src/js/website_blog.js index f158c713af7..f523816995a 100644 --- a/addons/website_blog/static/src/js/website_blog.js +++ b/addons/website_blog/static/src/js/website_blog.js @@ -85,31 +85,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 d9a22c4b3f4..174195858d0 100644 --- a/addons/website_blog/views/website_blog_templates.xml +++ b/addons/website_blog/views/website_blog_templates.xml @@ -163,7 +163,7 @@ <div class="col-12 col-sm my-1 o_wblog_post_short_tag_section"> <span t-if="len(blog_post.tag_ids)" class="fa fa-tags" role="img" aria-label="Tags" title="Tags"/> <t t-foreach="blog_post.tag_ids" t-as="one_tag"> - <a class="badge badge-primary" t-attf-href="#{blog_url(tag=one_tag.id, date_begin=False, date_end=False)}" t-esc="one_tag.name"/> + <a class="badge badge-primary" t-attf-href="#{blog_url(tag=slug(one_tag), date_begin=False, date_end=False)}" t-esc="one_tag.name"/> </t> </div> <div class="col-sm-auto my-1"> @@ -259,7 +259,7 @@ <p class="post-meta text-muted text-center" t-if="len(blog_post.tag_ids)"> <span class="fa fa-tags" role="img" aria-label="Tags" title="Tags"/> <t t-foreach="blog_post.tag_ids" t-as="one_tag"> - <a class="badge badge-primary mr8" t-attf-href="#{blog_url(tag=one_tag.id)}" t-esc="one_tag.name"/> + <a class="badge badge-primary mr8" t-attf-href="#{blog_url(tag=slug(one_tag))}" t-esc="one_tag.name"/> </t> </p> <div t-if="'cover_full' in blog_post_cover_properties.get('resize_class', '')" id="blog_angle_down"> @@ -402,6 +402,7 @@ <t t-foreach="tags" t-as="tag"> <t t-if="tag.post_ids"> <a t-attf-href="#{blog_url(tag=tags_list(active_tag_ids, tag.id))}" + t-att-rel="'nofollow' if active_tag_ids else None" t-attf-class="badge #{tag.id in active_tag_ids and 'badge-primary' or 'badge-secondary'}"> <span t-field="tag.name"/> </a> @@ -453,10 +454,10 @@ <ul t-attf-class="blog_post_year collapse nav nav-pills flex-column #{not date and year_index == 0 and 'in'}" t-att-id="'b_' + year" role="menu"> <t t-foreach="nav_list[year]" t-as="months"> <li t-if="months['date_begin'] == date" class="nav-item"> - <a role="menuitem" t-ignore="True" t-attf-href="#{blog_url(date_begin=False, date_end=False)}" class="nav-link active"><t t-esc="months['month']"/><span class="float-right badge badge-pill" t-esc="months['post_date_count']"/></a> + <a role="menuitem" rel="nofollow" t-ignore="True" t-attf-href="#{blog_url(date_begin=False, date_end=False)}" class="nav-link active"><t t-esc="months['month']"/><span class="float-right badge badge-pill" t-esc="months['post_date_count']"/></a> </li> <li t-else="1" class="nav-item"> - <a role="menuitem" t-ignore="True" t-attf-href="#{blog_url(date_begin=months['date_begin'], date_end=months['date_end'])}" class="nav-link"><t t-esc="months['month']"/><span class="float-right badge badge-pill" t-esc="months['post_date_count']"/></a> + <a role="menuitem" rel="nofollow" t-ignore="True" t-attf-href="#{blog_url(date_begin=months['date_begin'], date_end=months['date_end'])}" class="nav-link"><t t-esc="months['month']"/><span class="float-right badge badge-pill" t-esc="months['post_date_count']"/></a> </li> </t> </ul> @@ -587,7 +588,7 @@ <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 cd25e6e1943..c13e8a4c23e 100644 --- a/addons/website_event_sale/models/sale_order.py +++ b/addons/website_event_sale/models/sale_order.py @@ -41,7 +41,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 deb31e167c2..03143b921bf 100644 --- a/addons/website_event_track/models/event_track.py +++ b/addons/website_event_track/models/event_track.py @@ -159,7 +159,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/models/website.py b/addons/website_sale/models/website.py index bb4daaa519d..b0fa9ef176c 100644 --- a/addons/website_sale/models/website.py +++ b/addons/website_sale/models/website.py @@ -216,7 +216,7 @@ class Website(models.Model): salesperson_id = affiliate_id if self.env['res.users'].sudo().browse(affiliate_id).exists() else request.website.salesperson_id.id addr = partner.address_get(['delivery']) if not request.website.is_public_user(): - last_sale_order = self.env['sale.order'].search([('partner_id', '=', partner.id)], limit=1, order="date_order desc, id desc") + last_sale_order = self.env['sale.order'].sudo().search([('partner_id', '=', partner.id)], limit=1, order="date_order desc, id desc") if last_sale_order: # first = me addr['delivery'] = last_sale_order.partner_shipping_id.id default_user_id = partner.parent_id.user_id.id or partner.user_id.id diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml index cebc6c5c897..6b22cb89ea2 100644 --- a/addons/website_sale/views/templates.xml +++ b/addons/website_sale/views/templates.xml @@ -1629,7 +1629,7 @@ <b>Communication: </b><span t-esc='order.reference'/> </div> </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_comparison/models/website_sale_comparison.py b/addons/website_sale_comparison/models/website_sale_comparison.py index 9c2fbca9082..24875d32ac1 100644 --- a/addons/website_sale_comparison/models/website_sale_comparison.py +++ b/addons/website_sale_comparison/models/website_sale_comparison.py @@ -9,7 +9,7 @@ class ProductAttributeCategory(models.Model): _description = "Product Attribute Category" _order = 'sequence' - name = fields.Char("Category Name", required=True) + name = fields.Char("Category Name", required=True, translate=True) sequence = fields.Integer("Sequence", default=10) diff --git a/addons/website_sale_coupon/views/website_sale_templates.xml b/addons/website_sale_coupon/views/website_sale_templates.xml index db123dcc504..7953bcf2109 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_small"> + <span> + <img t-att-src="image_data_uri(line.sudo().product_id.image_small)" 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_small"> + <span> + <img t-att-src="image_data_uri(line.sudo().product_id.image_small)" 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..50df06adf62 100644 --- a/addons/website_sale_delivery/tests/test_ui.py +++ b/addons/website_sale_delivery/tests/test_ui.py @@ -12,4 +12,8 @@ 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, + }) 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 6d056d1ed2e..51dc76db463 100644 --- a/addons/website_sale_stock/models/product_template.py +++ b/addons/website_sale_stock/models/product_template.py @@ -17,10 +17,10 @@ class ProductTemplate(models.Model): custom_message = fields.Text(string='Custom Message', default='', translate=True) @api.multi - 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 42fce775674..69f0944ff3f 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 @@ -47,21 +47,22 @@ odoo.define('website_slides.fullscreen', function (require) { }); }, _loadYoutubeAPI: function () { - var def = new Promise(function () {}); - 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 () { - def.resolve(); - }; + var self = this; + 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); - } else { - def.resolve(); - } - return def; + // function called when the Youtube asset is loaded + // see https://developers.google.com/youtube/iframe_api_reference#Requirements + onYouTubeIframeAPIReady = function () { + resolve(); + }; + } else { + resolve(); + } + }); + return prom; }, /** * Links the youtube api to the iframe present in the template |
