[MERGE] forward port branch saas-12.2 up to 2694174b41
This commit is contained in:
@@ -19,3 +19,4 @@ from . import account_cash_rounding
|
||||
from . import reconciliation_widget
|
||||
from . import account_incoterms
|
||||
from . import digest
|
||||
from . import res_users
|
||||
|
||||
@@ -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)
|
||||
@@ -1114,12 +1118,18 @@ class AccountInvoice(models.Model):
|
||||
tax_grouped[key][field] += round_curr(val.get(field) or 0)
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'))
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ''"/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
'views/pos_order_report_view.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'auto-install': True,
|
||||
'auto_install': True,
|
||||
'qweb': ['static/src/xml/pos.xml'],
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+16
-13
@@ -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)
|
||||
@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))
|
||||
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
|
||||
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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" >
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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'})
|
||||
)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
The Democratic Republic of Congo, April 3rd 2019
|
||||
|
||||
Business Solutions For Africa agrees to the terms of the Odoo Corporate Contributor License
|
||||
Agreement v1.0.
|
||||
|
||||
I declare that I am authorized and able to make this agreement and sign this
|
||||
declaration.
|
||||
|
||||
Signed,
|
||||
|
||||
Vincent Luba vincent@biz-4-africa.com https://github.com/BIZ4Africa
|
||||
|
||||
List of contributors:
|
||||
|
||||
Vincent Luba vincent@biz-4-africa.com https://github.com/BIZ4Africa
|
||||
@@ -13,3 +13,4 @@ David Arnold dar@xoe.solutions https://github.com/blaggacao
|
||||
List of contributors:
|
||||
|
||||
David Arnold dar@devco.co https://github.com/blaggacao
|
||||
Deiber Rincon dri@xoe.solutions https://github.com/deiber
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
Ukraine, 2019-04-08
|
||||
|
||||
I hereby agree to the terms of the Odoo Individual Contributor License Agreement v1.0.
|
||||
|
||||
I declare that I am authorized and able to make this agreement and sign this declaration.
|
||||
|
||||
Signed,
|
||||
|
||||
Andrii Semko semko.andrey.i@gmail.com https://github.com/andriisem
|
||||
@@ -0,0 +1,11 @@
|
||||
Switzerland, 2019-03-25
|
||||
|
||||
I hereby agree to the terms of the Odoo Individual Contributor License
|
||||
Agreement v1.0.
|
||||
|
||||
I declare that I am authorized and able to make this agreement and sign this
|
||||
declaration.
|
||||
|
||||
Signed,
|
||||
|
||||
Bernard Letourmy bernard.letourmy@gmail.com https://github.com/bletourmy
|
||||
@@ -0,0 +1,11 @@
|
||||
USA, 2019-04-04
|
||||
|
||||
I hereby agree to the terms of the Odoo Individual Contributor License
|
||||
Agreement v1.0.
|
||||
|
||||
I declare that I am authorized and able to make this agreement and sign this
|
||||
declaration.
|
||||
|
||||
Signed,
|
||||
|
||||
Matthew Watkins mwatkins.bulk@gmail.com https://github.com/matthewdwatkins
|
||||
@@ -0,0 +1,11 @@
|
||||
Sudan, 2019-03-15
|
||||
|
||||
I hereby agree to the terms of the Odoo Individual Contributor License
|
||||
Agreement v1.0.
|
||||
|
||||
I declare that I am authorized and able to make this agreement and sign this
|
||||
declaration.
|
||||
|
||||
Signed,
|
||||
|
||||
Mohammed Mahgoub mmahgoub@gmail.com https://github.com/mmahgoub
|
||||
@@ -277,7 +277,11 @@ Installing your theme
|
||||
|
||||
To install your theme, you just place your theme folder inside addons in your Odoo installation.
|
||||
|
||||
After that, navigate to the Settings page, look for your theme and click on the install button.
|
||||
After that, navigate to the Odoo **Website** module, go to
|
||||
:menuselection:`Configuration --> Settings`.
|
||||
|
||||
Under **Website** section click the **Choose a theme** button, then hover over
|
||||
your theme and click **Use this theme**.
|
||||
|
||||
Structure of an Odoo page
|
||||
=========================
|
||||
|
||||
@@ -483,6 +483,8 @@ class IrMailServer(models.Model):
|
||||
# do not quit() a pre-established smtp_session
|
||||
if not smtp_session:
|
||||
smtp.quit()
|
||||
except smtplib.SMTPServerDisconnected:
|
||||
raise
|
||||
except Exception as e:
|
||||
params = (ustr(smtp_server), e.__class__.__name__, ustr(e))
|
||||
msg = _("Mail delivery failed via SMTP server '%s'.\n%s: %s") % params
|
||||
|
||||
@@ -185,6 +185,11 @@ class IrModel(models.Model):
|
||||
self._cr.execute('DROP VIEW "%s"' % table)
|
||||
elif kind == 'r':
|
||||
self._cr.execute('DROP TABLE "%s" CASCADE' % table)
|
||||
# discard all translations for this model
|
||||
self._cr.execute("""
|
||||
DELETE FROM ir_translation
|
||||
WHERE type IN ('model', 'model_terms') AND name LIKE %s
|
||||
""", [model.model + ',%'])
|
||||
else:
|
||||
_logger.warning('The model %s could not be dropped because it did not exist in the registry.', model.model)
|
||||
return True
|
||||
@@ -574,6 +579,12 @@ class IrModelFields(models.Model):
|
||||
tables_to_drop.add(rel_name)
|
||||
if field.state == 'manual' and is_model:
|
||||
model._pop_field(field.name)
|
||||
if field.translate:
|
||||
# discard all translations for this field
|
||||
self._cr.execute("""
|
||||
DELETE FROM ir_translation
|
||||
WHERE type IN ('model', 'model_terms') AND name=%s
|
||||
""", ['%s,%s' % (field.model, field.name)])
|
||||
|
||||
if tables_to_drop:
|
||||
# drop the relation tables that are not used by other fields
|
||||
|
||||
@@ -19,6 +19,7 @@ from docutils.core import publish_string
|
||||
from docutils.transforms import Transform, writer_aux
|
||||
from docutils.writers.html4css1 import Writer
|
||||
import lxml.html
|
||||
import psycopg2
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, models, modules, tools, _
|
||||
@@ -542,6 +543,14 @@ class Module(models.Model):
|
||||
|
||||
@api.multi
|
||||
def _button_immediate_function(self, function):
|
||||
try:
|
||||
# This is done because the installation/uninstallation/upgrade can modify a currently
|
||||
# running cron job and prevent it from finishing, and since the ir_cron table is locked
|
||||
# during execution, the lock won't be released until timeout.
|
||||
self._cr.execute("SELECT * FROM ir_cron FOR UPDATE NOWAIT")
|
||||
except psycopg2.OperationalError:
|
||||
raise UserError(_("The server is busy right now, module operations are not possible at"
|
||||
" this time, please try again later."))
|
||||
function(self)
|
||||
|
||||
self._cr.commit()
|
||||
|
||||
@@ -669,6 +669,47 @@ class IrTranslation(models.Model):
|
||||
})
|
||||
self._modified_model(field.model_name)
|
||||
|
||||
@api.model
|
||||
def _upsert_translations(self, vals_list):
|
||||
""" Insert or update translations of type 'model' or 'model_terms'.
|
||||
|
||||
This method is used for creations of translations where the given
|
||||
``vals_list`` is trusted to be the right values and potential
|
||||
conflicts should be updated to the new given value.
|
||||
"""
|
||||
rows_by_type = defaultdict(list)
|
||||
for vals in vals_list:
|
||||
rows_by_type[vals['type']].append((
|
||||
vals['name'], vals['lang'], vals['res_id'], vals['src'], vals['type'],
|
||||
vals.get('module'), vals['value'], vals.get('state'), vals.get('comments'),
|
||||
))
|
||||
|
||||
if rows_by_type['model']:
|
||||
query = """
|
||||
INSERT INTO ir_translation (name, lang, res_id, src, type,
|
||||
module, value, state, comments)
|
||||
VALUES {}
|
||||
ON CONFLICT (type, lang, name, res_id) WHERE type='model'
|
||||
DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
|
||||
(EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
|
||||
EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
|
||||
WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
|
||||
""".format(", ".join(["%s"] * len(rows_by_type['model'])))
|
||||
self.env.cr.execute(query, rows_by_type['model'])
|
||||
|
||||
if rows_by_type['model_terms']:
|
||||
query = """
|
||||
INSERT INTO ir_translation (name, lang, res_id, src, type,
|
||||
module, value, state, comments)
|
||||
VALUES {}
|
||||
ON CONFLICT (type, name, lang, res_id, md5(src))
|
||||
DO UPDATE SET (name, lang, res_id, src, type, value, module, state, comments) =
|
||||
(EXCLUDED.name, EXCLUDED.lang, EXCLUDED.res_id, EXCLUDED.src, EXCLUDED.type,
|
||||
EXCLUDED.value, EXCLUDED.module, EXCLUDED.state, EXCLUDED.comments)
|
||||
WHERE EXCLUDED.value IS NOT NULL AND EXCLUDED.value != '';
|
||||
""".format(", ".join(["%s"] * len(rows_by_type['model_terms'])))
|
||||
self.env.cr.execute(query, rows_by_type['model_terms'])
|
||||
|
||||
@api.model
|
||||
def translate_fields(self, model, id, field=None):
|
||||
""" Open a view for translating the field(s) of the record (model, id). """
|
||||
|
||||
@@ -115,7 +115,7 @@ class Currency(models.Model):
|
||||
fractional_value = int(parts[2] or 0)
|
||||
|
||||
lang_code = self.env.context.get('lang') or self.env.user.lang
|
||||
lang = self.env['res.lang'].search([('code', '=', lang_code)])
|
||||
lang = self.env['res.lang'].with_context(active_test=False).search([('code', '=', lang_code)])
|
||||
amount_words = tools.ustr('{amt_value} {amt_word}').format(
|
||||
amt_value=_num2words(integer_value, lang=lang.iso_code),
|
||||
amt_word=self.currency_unit_label,
|
||||
|
||||
+5
-1
@@ -945,7 +945,11 @@ class Field(MetaField('DummyField', (object,), {})):
|
||||
"""
|
||||
indexname = '%s_%s_index' % (model._table, self.name)
|
||||
if self.index:
|
||||
sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name])
|
||||
try:
|
||||
with model._cr.savepoint():
|
||||
sql.create_index(model._cr, indexname, model._table, ['"%s"' % self.name])
|
||||
except psycopg2.OperationalError:
|
||||
_schema.error("Unable to add index for %s", self)
|
||||
else:
|
||||
sql.drop_index(model._cr, indexname, model._table)
|
||||
|
||||
|
||||
+3
-2
@@ -4311,11 +4311,12 @@ Fields:
|
||||
if vals['lang'] == old.env.lang and field.translate is True:
|
||||
# force a source if the new_val was not changed by copy override
|
||||
if new_val == old[name]:
|
||||
vals['source'] = old_wo_lang[name]
|
||||
new_wo_lang[name] = old_wo_lang[name]
|
||||
vals['src'] = old_wo_lang[name]
|
||||
# the value should be the new value (given by copy())
|
||||
vals['value'] = new_val
|
||||
vals_list.append(vals)
|
||||
Translation.create(vals_list)
|
||||
Translation._upsert_translations(vals_list)
|
||||
|
||||
@api.multi
|
||||
@api.returns('self', lambda value: value.id)
|
||||
|
||||
Reference in New Issue
Block a user