diff options
| author | Christophe Simonis <chs@odoo.com> | 2019-04-10 15:10:24 +0200 |
|---|---|---|
| committer | Christophe Simonis <chs@odoo.com> | 2019-04-10 15:10:24 +0200 |
| commit | e145f2b0c8831ec8756307b6b70c82f8dcdcab54 (patch) | |
| tree | 93406d01e313cfecdeac03132c1cccb55ff05088 /addons | |
| parent | 3f95b074e959f0f4ac93fbf4048c0c7276f2c17a (diff) | |
| parent | 2694174b410f14ca81f0681ff4ae6856ff459ced (diff) | |
[MERGE] forward port branch saas-12.2 up to 2694174b410
Diffstat (limited to 'addons')
56 files changed, 656 insertions, 107 deletions
diff --git a/addons/account/models/__init__.py b/addons/account/models/__init__.py index a25c1272e76..6f178fd2665 100644 --- a/addons/account/models/__init__.py +++ b/addons/account/models/__init__.py @@ -19,3 +19,4 @@ from . import account_cash_rounding from . import reconciliation_widget from . import account_incoterms from . import digest +from . import res_users diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py index 103d1421e20..f2a5e0d785b 100644 --- a/addons/account/models/account_invoice.py +++ b/addons/account/models/account_invoice.py @@ -103,6 +103,11 @@ class AccountInvoice(models.Model): if invoice_type == 'out_invoice' and self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms'): return self.env.user.company_id.invoice_terms + def _get_aml_for_amount_residual(self): + """ Get the aml to consider to compute the amount residual of invoices """ + self.ensure_one() + return self.sudo().move_id.line_ids.filtered(lambda l: l.account_id == self.account_id) + @api.one @api.depends( 'state', 'currency_id', 'invoice_line_ids.price_subtotal', @@ -112,14 +117,13 @@ class AccountInvoice(models.Model): residual = 0.0 residual_company_signed = 0.0 sign = self.type in ['in_refund', 'out_refund'] and -1 or 1 - for line in self.sudo().move_id.line_ids: - if line.account_id == self.account_id: - residual_company_signed += line.amount_residual - if line.currency_id == self.currency_id: - residual += line.amount_residual_currency if line.currency_id else line.amount_residual - else: - from_currency = line.currency_id or line.company_id.currency_id - residual += from_currency._convert(line.amount_residual, self.currency_id, line.company_id, line.date or fields.Date.today()) + for line in self._get_aml_for_amount_residual(): + residual_company_signed += line.amount_residual + if line.currency_id == self.currency_id: + residual += line.amount_residual_currency if line.currency_id else line.amount_residual + else: + from_currency = line.currency_id or line.company_id.currency_id + residual += from_currency._convert(line.amount_residual, self.currency_id, line.company_id, line.date or fields.Date.today()) self.residual_company_signed = abs(residual_company_signed) * sign self.residual_signed = abs(residual) * sign self.residual = abs(residual) @@ -1115,11 +1119,17 @@ class AccountInvoice(models.Model): return tax_grouped @api.multi + def _get_aml_for_register_payment(self): + """ Get the aml to consider to reconcile in register payment """ + self.ensure_one() + return self.move_id.line_ids.filtered(lambda r: not r.reconciled and r.account_id.internal_type in ('payable', 'receivable')) + + @api.multi def register_payment(self, payment_line, writeoff_acc_id=False, writeoff_journal_id=False): """ Reconcile payable/receivable lines from the invoice with payment_line """ line_to_reconcile = self.env['account.move.line'] for inv in self: - line_to_reconcile += inv.move_id.line_ids.filtered(lambda r: not r.reconciled and r.account_id.internal_type in ('payable', 'receivable')) + line_to_reconcile += inv._get_aml_for_register_payment() return (line_to_reconcile + payment_line).reconcile(writeoff_acc_id, writeoff_journal_id) @api.multi diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 7c9884fc69f..d634186ffde 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -817,7 +817,9 @@ class AccountMoveLine(models.Model): total_amount_currency = 0 maxdate = date.min to_balance = {} + cash_basis_partial = self.env['account.partial.reconcile'] for aml in amls: + cash_basis_partial |= aml.move_id.tax_cash_basis_rec_id total_debit += aml.debit total_credit += aml.credit maxdate = max(aml.date, maxdate) @@ -832,15 +834,28 @@ class AccountMoveLine(models.Model): to_balance[aml.currency_id] = [self.env['account.move.line'], 0] to_balance[aml.currency_id][0] += aml to_balance[aml.currency_id][1] += aml.amount_residual != 0 and aml.amount_residual or aml.amount_residual_currency + # Check if reconciliation is total # To check if reconciliation is total we have 3 differents use case: # 1) There are multiple currency different than company currency, in that case we check using debit-credit # 2) We only have one currency which is different than company currency, in that case we check using amount_currency # 3) We have only one currency and some entries that don't have a secundary currency, in that case we check debit-credit # or amount_currency. + # 4) Cash basis full reconciliation + # - either none of the moves are cash basis reconciled, and we proceed + # - or some moves are cash basis reconciled and we make sure they are all fully reconciled + digits_rounding_precision = amls[0].company_id.currency_id.rounding - if (currency and float_is_zero(total_amount_currency, precision_rounding=currency.rounding)) or \ - (multiple_currency and float_compare(total_debit, total_credit, precision_rounding=digits_rounding_precision) == 0): + if ( + ( + not cash_basis_partial or (cash_basis_partial and all([p >= 1.0 for p in amls._get_matched_percentage().values()])) + ) and + ( + currency and float_is_zero(total_amount_currency, precision_rounding=currency.rounding) or + multiple_currency and float_compare(total_debit, total_credit, precision_rounding=digits_rounding_precision) == 0 + ) + ): + exchange_move_id = False # Eventually create a journal entry to book the difference due to foreign currency's exchange rate that fluctuates if to_balance and any([not float_is_zero(residual, precision_rounding=digits_rounding_precision) for aml, residual in to_balance.values()]): @@ -934,7 +949,8 @@ class AccountMoveLine(models.Model): for after_rec_dict in cash_basis_subjected: new_rec = part_rec.create(after_rec_dict) - if cash_basis: + # if the pair belongs to move being reverted, do not create CABA entry + if cash_basis and not (new_rec.debit_move_id + new_rec.credit_move_id).mapped('move_id').mapped('reverse_entry_id'): new_rec.create_tax_cash_basis_entry(cash_basis_percentage_before_rec) self.recompute() diff --git a/addons/account/models/res_users.py b/addons/account/models/res_users.py new file mode 100644 index 00000000000..3e6fd163482 --- /dev/null +++ b/addons/account/models/res_users.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, _ +from odoo.exceptions import ValidationError + + +class Users(models.Model): + _inherit = "res.users" + + @api.multi + @api.constrains('groups_id') + def _check_one_user_type(self): + super(Users, self)._check_one_user_type() + for user in self: + if (user.user_has_groups('account.group_show_line_subtotals_tax_included') and + user.user_has_groups('account.group_show_line_subtotals_tax_excluded')): + raise ValidationError(_('A user cannot have both Tax B2B and Tax B2C')) diff --git a/addons/account/security/account_security.xml b/addons/account/security/account_security.xml index 5e5a63f120f..bad495b9290 100644 --- a/addons/account/security/account_security.xml +++ b/addons/account/security/account_security.xml @@ -25,18 +25,6 @@ <field name="category_id" ref="base.module_category_hidden"/> </record> - <record id="base.group_public" model="res.groups"> - <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> - </record> - - <record id="base.group_portal" model="res.groups"> - <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> - </record> - - <record id="base.group_user" model="res.groups"> - <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> - </record> - <record id="group_account_user" model="res.groups"> <field name="name">Show Full Accounting Features</field> <field name="category_id" ref="base.module_category_hidden"/> @@ -80,6 +68,16 @@ <data noupdate="1"> + <record id="base.group_public" model="res.groups"> + <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> + </record> + <record id="base.group_portal" model="res.groups"> + <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> + </record> + <record id="base.group_user" model="res.groups"> + <field name="implied_ids" eval="[(4, ref('account.group_show_line_subtotals_tax_excluded'))]"/> + </record> + <record id="base.default_user" model="res.users"> <field name="groups_id" eval="[(4,ref('account.group_account_manager'))]"/> </record> diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py index 49a9be865e2..0fc4c2bc7dd 100644 --- a/addons/account/tests/test_reconciliation.py +++ b/addons/account/tests/test_reconciliation.py @@ -6,7 +6,8 @@ import time import unittest -@tagged('post_install', '-at_install') +# TODO in master +# The name of this class should be TestReconciliationHelpers class TestReconciliation(AccountingTestCase): """Tests for reconciliation (account.tax) @@ -167,6 +168,10 @@ class TestReconciliation(AccountingTestCase): supplier_move_lines = bank_stmt.move_line_ids return customer_move_lines, supplier_move_lines + +@tagged('post_install', '-at_install') +class TestReconciliationExec(TestReconciliation): + def test_statement_usd_invoice_eur_transaction_eur(self): customer_move_lines, supplier_move_lines = self.make_customer_and_supplier_flows(self.currency_euro_id, 30, self.bank_journal_usd, 42, 30, self.currency_euro_id) self.assertRecordValues(customer_move_lines, [ @@ -1911,3 +1916,121 @@ class TestReconciliation(AccountingTestCase): (move_lines - base_amount_tax_lines) .filtered(lambda l: l.account_id == self.tax_final_account) .debit, 17094.66) + + def test_reconciliation_cash_basis_revert(self): + company = self.env.ref('base.main_company') + company.tax_cash_basis_journal_id = self.cash_basis_journal + tax_cash_basis10percent = self.tax_cash_basis.copy({'amount': 10}) + self.tax_waiting_account.reconcile = True + tax_waiting_account10 = self.tax_waiting_account.copy({ + 'name': 'TAX WAIT 10', + 'code': 'TWAIT1', + }) + + AccountMoveLine = self.env['account.move.line'].with_context(check_move_validity=False) + + # Purchase + purchase_move = self.env['account.move'].create({ + 'name': 'invoice', + 'journal_id': self.purchase_journal.id, + }) + + purchase_payable_line0 = AccountMoveLine.create({ + 'account_id': self.account_rsa.id, + 'credit': 175, + 'move_id': purchase_move.id, + }) + + AccountMoveLine.create({ + 'name': 'expenseTaxed 10%', + 'account_id': self.expense_account.id, + 'debit': 50, + 'move_id': purchase_move.id, + 'tax_ids': [(4, tax_cash_basis10percent.id, False)], + }) + tax_line0 = AccountMoveLine.create({ + 'name': 'TaxLine0', + 'account_id': tax_waiting_account10.id, + 'debit': 5, + 'move_id': purchase_move.id, + 'tax_line_id': tax_cash_basis10percent.id, + }) + AccountMoveLine.create({ + 'name': 'expenseTaxed 20%', + 'account_id': self.expense_account.id, + 'debit': 100, + 'move_id': purchase_move.id, + 'tax_ids': [(4, self.tax_cash_basis.id, False)], + }) + tax_line1 = AccountMoveLine.create({ + 'name': 'TaxLine1', + 'account_id': self.tax_waiting_account.id, + 'debit': 20, + 'move_id': purchase_move.id, + 'tax_line_id': self.tax_cash_basis.id, + }) + purchase_move.post() + + reverted = self.env['account.move'].browse(purchase_move.reverse_moves()) + self.assertTrue(reverted.exists()) + + for inv_line in [purchase_payable_line0, tax_line0, tax_line1]: + self.assertTrue(inv_line.full_reconcile_id.exists()) + reverted_expected = reverted.line_ids.filtered(lambda l: l.account_id == inv_line.account_id) + self.assertEqual(len(reverted_expected), 1) + self.assertEqual(reverted_expected.full_reconcile_id, inv_line.full_reconcile_id) + + def test_reconciliation_cash_basis_foreign_currency_low_values(self): + journal = self.env['account.journal'].create({ + 'name': 'Bank', 'type': 'bank', 'code': 'THE', + 'currency_id': self.currency_usd_id, + }) + usd = self.env['res.currency'].browse(self.currency_usd_id) + usd.rate_ids.unlink() + self.env['res.currency.rate'].create({ + 'name': time.strftime('%Y-01-01'), + 'rate': 1/17.0, + 'currency_id': self.currency_usd_id, + 'company_id': self.env.ref('base.main_company').id, + }) + invoice = self.create_invoice( + type='out_invoice', invoice_amount=50, + currency_id=self.currency_usd_id) + invoice.journal_id.update_posted = True + invoice.action_cancel() + invoice.state = 'draft' + invoice.invoice_line_ids.write({ + 'invoice_line_tax_ids': [(6, 0, [self.tax_cash_basis.id])]}) + invoice.compute_taxes() + invoice.action_invoice_open() + + self.assertTrue(invoice.currency_id != self.env.user.company_id.currency_id) + + # First Payment + payment0 = self.make_payment(invoice, journal, invoice.amount_total - 0.01) + self.assertEqual(invoice.residual, 0.01) + + tax_waiting_line = invoice.move_id.line_ids.filtered(lambda l: l.account_id == self.tax_waiting_account) + self.assertFalse(tax_waiting_line.reconciled) + + move_caba0 = tax_waiting_line.matched_debit_ids.debit_move_id.move_id + self.assertTrue(move_caba0.exists()) + self.assertEqual(move_caba0.journal_id, self.env.user.company_id.tax_cash_basis_journal_id) + + pay_receivable_line0 = payment0.move_line_ids.filtered(lambda l: l.account_id == self.account_rcv) + self.assertTrue(pay_receivable_line0.reconciled) + self.assertEqual(pay_receivable_line0.matched_debit_ids, move_caba0.tax_cash_basis_rec_id) + + # Second Payment + payment1 = self.make_payment(invoice, journal, 0.01) + self.assertEqual(invoice.residual, 0) + self.assertEqual(invoice.state, 'paid') + + self.assertTrue(tax_waiting_line.reconciled) + move_caba1 = tax_waiting_line.matched_debit_ids.mapped('debit_move_id').mapped('move_id').filtered(lambda m: m != move_caba0) + self.assertEqual(len(move_caba1.exists()), 1) + self.assertEqual(move_caba1.journal_id, self.env.user.company_id.tax_cash_basis_journal_id) + + pay_receivable_line1 = payment1.move_line_ids.filtered(lambda l: l.account_id == self.account_rcv) + self.assertTrue(pay_receivable_line1.reconciled) + self.assertEqual(pay_receivable_line1.matched_debit_ids, move_caba1.tax_cash_basis_rec_id) diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index 641596bd7b6..69956a6f349 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -36,6 +36,7 @@ <field name="invoice_id" attrs="{'invisible': [('invoice_id','=',False)]}"/> </group> <group string="Dates"> + <field name="date"/> <field name="date_maturity"/> </group> @@ -43,7 +44,7 @@ <field name="tax_line_id" readonly="1" attrs="{'invisible': [('tax_line_id','=',False)]}"/> <field name="tax_ids" widget="many2many_tags" readonly="1" attrs="{'invisible': [('tax_ids','=',[])]}"/> </group> - <group string="Matching" attrs="{'invisible':[('full_reconcile_id','=',False)]}"> + <group string="Matching" attrs="{'invisible':[('full_reconcile_id','=',False), ('matched_debit_ids', '=', []),('matched_credit_ids', '=', [])]}"> <label for="full_reconcile_id"/> <div> <field name="full_reconcile_id"/> diff --git a/addons/account/views/report_invoice.xml b/addons/account/views/report_invoice.xml index 043c5a3cc47..8c3da3ae101 100644 --- a/addons/account/views/report_invoice.xml +++ b/addons/account/views/report_invoice.xml @@ -51,12 +51,16 @@ <table class="table table-sm o_main_table" name="invoice_line_table"> <thead> <tr> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="6"/> <th class="text-left"><span>Description</span></th> <th class="d-none text-left"><span>Source Document</span></th> <th class="text-right"><span>Quantity</span></th> <th t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"><span>Unit Price</span></th> <th t-if="display_discount" t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"> <span>Disc.(%)</span> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="colspan+1"/> </th> <th t-attf-class="text-left {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}"><span>Taxes</span></th> <th class="text-right"> diff --git a/addons/account_payment/views/account_portal_templates.xml b/addons/account_payment/views/account_portal_templates.xml index e9d121e15e0..bdc9c9da488 100644 --- a/addons/account_payment/views/account_portal_templates.xml +++ b/addons/account_payment/views/account_portal_templates.xml @@ -48,7 +48,7 @@ <div t-if="pms or acquirers" id="payment_method" class="text-left col-md-13"> <t t-call="payment.payment_tokens_list"> <t t-set="mode" t-value="'payment'"/> - <t t-set="partner_id" t-value="invoice.partner_id.id"/> + <t t-set="partner_id" t-value="request.env.user.partner_id.id"/> <t t-set="success_url" t-value="invoice.get_portal_url()"/> <t t-set="error_url" t-value="invoice.get_portal_url()"/> <t t-set="access_token" t-value="access_token or ''"/> diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py index f8f01119e7d..c9ee67f2c7b 100644 --- a/addons/base_address_city/models/res_partner.py +++ b/addons/base_address_city/models/res_partner.py @@ -31,13 +31,13 @@ class Partner(models.Model): replacement_xml = """ <div> <field name="country_enforce_cities" invisible="1"/> - <field name='city' placeholder="%(placeholder)s" + <field name='city' placeholder="%(placeholder)s" class="o_address_city" attrs="{ 'invisible': [('country_enforce_cities', '=', True), ('city_id', '!=', False)], 'readonly': [('type', '=', 'contact')%(parent_condition)s] }" /> - <field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s" + <field name='city_id' placeholder="%(placeholder)s" string="%(placeholder)s" class="o_address_city" context="{'default_country_id': country_id, 'default_name': city, 'default_zipcode': zip, diff --git a/addons/board/static/src/scss/dashboard.scss b/addons/board/static/src/scss/dashboard.scss index d5d95fa9f59..518849cde0a 100644 --- a/addons/board/static/src/scss/dashboard.scss +++ b/addons/board/static/src/scss/dashboard.scss @@ -123,8 +123,8 @@ } // Override height of graph. min-height doesn't do the trick - .o_graph_controller { - height: 500px; + .o_graph_svg_container { + height: 250px; } } } diff --git a/addons/calendar/models/res_partner.py b/addons/calendar/models/res_partner.py index 403d70b18fc..261b955fc8f 100644 --- a/addons/calendar/models/res_partner.py +++ b/addons/calendar/models/res_partner.py @@ -11,7 +11,7 @@ from odoo.addons.calendar.models.calendar import get_real_ids class Partner(models.Model): _inherit = 'res.partner' - calendar_last_notif_ack = fields.Datetime('Last notification marked as read from base Calendar') + calendar_last_notif_ack = fields.Datetime('Last notification marked as read from base Calendar', default=fields.Datetime.now) @api.multi def get_attendee_detail(self, meeting_id): diff --git a/addons/delivery/models/product_template.py b/addons/delivery/models/product_template.py index 79465b50303..eceb08e58e9 100644 --- a/addons/delivery/models/product_template.py +++ b/addons/delivery/models/product_template.py @@ -7,4 +7,7 @@ from odoo import models, fields class ProductTemplate(models.Model): _inherit = 'product.template' - hs_code = fields.Char(string="HS Code", help="Standardized code for international shipping and goods declaration") + hs_code = fields.Char( + string="HS Code", + help="Standardized code for international shipping and goods declaration. At the moment, only used for the FedEx shipping provider.", + ) diff --git a/addons/digest/models/digest.py b/addons/digest/models/digest.py index 7809648e48c..eb4fd1190bb 100644 --- a/addons/digest/models/digest.py +++ b/addons/digest/models/digest.py @@ -133,7 +133,7 @@ class Digest(models.Model): tip = self.env['digest.tip'].search([('user_ids', '!=', user.id), '|', ('group_id', 'in', user.groups_id.ids), ('group_id', '=', False)], limit=1) if not tip: return False - tip.user_ids = [4, user.id] + tip.user_ids += user body = tools.html_sanitize(tip.tip_description) tip_description = self.env['mail.template']._render_template(body, 'digest.tip', self.id) return tip_description diff --git a/addons/hr_holidays/models/hr_leave.py b/addons/hr_holidays/models/hr_leave.py index 748af9b1a32..34afa16eb5d 100644 --- a/addons/hr_holidays/models/hr_leave.py +++ b/addons/hr_holidays/models/hr_leave.py @@ -478,7 +478,7 @@ class HolidaysRequest(models.Model): self.message_subscribe(partner_ids=employee.user_id.partner_id.ids) @api.multi - @api.constrains('holiday_status_id') + @api.constrains('holiday_status_id', 'date_to', 'date_from') def _check_leave_type_validity(self): for leave in self: if leave.holiday_status_id.validity_start and leave.holiday_status_id.validity_stop: diff --git a/addons/hr_timesheet/models/hr_timesheet.py b/addons/hr_timesheet/models/hr_timesheet.py index 0542428f25c..7d1edf4b2b0 100644 --- a/addons/hr_timesheet/models/hr_timesheet.py +++ b/addons/hr_timesheet/models/hr_timesheet.py @@ -42,7 +42,10 @@ class AccountAnalyticLine(models.Model): @api.onchange('employee_id') def _onchange_employee_id(self): - self.user_id = self.employee_id.user_id + if self.employee_id: + self.user_id = self.employee_id.user_id + else: + self.user_id = self._default_user() @api.depends('employee_id') def _compute_department_id(self): diff --git a/addons/http_routing/models/ir_http.py b/addons/http_routing/models/ir_http.py index b8356cf13b5..12c81e9d620 100644 --- a/addons/http_routing/models/ir_http.py +++ b/addons/http_routing/models/ir_http.py @@ -165,7 +165,7 @@ def is_multilang_url(local_url, langs=None): local_url = '/'.join(spath) try: # Try to match an endpoint in werkzeug's routing table - url = local_url.split('?') + url = local_url.partition('#')[0].split('?') path = url[0] query_string = url[1] if len(url) > 1 else None router = request.httprequest.app.get_db_router(request.db).bind('') @@ -174,6 +174,10 @@ def is_multilang_url(local_url, langs=None): return (func.routing.get('website', False) and func.routing.get('multilang', func.routing['type'] == 'http')) except werkzeug.exceptions.NotFound: + # Consider /static/ files as non-multilang + static_index = path.find('/static/', 1) + if static_index != -1 and static_index == path.find('/', 1): + return False return True except Exception as e: return False diff --git a/addons/l10n_pl/data/account_tax_data.xml b/addons/l10n_pl/data/account_tax_data.xml index f29294b36aa..973d05ff7b6 100644 --- a/addons/l10n_pl/data/account_tax_data.xml +++ b/addons/l10n_pl/data/account_tax_data.xml @@ -256,7 +256,7 @@ <field name="type_tax_use">sale</field> <field name="refund_account_id" ref="chart22030200"/> <field name="account_id" ref="chart22030200"/> - <field name="tag_ids" eval="[ref('l10n_pl.tag_pl_08'), ref('l10n_pl.tag_pl_29')]"/> + <field name="tag_ids" eval="[(6,0,[ref('l10n_pl.tag_pl_08'), ref('l10n_pl.tag_pl_29')])]"/> <field name="tax_group_id" ref="tax_group_vat_7"/> </record> diff --git a/addons/mail/models/ir_attachment.py b/addons/mail/models/ir_attachment.py index 31e59d680c9..7273145096d 100644 --- a/addons/mail/models/ir_attachment.py +++ b/addons/mail/models/ir_attachment.py @@ -13,9 +13,9 @@ class IrAttachment(models.Model): """ super(IrAttachment, self)._post_add_create() for record in self: - record.register_as_main_attachment() + record.register_as_main_attachment(force=False) - def register_as_main_attachment(self): + def register_as_main_attachment(self, force=True): """ Registers this attachment as the main one of the model it is attached to. """ @@ -24,4 +24,5 @@ class IrAttachment(models.Model): # message_main_attachment_id field can be empty, that's why we compare to False; # we are just checking that it exists on the model before writing it if related_record and hasattr(related_record, 'message_main_attachment_id'): - related_record.message_main_attachment_id = self + if force or not related_record.message_main_attachment_id: + related_record.message_main_attachment_id = self diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index f758456c77e..a2ba820656f 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -5,6 +5,7 @@ import base64 import datetime import logging import psycopg2 +import smtplib import threading import re @@ -394,11 +395,12 @@ class MailMail(models.Model): mail.id, mail.message_id) # mail status will stay on ongoing since transaction will be rollback raise - except psycopg2.Error: - # If an error with the database occurs, chances are that the cursor is unusable. - # This will lead to an `psycopg2.InternalError` being raised when trying to write - # `state`, shadowing the original exception and forbid a retry on concurrent - # update. Let's bubble it. + except (psycopg2.Error, smtplib.SMTPServerDisconnected): + # If an error with the database or SMTP session occurs, chances are that the cursor + # or SMTP session are unusable, causing further errors when trying to save the state. + _logger.exception( + 'Exception while processing mail with ID %r and Msg-Id %r.', + mail.id, mail.message_id) raise except Exception as e: failure_reason = tools.ustr(e) diff --git a/addons/mail/models/mail_message.py b/addons/mail/models/mail_message.py index e228f7c4062..e2ebb16c69e 100644 --- a/addons/mail/models/mail_message.py +++ b/addons/mail/models/mail_message.py @@ -154,8 +154,8 @@ class Message(models.Model): @api.multi def _search_has_error(self, operator, operand): if operator == '=' and operand: - return ['&', ('notification_ids.email_status', 'in', ('bounce', 'exception')), ('author_id', '=', self.env.user.partner_id.id)] - return ['!', '&', ('notification_ids.email_status', 'in', ('bounce', 'exception')), ('author_id', '=', self.env.user.partner_id.id)] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False" + return [('notification_ids.email_status', 'in', ('bounce', 'exception'))] + return ['!', ('notification_ids.email_status', 'in', ('bounce', 'exception'))] # this wont work and will be equivalent to "not in" beacause of orm restrictions. Dont use "has_error = False" @api.depends('starred_partner_ids') def _get_starred(self): diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 127b7f7d609..81f1dc0510b 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -237,7 +237,7 @@ class MailThread(models.AbstractModel): @api.model def _search_message_has_error(self, operator, operand): - return [('message_ids.has_error', operator, operand)] + return ['&', ('message_ids.has_error', operator, operand), ('message_ids.author_id', '=', self.env.user.partner_id.id)] @api.multi def _compute_message_attachment_count(self): diff --git a/addons/mail/static/src/js/chatter.js b/addons/mail/static/src/js/chatter.js index bc5d18a4150..968242ffe6a 100644 --- a/addons/mail/static/src/js/chatter.js +++ b/addons/mail/static/src/js/chatter.js @@ -81,6 +81,7 @@ var Chatter = Widget.extend({ var nodeOptions = fieldsInfo[mailFields.mail_thread].options || {}; this.hasLogButton = options.display_log_button || nodeOptions.display_log_button; this.postRefresh = nodeOptions.post_refresh || 'never'; + this.reloadOnUploadAttachment = this.postRefresh === 'always'; } }, /** @@ -577,6 +578,9 @@ var Chatter = Widget.extend({ * @private */ _onReloadAttachmentBox: function () { + if (this.reloadOnUploadAttachment) { + this.trigger_up('reload'); + } this._reloadAttachmentBox(); }, /** diff --git a/addons/mail/static/src/scss/chatter.scss b/addons/mail/static/src/scss/chatter.scss index b670e5b92d0..5b503966706 100644 --- a/addons/mail/static/src/scss/chatter.scss +++ b/addons/mail/static/src/scss/chatter.scss @@ -21,11 +21,11 @@ position: relative; float: right; flex: 1 1 auto; + height: $o-statusbar-height; .o_chatter_button_attachment { - height: $o-statusbar-height - 2px; + height: $o-statusbar-height; float: right; - padding: 0px; margin-right: 25px; .fa-paperclip, .o_chatter_attachment_button_count { @@ -33,15 +33,20 @@ } } .o_followers { - height: $o-statusbar-height + 1; + height: $o-statusbar-height; border-bottom: 1px solid transparent; flex: 0 1 auto; } } > .btn { - height: $o-statusbar-height + 1; + height: $o-statusbar-height; border: 1px solid transparent; } + .btn { + @extend .text-nowrap; + padding-right: $btn-padding-x; + padding-left: $btn-padding-x; + } } @@ -60,3 +65,11 @@ } } } + +@include media-breakpoint-down(sm) { + .o_chatter .o_chatter_topbar > .o_topbar_right_area { + .o_chatter_button_attachment { + margin-right: initial; + } + } +} diff --git a/addons/mail/static/src/scss/composer.scss b/addons/mail/static/src/scss/composer.scss index 9dbdf8fde89..802236afd73 100644 --- a/addons/mail/static/src/scss/composer.scss +++ b/addons/mail/static/src/scss/composer.scss @@ -142,6 +142,9 @@ &:not(.o_chat_inline_composer) { flex-flow: row wrap; + .o_composer_container { + max-width: 100%; + } .o_composer_container, .o_composer_button_send { flex: 1 0 100%; } diff --git a/addons/mass_mailing/models/mass_mailing_stats.py b/addons/mass_mailing/models/mass_mailing_stats.py index 662b6adbaaf..f0d8a763438 100644 --- a/addons/mass_mailing/models/mass_mailing_stats.py +++ b/addons/mass_mailing/models/mass_mailing_stats.py @@ -66,14 +66,14 @@ class MailMailStats(models.Model): stat.state = 'ignored' elif stat.exception: stat.state = 'exception' - elif stat.sent: - stat.state = 'sent' elif stat.opened or stat.clicked: stat.state = 'opened' elif stat.replied: stat.state = 'replied' elif stat.bounced: stat.state = 'bounced' + elif stat.sent: + stat.state = 'sent' else: stat.state = 'outgoing' diff --git a/addons/mass_mailing/wizard/mail_compose_message.py b/addons/mass_mailing/wizard/mail_compose_message.py index d0804977301..3f88e62552b 100644 --- a/addons/mass_mailing/wizard/mail_compose_message.py +++ b/addons/mass_mailing/wizard/mail_compose_message.py @@ -64,7 +64,8 @@ class MailComposeMessage(models.TransientModel): if mail_values.get('email_to'): mail_to = tools.email_normalize(mail_values['email_to']) else: - mail_to = tools.email_normalize(partners_email.get(res_id)) + partner_id = (mail_values.get('recipient_ids') or [(False, '')])[0][1] + mail_to = tools.email_normalize(partners_email.get(partner_id)) if (opt_out_list and mail_to in opt_out_list) or (seen_list and mail_to in seen_list) \ or (not mail_to or not email_re.findall(mail_to)): # prevent sending to blocked addresses that were included by mistake diff --git a/addons/point_of_sale/static/src/js/chrome.js b/addons/point_of_sale/static/src/js/chrome.js index ffc5d5cc860..f1a79a22064 100644 --- a/addons/point_of_sale/static/src/js/chrome.js +++ b/addons/point_of_sale/static/src/js/chrome.js @@ -720,9 +720,6 @@ var Chrome = PosBaseWidget.extend(AbstractAction.prototype, { if(err.message === 'XmlHttpRequestError '){ title = 'Network Failure (XmlHttpRequestError)'; body = 'The Point of Sale could not be loaded due to a network problem.\n Please check your internet connection.'; - }else if(err.message === 'TLSError'){ - title = 'Https connection to IoT Box failed'; - body = 'Make sure you are using IoT Box v18.12 or higher.\n\n Navigate to ' + err.url + ' to accept the certificate of your IoT Box.'; }else if(err.code === 200){ title = err.data.message; body = err.data.debug; diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index 2840b6b2866..6797107a367 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -137,11 +137,16 @@ exports.PosModel = Backbone.Model.extend({ resolve(); }, function (statusText, url) { - if (statusText == 'error' && window.location.protocol == 'https:') { - var error = {message: 'TLSError', url: url}; - self.chrome.loading_error(error); - } else { - resolve(); + var show_loading_error = (self.gui.current_screen === null); + resolve(); + if (show_loading_error && statusText == 'error' && window.location.protocol == 'https:') { + self.gui.show_popup('alert', { + title: _t('HTTPS connection to IoT Box failed'), + body: _.str.sprintf( + _t('Make sure you are using IoT Box v18.12 or higher. Navigate to %s to accept the certificate of your IoT Box.'), + url + ), + }); } } ); diff --git a/addons/pos_hr/__manifest__.py b/addons/pos_hr/__manifest__.py index a741ea0cd2c..170529048ad 100644 --- a/addons/pos_hr/__manifest__.py +++ b/addons/pos_hr/__manifest__.py @@ -20,6 +20,6 @@ 'views/pos_order_report_view.xml', ], 'installable': True, - 'auto-install': True, + 'auto_install': True, 'qweb': ['static/src/xml/pos.xml'], } diff --git a/addons/product/models/product.py b/addons/product/models/product.py index 1979ccda68c..99a314662dd 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -15,6 +15,7 @@ from odoo.tools import float_compare _logger = logging.getLogger(__name__) + class ProductCategory(models.Model): _name = "product.category" _description = "Product Category" @@ -612,7 +613,10 @@ class ProductProduct(models.Model): precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') res = self.env['product.supplierinfo'] - for seller in self._prepare_sellers(params): + sellers = self._prepare_sellers(params) + if self.env.context.get('force_company'): + sellers = sellers.filtered(lambda s: not s.company_id or s.company_id.id == self.env.context['force_company']) + for seller in sellers: # Set quantity in UoM of seller quantity_uom_seller = quantity if quantity_uom_seller and uom_id and uom_id != seller.product_uom: diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index c9fb51039c1..56f9e10a840 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -523,6 +523,7 @@ class ProductTemplate(models.Model): variants_to_create.append({ 'product_tmpl_id': tmpl_id.id, 'attribute_value_ids': [(6, 0, list(value_ids))], + 'active': tmpl_id.active, }) if len(variants_to_create) > 1000: raise UserError(_( @@ -991,8 +992,10 @@ class ProductTemplate(models.Model): if not self.active: return _("The product template is archived so no combination is possible.") - ptal_stack = [self._get_valid_product_template_attribute_lines()] - combination_stack = [self.env['product.template.attribute.value']] + necessary_values = necessary_values or self.env['product.template.attribute.value'] + necessary_attributes = necessary_values.mapped('attribute_id') + ptal_stack = [self.valid_product_template_attribute_line_ids.filtered(lambda ptal: ptal.attribute_id not in necessary_attributes)] + combination_stack = [necessary_values] # keep going while we have attribute lines to test while len(ptal_stack): @@ -1001,7 +1004,7 @@ class ProductTemplate(models.Model): if not attribute_lines: # full combination, if it's possible return it, otherwise skip it - if self._is_combination_possible(combination, parent_combination) and all(v in combination for v in (necessary_values or [])): + if self._is_combination_possible(combination, parent_combination): yield(combination) else: # we have remaining attribute lines to consider diff --git a/addons/product/tests/test_product_attribute_value_config.py b/addons/product/tests/test_product_attribute_value_config.py index aa1de67d16b..e966adbdbdc 100644 --- a/addons/product/tests/test_product_attribute_value_config.py +++ b/addons/product/tests/test_product_attribute_value_config.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +import time + from odoo.tests import tagged from odoo.tests.common import TransactionCase @@ -423,6 +425,47 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup): self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2), computer_ssd_256 + computer_ram_8 + computer_hdd_4) + # Make sure this is not extremely slow: + product_template = self.env['product.template'].create({ + 'name': 'many combinations', + }) + + for i in range(10): + # create the attributes + product_attribute = self.env['product.attribute'].create({ + 'name': "att %s" % i, + 'create_variant': 'dynamic', + 'sequence': i, + }) + + for j in range(10): + # create the attribute values + self.env['product.attribute.value'].create([{ + 'name': "val %s/%s" % (i, j), + 'attribute_id': product_attribute.id, + 'sequence': j, + }]) + + # set attribute and attribute values on the template + self.env['product.template.attribute.line'].create([{ + 'attribute_id': product_attribute.id, + 'product_tmpl_id': product_template.id, + 'value_ids': [(6, 0, product_attribute.value_ids.ids)] + }]) + + # Get a value in the middle for each attribute to make sure it would + # take time to reach it (if looping one by one like before the fix). + combination = self.env['product.template.attribute.value'] + for ptal in product_template.attribute_line_ids: + combination += ptal.product_template_value_ids[5] + + started_at = time.time() + self.assertEqual(product_template._get_closest_possible_combination(combination), combination) + elapsed = time.time() - started_at + # It should take around 10ms, but to avoid false positives we check an + # higher value. Before the fix it would take hours. + self.assertLess(elapsed, 0.5) + def test_clear_caches(self): """The goal of this test is to make sure the cache is invalidated when it should be.""" diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index eead94d7c9a..cac587a3fa7 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -300,8 +300,9 @@ <div class="o_row col-5 pl-0"> <field name="lst_price" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" attrs="{'readonly': [('product_variant_count', '>', 1)]}"/> </div> - <field name="standard_price" widget='monetary' options="{'currency_field': 'currency_id'}"/> + <field name="standard_price" widget='monetary' options="{'currency_field': 'cost_currency_id'}"/> <field name="currency_id" invisible='1'/> + <field name="cost_currency_id" invisible="1"/> </group> </group> <group> diff --git a/addons/purchase/models/account_invoice.py b/addons/purchase/models/account_invoice.py index 9f63039afd7..6c7b1b9293d 100644 --- a/addons/purchase/models/account_invoice.py +++ b/addons/purchase/models/account_invoice.py @@ -25,6 +25,10 @@ class AccountInvoice(models.Model): return {} self.purchase_id = self.vendor_bill_purchase_id.purchase_order_id self.vendor_bill_id = self.vendor_bill_purchase_id.vendor_bill_id + vendor_ref = self.vendor_bill_purchase_id.purchase_order_id.partner_ref + if vendor_ref: + self.reference = ", ".join([self.reference, vendor_ref]) if ( + self.reference and vendor_ref not in self.reference) else vendor_ref self.vendor_bill_purchase_id = False return {} diff --git a/addons/purchase_requisition/models/product.py b/addons/purchase_requisition/models/product.py index ef94ad283b7..48380f1c3dc 100644 --- a/addons/purchase_requisition/models/product.py +++ b/addons/purchase_requisition/models/product.py @@ -16,10 +16,11 @@ class ProductProduct(models.Model): _inherit = 'product.product' def _prepare_sellers(self, params): + sellers = super(ProductProduct, self)._prepare_sellers(params) if params and params.get('order_id'): - return self.seller_ids.filtered(lambda s: not s.purchase_requisition_id or s.purchase_requisition_id == params['order_id'].requisition_id) + return sellers.filtered(lambda s: not s.purchase_requisition_id or s.purchase_requisition_id == params['order_id'].requisition_id) else: - return self.seller_ids + return sellers class ProductTemplate(models.Model): diff --git a/addons/purchase_stock/models/stock_rule.py b/addons/purchase_stock/models/stock_rule.py index 64a72467b11..e8873375ba9 100644 --- a/addons/purchase_stock/models/stock_rule.py +++ b/addons/purchase_stock/models/stock_rule.py @@ -174,7 +174,7 @@ class StockRule(models.Model): def _update_purchase_order_line(self, product_id, product_qty, product_uom, company_id, values, line): partner = values['supplier'].name procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id) - seller = product_id._select_seller( + seller = product_id.with_context(force_company=company_id.id)._select_seller( partner_id=partner, quantity=line.product_qty + procurement_uom_po_qty, date=line.order_id.date_order and line.order_id.date_order.date(), @@ -197,7 +197,7 @@ class StockRule(models.Model): procurement_uom_po_qty = product_uom._compute_quantity(product_qty, product_id.uom_po_id) # _select_seller is used if the supplier have different price depending # the quantities ordered. - seller = product_id._select_seller( + seller = product_id.with_context(force_company=company_id.id)._select_seller( partner_id=partner, quantity=procurement_uom_po_qty, date=po.date_order and po.date_order.date(), diff --git a/addons/sale/models/res_company.py b/addons/sale/models/res_company.py index 6d6cfccc87c..439061adafa 100644 --- a/addons/sale/models/res_company.py +++ b/addons/sale/models/res_company.py @@ -61,6 +61,7 @@ class ResCompany(models.Model): 'product_uom_qty': 10, 'price_unit': 123, 'order_id': sample_sales_order.id, + 'company_id': sample_sales_order.company_id.id, }) return sample_sales_order diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 99258e80233..427c9a7986e 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -1031,20 +1031,23 @@ class SaleOrderLine(models.Model): res[field] = line._fields[field].convert_to_write(line[field], line) return res - @api.model - def create(self, values): - if values.get('display_type', self.default_get(['display_type'])['display_type']): - values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0) - - values.update(self._prepare_add_missing_fields(values)) - line = super(SaleOrderLine, self).create(values) - if line.product_id and line.order_id.state == 'sale': - msg = _("Extra line with %s ") % (line.product_id.display_name,) - line.order_id.message_post(body=msg) - # create an analytic account if at least an expense product - if line.product_id.expense_policy not in [False, 'no'] and not self.order_id.analytic_account_id: - self.order_id._create_analytic_account() - return line + @api.model_create_multi + def create(self, vals_list): + for values in vals_list: + if values.get('display_type', self.default_get(['display_type'])['display_type']): + values.update(product_id=False, price_unit=0, product_uom_qty=0, product_uom=False, customer_lead=0) + + values.update(self._prepare_add_missing_fields(values)) + + lines = super().create(vals_list) + for line in lines: + if line.product_id and line.order_id.state == 'sale': + msg = _("Extra line with %s ") % (line.product_id.display_name,) + line.order_id.message_post(body=msg) + # create an analytic account if at least an expense product + if line.product_id.expense_policy not in [False, 'no'] and not line.order_id.analytic_account_id: + line.order_id._create_analytic_account() + return lines _sql_constraints = [ ('accountable_required_fields', diff --git a/addons/sale/report/sale_report_templates.xml b/addons/sale/report/sale_report_templates.xml index 380596eb181..c8c9612326a 100644 --- a/addons/sale/report/sale_report_templates.xml +++ b/addons/sale/report/sale_report_templates.xml @@ -70,11 +70,15 @@ <table class="table table-sm o_main_table"> <thead> <tr> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="5"/> <th class="text-left">Description</th> <th class="text-right">Quantity</th> <th class="text-right">Unit Price</th> <th t-if="display_discount" class="text-right" groups="sale.group_discount_per_so_line"> <span>Disc.(%)</span> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="colspan+1"/> </th> <th class="text-right">Taxes</th> <th class="text-right"> diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml index 4c75b4b7d00..a69c8ba21cc 100644 --- a/addons/sale/views/sale_portal_templates.xml +++ b/addons/sale/views/sale_portal_templates.xml @@ -424,11 +424,15 @@ <table class="table table-sm" id="sales_order_table"> <thead class="bg-100"> <tr> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="6"/> <th class="text-left">Products</th> <th class="text-right">Quantity</th> <th t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}">Unit Price</th> <th t-if="display_discount" t-attf-class="text-right {{ 'd-none d-sm-table-cell' if report_type == 'html' else '' }}"> <span>Discount</span> + <!-- TODO: remove in master --> + <t t-set="colspan" t-value="colspan+1"/> </th> <th t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">Taxes</th> <th class="text-right" > diff --git a/addons/sale_management/models/sale_order.py b/addons/sale_management/models/sale_order.py index 6ebed05a2bc..6a95dfeaeeb 100644 --- a/addons/sale_management/models/sale_order.py +++ b/addons/sale_management/models/sale_order.py @@ -31,7 +31,8 @@ class SaleOrder(models.Model): @api.onchange('partner_id') def onchange_partner_id(self): super(SaleOrder, self).onchange_partner_id() - self.note = self.sale_order_template_id.note or self.note + template = self.sale_order_template_id.with_context(lang=self.partner_id.lang) + self.note = template.note or self.note def _compute_line_data_for_template_change(self, line): return { diff --git a/addons/sale_management/models/sale_order_template.py b/addons/sale_management/models/sale_order_template.py index 8eef6efd405..493386f33c7 100644 --- a/addons/sale_management/models/sale_order_template.py +++ b/addons/sale_management/models/sale_order_template.py @@ -18,7 +18,7 @@ class SaleOrderTemplate(models.Model): name = fields.Char('Quotation Template', required=True) sale_order_template_line_ids = fields.One2many('sale.order.template.line', 'sale_order_template_id', 'Lines', copy=True) - note = fields.Text('Terms and conditions') + note = fields.Text('Terms and conditions', translate=True) sale_order_template_option_ids = fields.One2many('sale.order.template.option', 'sale_order_template_id', 'Optional Products', copy=True) number_of_days = fields.Integer('Quotation Duration', help='Number of days for the validity date computation of the quotation') diff --git a/addons/sale_management/views/sale_portal_templates.xml b/addons/sale_management/views/sale_portal_templates.xml index 90d2100ba4a..f29a538dd0e 100644 --- a/addons/sale_management/views/sale_portal_templates.xml +++ b/addons/sale_management/views/sale_portal_templates.xml @@ -15,6 +15,7 @@ <xpath expr="//section[@id='details']//table[@id='sales_order_table']/thead/tr" position="inside"> <th t-if="display_remove"> + <!-- TODO: remove in master --> <t t-set="colspan" t-value="colspan+1"/> </th> </xpath> diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index d3111d2564f..4710ff88851 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -3,7 +3,7 @@ from odoo import api, fields, models, _ from odoo.addons import decimal_precision as dp -from odoo.exceptions import UserError +from odoo.exceptions import UserError, ValidationError from odoo.tools import float_utils, float_compare @@ -151,13 +151,13 @@ class Inventory(models.Model): if self.filter == 'none' and self.product_id and self.location_id and self.lot_id: return if self.filter not in ('product', 'product_owner') and self.product_id: - raise UserError(_('The selected product doesn\'t belong to that owner..')) + raise ValidationError(_('The selected product doesn\'t belong to that owner..')) if self.filter != 'lot' and self.lot_id: - raise UserError(_('The selected lot number doesn\'t exist.')) + raise ValidationError(_('The selected lot number doesn\'t exist.')) if self.filter not in ('owner', 'product_owner') and self.partner_id: - raise UserError(_('The selected owner doesn\'t have the proprietary of that product.')) + raise ValidationError(_('The selected owner doesn\'t have the proprietary of that product.')) if self.filter != 'pack' and self.package_id: - raise UserError(_('The selected inventory options are not coherent, the package doesn\'t exist.')) + raise ValidationError(_('The selected inventory options are not coherent, the package doesn\'t exist.')) def action_reset_product_qty(self): self.mapped('line_ids').write({'product_qty': 0}) @@ -425,7 +425,7 @@ class InventoryLine(models.Model): """ for line in self: if line.product_id.type != 'product': - raise UserError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type)) + raise ValidationError(_("You can only adjust storable products.") + '\n\n%s -> %s' % (line.product_id.display_name, line.product_id.type)) def _get_move_values(self, qty, location_id, location_dest_id, out): self.ensure_one() diff --git a/addons/stock/models/stock_quant.py b/addons/stock/models/stock_quant.py index 03c1890c51a..66a7aa90b8c 100644 --- a/addons/stock/models/stock_quant.py +++ b/addons/stock/models/stock_quant.py @@ -365,7 +365,7 @@ class QuantPackage(models.Model): 'res.partner', 'Owner', compute='_compute_package_info', search='_search_owner', index=True, readonly=True) - @api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id') + @api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id', 'quant_ids.quantity', 'quant_ids.reserved_quantity') def _compute_package_info(self): for package in self: values = {'location_id': False, 'company_id': self.env.user.company_id.id, 'owner_id': False} diff --git a/addons/stock/report/report_stockinventory.xml b/addons/stock/report/report_stockinventory.xml index 90abd6b4109..19a988dfe59 100644 --- a/addons/stock/report/report_stockinventory.xml +++ b/addons/stock/report/report_stockinventory.xml @@ -6,6 +6,7 @@ <t t-foreach="docs" t-as="o"> <t t-call="web.external_layout"> <div class="page"> + <br/> <h2>Stock Inventory</h2> <table class="table table-sm"> diff --git a/addons/stock/wizard/stock_immediate_transfer.py b/addons/stock/wizard/stock_immediate_transfer.py index 66a652ac795..9b46a91c585 100644 --- a/addons/stock/wizard/stock_immediate_transfer.py +++ b/addons/stock/wizard/stock_immediate_transfer.py @@ -22,7 +22,7 @@ class StockImmediateTransfer(models.TransientModel): picking.action_assign() if picking.state != 'assigned': raise UserError(_("Could not reserve all requested products. Please use the \'Mark as Todo\' button to handle the reservation manually.")) - for move in picking.move_lines: + for move in picking.move_lines.filtered(lambda m: m.state not in ['done', 'cancel']): for move_line in move.move_line_ids: move_line.qty_done = move_line.product_uom_qty if picking._check_backorder(): diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py index 5a6500ebf9d..1abd666afc2 100644 --- a/addons/stock_account/models/stock.py +++ b/addons/stock_account/models/stock.py @@ -303,7 +303,7 @@ class StockMove(models.Model): if qty_to_take_on_candidates == 0: move.write({ 'value': -tmp_value if not quantity else move.value or -tmp_value, # outgoing move are valued negatively - 'price_unit': -tmp_value / move.product_qty, + 'price_unit': -tmp_value / (move.product_qty or quantity), }) elif qty_to_take_on_candidates > 0: last_fifo_price = new_standard_price or move.product_id.standard_price diff --git a/addons/stock_account/views/product_views.xml b/addons/stock_account/views/product_views.xml index 4919bf85924..b4fd4e46503 100644 --- a/addons/stock_account/views/product_views.xml +++ b/addons/stock_account/views/product_views.xml @@ -67,7 +67,7 @@ <div name="standard_price_div" class="row ml-0"> <field name="standard_price" attrs="{'readonly':[('valuation','=','real_time'), ('cost_method', '!=', 'fifo')]}" - widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" class="float-left"/> + widget='monetary' options="{'currency_field': 'cost_currency_id', 'field_digits': True}" class="float-left"/> <div name="update_cost_price"> <button string="Update Cost" type="action" name="%(action_view_change_standard_price)d" diff --git a/addons/survey/static/src/js/survey.js b/addons/survey/static/src/js/survey.js index a2b5ab66a6f..d232c3073f7 100644 --- a/addons/survey/static/src/js/survey.js +++ b/addons/survey/static/src/js/survey.js @@ -98,7 +98,10 @@ if(!the_form.length) { } // checkboxes and radios - the_form.find("input[name^=" + key + "][type!='text']").each(function(){ + the_form.find("input[name^='" + key + "_'][type='checkbox']").each(function(){ + $(this).val(value); + }); + the_form.find("input[name=" + key + "][type!='text']").each(function(){ $(this).val(value); }); }); diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js index 9f37fcecbea..2bd9b5cc4b1 100644 --- a/addons/web/static/src/js/views/list/list_editable_renderer.js +++ b/addons/web/static/src/js/views/list/list_editable_renderer.js @@ -435,6 +435,18 @@ ListRenderer.include({ }); } }, + /** + * @override + */ + updateState: function (state, params) { + if (params.noRender) { + // the state changed, but we won't do a re-rendering right now, so + // remove computed modifiers data (as they are obsolete) to force + // them to be recomputed at next (sub-)rendering + this.allModifiersData = []; + } + return this._super.apply(this, arguments); + }, //-------------------------------------------------------------------------- // Private @@ -955,6 +967,8 @@ ListRenderer.include({ * @param {boolean} [options.force=false] if true, force selecting the cell * even if seems to be already the selected one (useful after a re- * rendering, to reset the focus on the correct field) + * @param {integer} [options.inc=1] the increment to use when searching for + * the "next" possible cell (if the cell to select can't be selected) * @return {Promise} fails if no cell could be selected */ _selectCell: function (rowIndex, fieldIndex, options) { @@ -979,7 +993,7 @@ ListRenderer.include({ var oldFieldIndex = self.currentFieldIndex; self.currentFieldIndex = fieldIndex; fieldIndex = self._activateFieldWidget(record, fieldIndex, { - inc: 1, + inc: options.inc || 1, wrap: wrap, event: options && options.event, }); @@ -1183,7 +1197,7 @@ ListRenderer.include({ switch (ev.data.direction) { case 'previous': if (this.currentFieldIndex > 0) { - this._selectCell(this.currentRow, this.currentFieldIndex - 1, {wrap: false}) + this._selectCell(this.currentRow, this.currentFieldIndex - 1, {inc: -1, wrap: false}) .guardedCatch(this._moveToPreviousLine.bind(this)); } else { this._moveToPreviousLine(); diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js index 5e5a0c7b7a1..a8d043775db 100644 --- a/addons/web/static/tests/views/list_tests.js +++ b/addons/web/static/tests/views/list_tests.js @@ -3212,6 +3212,90 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('pressing SHIFT-TAB in editable list with a readonly field [REQUIRE FOCUS]', async function (assert) { + assert.expect(4); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="foo"/>' + + '<field name="int_field" readonly="1"/>' + + '<field name="qux"/>' + + '</tree>', + }); + + // start on 'qux', line 3 + await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(2)')); + assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=qux]')[0]); + + // Press 'shift-Tab' -> should go to first cell (same line) + $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true}); + await testUtils.nextTick(); + assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]); + + list.destroy(); + }); + + QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in first column [REQUIRE FOCUS]', async function (assert) { + assert.expect(4); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="int_field" readonly="1"/>' + + '<field name="foo"/>' + + '<field name="qux"/>' + + '</tree>', + }); + + // start on 'foo', line 3 + await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:nth(1)')); + assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=foo]')[0]); + + // Press 'shift-Tab' -> should go to previous line (last cell) + $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true}); + await testUtils.nextTick(); + assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=qux]')[0]); + + list.destroy(); + }); + + QUnit.test('pressing SHIFT-TAB in editable list with a readonly field in last column [REQUIRE FOCUS]', async function (assert) { + assert.expect(4); + + var list = await createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="bottom">' + + '<field name="int_field"/>' + + '<field name="foo"/>' + + '<field name="qux" readonly="1"/>' + + '</tree>', + }); + + // start on 'int_field', line 3 + await testUtils.dom.click(list.$('.o_data_row:nth(2) .o_data_cell:first')); + assert.hasClass(list.$('.o_data_row:nth(2)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(2) .o_data_cell input[name=int_field]')[0]); + + // Press 'shift-Tab' -> should go to previous line ('foo' field) + $(document.activeElement).trigger({type: 'keydown', which: $.ui.keyCode.TAB, shiftKey: true}); + await testUtils.nextTick(); + assert.hasClass(list.$('.o_data_row:nth(1)'), 'o_selected_row'); + assert.strictEqual(document.activeElement, list.$('.o_data_row:nth(1) .o_data_cell input[name=foo]')[0]); + + list.destroy(); + }); + QUnit.test('skip invisible fields when navigating list view with TAB', async function (assert) { assert.expect(2); @@ -4460,6 +4544,47 @@ QUnit.module('Views', { list.destroy(); }); + QUnit.test('create record on list with modifiers depending on id', function (assert) { + assert.expect(8); + + var list = createView({ + View: ListView, + model: 'foo', + data: this.data, + arch: '<tree editable="top">' + + '<field name="id" invisible="1"/>' + + '<field name="foo" attrs="{\'readonly\': [[\'id\',\'!=\',False]]}"/>' + + '<field name="int_field" attrs="{\'invisible\': [[\'id\',\'!=\',False]]}"/>' + + '</tree>', + }); + + // add a new record + testUtils.dom.click(list.$buttons.find('.o_list_button_add')); + + // modifiers should be evaluted to false + assert.containsOnce(list, '.o_selected_row'); + assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier'); + assert.doesNotHaveClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier'); + + // set a value and save + testUtils.fields.editInput(list.$('.o_selected_row input[name=foo]'), 'some value'); + testUtils.dom.click(list.$buttons.find('.o_list_button_save')); + + // modifiers should be evaluted to true + assert.hasClass(list.$('.o_data_row:first .o_data_cell:first'), 'o_readonly_modifier'); + assert.hasClass(list.$('.o_data_row:first .o_data_cell:nth(1)'), 'o_invisible_modifier'); + + // edit again the just created record + testUtils.dom.click(list.$('.o_data_row:first .o_data_cell:first')); + + // modifiers should be evaluted to true + assert.containsOnce(list, '.o_selected_row'); + assert.hasClass(list.$('.o_selected_row .o_data_cell:first'), 'o_readonly_modifier'); + assert.hasClass(list.$('.o_selected_row .o_data_cell:nth(1)'), 'o_invisible_modifier'); + + list.destroy(); + }); + QUnit.test('grouped list with async widget', async function (assert) { assert.expect(4); diff --git a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js index 44793603027..ddd987f4ed2 100644 --- a/addons/web_editor/static/src/js/wysiwyg/widgets/media.js +++ b/addons/web_editor/static/src/js/wysiwyg/widgets/media.js @@ -909,7 +909,7 @@ var VideoWidget = MediaWidget.extend({ options = options || {}; // Video url patterns(youtube, instagram, vimeo, dailymotion, youku, ...) - var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/; + var ytRegExp = /^(?:(?:https?:)?\/\/)?(?:www\.)?(?:youtu\.be\/|youtube(-nocookie)?\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((?:\w|-){11})(?:\S+)?$/; var ytMatch = url.match(ytRegExp); var insRegExp = /(.*)instagram.com\/p\/(.[a-zA-Z0-9]*)/; @@ -936,8 +936,8 @@ var VideoWidget = MediaWidget.extend({ var autoplay = options.autoplay ? '?autoplay=1' : '?autoplay=0'; - if (ytMatch && ytMatch[1].length === 11) { - $video.attr('src', '//www.youtube.com/embed/' + ytMatch[1] + autoplay); + if (ytMatch && ytMatch[2].length === 11) { + $video.attr('src', '//www.youtube' + (ytMatch[1] || '') + '.com/embed/' + ytMatch[2] + autoplay); } else if (insMatch && insMatch[2].length) { $video.attr('src', '//www.instagram.com/p/' + insMatch[2] + '/embed/'); videoType = 'ins'; diff --git a/addons/website/models/ir_qweb.py b/addons/website/models/ir_qweb.py index 0a36e8fa8ee..ea33aa114b2 100644 --- a/addons/website/models/ir_qweb.py +++ b/addons/website/models/ir_qweb.py @@ -4,6 +4,7 @@ import re from collections import OrderedDict from odoo import models +from odoo.addons.http_routing.models.ir_http import url_for from odoo.http import request from odoo.addons.base.models.assetsbundle import AssetsBundle from odoo.tools import html_escape as escape @@ -50,10 +51,16 @@ class QWeb(models.AbstractModel): if not website and options.get('website_id'): website = self.env['website'].browse(options['website_id']) - if not website or not website.cdn_activated: + if not website: return atts name = self.URL_ATTRS.get(tagName) + if request and name and name in atts: + atts[name] = url_for(atts[name]) + + if not website.cdn_activated: + return atts + if name and name in atts: atts = OrderedDict(atts) atts[name] = website.get_cdn_url(atts[name]) diff --git a/addons/website/tests/test_qweb.py b/addons/website/tests/test_qweb.py index 67e22117cde..20dd0ce6f9d 100644 --- a/addons/website/tests/test_qweb.py +++ b/addons/website/tests/test_qweb.py @@ -2,7 +2,9 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. import re +import werkzeug +import odoo from odoo import tools from odoo.modules.module import get_module_resource from odoo.tests.common import TransactionCase @@ -65,3 +67,123 @@ class TestQweb(TransactionCase): "filename": "Marc%20Demo", "alt": "Marc Demo", }).encode('utf8')) + +class MockObject(object): + _log_call = [] + def __init__(self, *args, **kwargs): + self.__dict__ = kwargs + def __call__(self, *args, **kwargs): + self._log_call.append((args, kwargs)) + return self + def __getitem__(self, index): + return self + +def werkzeugRaiseNotFound(*args, **kwargs): + raise werkzeug.exceptions.NotFound() + +class MockRequest(object): + """ Class with context manager mocking odoo.http.request for tests """ + def __init__(self, env, website=None, context=None, multilang=True, routing=True): + app = MockObject(routing={ + 'type': 'http', + 'website': True, + 'multilang': multilang, + }) + app.get_db_router = app.bind = app.match = app + if not routing: + app.match = werkzeugRaiseNotFound + self.request = MockObject( + env=env, context=context or {}, db=None, debug=False, + website=website, httprequest=MockObject( + path='/hello/', + app=app + ) + ) + odoo.http._request_stack.push(self.request) + def __enter__(self): + return self.request + def __exit__(self, exc_type, exc_value, traceback): + odoo.http._request_stack.pop() + +class TestQwebProcessAtt(TransactionCase): + def setUp(self): + super(TestQwebProcessAtt, self).setUp() + self.website = self.env['website'].browse(1) + self.website.language_ids = self.env.ref('base.lang_en') + self.env.ref('base.lang_fr') + self.website.default_lang_id = self.env.ref('base.lang_en') + self.website.cdn_activated = True + self.website.cdn_url = "http://test.cdn" + self.website.cdn_filters = "\n".join(["^(/[a-z]{2}_[A-Z]{2})?/a$", "^/b$"]) + + def _test_att(self, url, expect, tag='a', attribute='href'): + self.assertEqual( + self.env['ir.qweb']._post_processing_att(tag, {attribute: url}, {}), + expect + ) + + def test_process_att_no_request(self): + # no request so no URL rewriting + self._test_att('/', {'href': '/'}) + self._test_att('/en_US/', {'href': '/en_US/'}) + self._test_att('/fr_FR/', {'href': '/fr_FR/'}) + # no URL rewritting for CDN + self._test_att('/a', {'href': '/a'}) + + def test_process_att_no_website(self): + with MockRequest(self.env) as request: + # no website so URL rewriting + self._test_att('/', {'href': '/'}) + self._test_att('/en_US/', {'href': '/en_US/'}) + self._test_att('/fr_FR/', {'href': '/fr_FR/'}) + # no URL rewritting for CDN + self._test_att('/a', {'href': '/a'}) + + def test_process_att_monolang_route(self): + with MockRequest(self.env, website=self.website, multilang=False) as request: + # lang not changed in URL but CDN enabled + self._test_att('/a', {'href': 'http://test.cdn/a'}) + self._test_att('/en_US/a', {'href': 'http://test.cdn/en_US/a'}) + self._test_att('/b', {'href': 'http://test.cdn/b'}) + self._test_att('/en_US/b', {'href': '/en_US/b'}) + + def test_process_att_no_request_lang(self): + with MockRequest(self.env, self.website) as request: + self._test_att('/', {'href': '/'}) + self._test_att('/en_US/', {'href': '/'}) + self._test_att('/fr_FR/', {'href': '/fr_FR/'}) + + def test_process_att_with_request_lang(self): + with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}) as request: + self._test_att('/', {'href': '/fr_FR/'}) + self._test_att('/en_US/', {'href': '/'}) + self._test_att('/fr_FR/', {'href': '/fr_FR/'}) + + def test_process_att_matching_cdn_and_lang(self): + with MockRequest(self.env, self.website) as request: + # lang prefix is added before CDN + self._test_att('/a', {'href': 'http://test.cdn/a'}) + self._test_att('/en_US/a', {'href': 'http://test.cdn/a'}) + self._test_att('/fr_FR/a', {'href': 'http://test.cdn/fr_FR/a'}) + self._test_att('/b', {'href': 'http://test.cdn/b'}) + self._test_att('/en_US/b', {'href': 'http://test.cdn/b'}) + self._test_att('/fr_FR/b', {'href': '/fr_FR/b'}) + + def test_process_att_no_route(self): + with MockRequest(self.env, self.website, context={'lang': 'fr_FR'}, routing=False) as request: + # default on multilang=True if route is not /{module}/static/ + self._test_att('/web/static/hi', {'href': '/web/static/hi'}) + self._test_att('/my-page', {'href': '/fr_FR/my-page'}) + + def test_process_att_url_crap(self): + with MockRequest(self.env, self.website) as request: + # #{fragment} is stripped from URL when testing route + self._test_att('/x#y?z', {'href': '/x#y?z'}) + self.assertEqual( + request.httprequest.app._log_call[-1], + (('/x',), {'method': 'POST', 'query_args': None}) + ) + self._test_att('/x?y#z', {'href': '/x?y#z'}) + self.assertEqual( + request.httprequest.app._log_call[-1], + (('/x',), {'method': 'POST', 'query_args': 'y'}) + ) |
