[MERGE] forward port branch 12.0 up to 9c742d0c65
This commit is contained in:
@@ -568,6 +568,9 @@ class AccountInvoice(models.Model):
|
||||
for field in changed_fields:
|
||||
if field not in vals and invoice[field]:
|
||||
vals[field] = invoice._fields[field].convert_to_write(invoice[field], invoice)
|
||||
bank_account = self._get_default_bank_id(vals.get('type'), vals.get('company_id'))
|
||||
if bank_account and not vals.get('partner_bank_id'):
|
||||
vals['partner_bank_id'] = bank_account.id
|
||||
|
||||
invoice = super(AccountInvoice, self.with_context(mail_create_nolog=True)).create(vals)
|
||||
|
||||
@@ -603,14 +606,20 @@ class AccountInvoice(models.Model):
|
||||
"""
|
||||
res = super(AccountInvoice, self).default_get(default_fields)
|
||||
|
||||
if res.get('type', False) not in ('out_invoice', 'in_refund') or not 'company_id' in res:
|
||||
return res
|
||||
|
||||
partner_bank_result = self._get_partner_bank_id(res['company_id'])
|
||||
partner_bank_result = self._get_default_bank_id(res.get('type'), res.get('company_id'))
|
||||
if partner_bank_result:
|
||||
res['partner_bank_id'] = partner_bank_result.id
|
||||
return res
|
||||
|
||||
def _get_default_bank_id(self, type, company_id):
|
||||
"""When setting the default bank account, we have two cases:
|
||||
in the case of 'out_invoice', 'in_refund', we want the bank account of the company.
|
||||
otherwise, we want the bank account of the partner.
|
||||
"""
|
||||
if type not in ('out_invoice', 'in_refund') or not company_id:
|
||||
return False
|
||||
return self._get_partner_bank_id(company_id)
|
||||
|
||||
def _get_partner_bank_id(self, company_id):
|
||||
company = self.env['res.company'].browse(company_id)
|
||||
if company.partner_id:
|
||||
|
||||
@@ -240,9 +240,9 @@ class ResPartner(models.Model):
|
||||
LEFT JOIN account_move_line aml ON aml.partner_id = partner.id
|
||||
RIGHT JOIN account_account acc ON aml.account_id = acc.id
|
||||
WHERE acc.internal_type = %s
|
||||
AND NOT acc.deprecated
|
||||
AND NOT acc.deprecated AND acc.company_id = %s
|
||||
GROUP BY partner.id
|
||||
HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, sign, operand))
|
||||
HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.user.company_id.id, sign, operand))
|
||||
res = self._cr.fetchall()
|
||||
if not res:
|
||||
return [('id', '=', '0')]
|
||||
|
||||
@@ -215,7 +215,8 @@ class ReportAgedPartnerBalance(models.AbstractModel):
|
||||
total[(i + 1)] += values['total']
|
||||
values['partner_id'] = partner['partner_id']
|
||||
if partner['partner_id']:
|
||||
browsed_partner = self.env['res.partner'].browse(partner['partner_id'])
|
||||
#browse the partner name and trust field in sudo, as we may not have full access to the record (but we still have to see it in the report)
|
||||
browsed_partner = self.env['res.partner'].sudo().browse(partner['partner_id'])
|
||||
values['name'] = browsed_partner.name and len(browsed_partner.name) >= 45 and browsed_partner.name[0:40] + '...' or browsed_partner.name
|
||||
values['trust'] = browsed_partner.trust
|
||||
else:
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<field name="composer_id" invisible="1"/>
|
||||
<field name="invoice_ids" invisible="1"/>
|
||||
<field name="email_from" invisible="1" />
|
||||
<field name="mail_server_id" invisible="1"/>
|
||||
<div name="option_print">
|
||||
<field name="is_print" />
|
||||
<b><label for="is_print"/></b>
|
||||
|
||||
@@ -304,7 +304,7 @@ class AccountVoucher(models.Model):
|
||||
'payment_id': self._context.get('payment_id'),
|
||||
}
|
||||
# Create one line per tax and fix debit-credit for the move line if there are tax included
|
||||
if (line.tax_ids):
|
||||
if (line.tax_ids and tax_calculation_rounding_method == 'round_per_line'):
|
||||
tax_group = line.tax_ids.compute_all(self._convert(line.price_unit), self.company_id.currency_id, line.quantity, line.product_id, self.partner_id)
|
||||
if move_line['debit']: move_line['debit'] = tax_group['total_excluded']
|
||||
if move_line['credit']: move_line['credit'] = tax_group['total_excluded']
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime
|
||||
from pprint import pformat
|
||||
import json
|
||||
import logging
|
||||
|
||||
@@ -184,29 +183,14 @@ class GoogleService(models.TransientModel):
|
||||
except:
|
||||
pass
|
||||
except requests.HTTPError as error:
|
||||
# https://developers.google.com/calendar/v3/errors
|
||||
if error.response.status_code in (204, 404):
|
||||
status = error.response.status_code
|
||||
response = ""
|
||||
else:
|
||||
try:
|
||||
exception_req = pformat(json.loads(error.request.body))
|
||||
except:
|
||||
exception_req = error.request.body
|
||||
|
||||
try:
|
||||
exception_res = pformat(error.response.json())
|
||||
error_msg = _("Error while requesting Google Services: %s") % res['error']['message']
|
||||
warning_msg = _("Something went wrong with your request to google: %s") % res['error']['message']
|
||||
except:
|
||||
exception_res = error.response.text
|
||||
error_msg = _("Error while requesting Google Services")
|
||||
warning_msg = _("Something went wrong with your request to google")
|
||||
|
||||
_logger.exception("Error while requesting Google Services\nRequest:\n%s\nResponse:\n%s", exception_req, exception_res)
|
||||
_logger.exception("Bad google request : %s !", error.response.content)
|
||||
if error.response.status_code in (400, 401, 410):
|
||||
raise UserError(error_msg)
|
||||
raise self.env['res.config.settings'].get_config_warning(warning_msg)
|
||||
raise error
|
||||
raise self.env['res.config.settings'].get_config_warning(_("Something went wrong with your request to google"))
|
||||
return (status, response, ask_time)
|
||||
|
||||
# TODO : remove me, it is only used in google calendar. Make google_calendar use the constants
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<report
|
||||
id="action_report_holidayssummary"
|
||||
string="Time Off Summary"
|
||||
model="hr.leave"
|
||||
model="hr.holidays.summary.dept"
|
||||
report_type="qweb-pdf"
|
||||
name="hr_holidays.report_holidayssummary"
|
||||
file="hr_holidays.report_holidayssummary"
|
||||
|
||||
@@ -183,13 +183,14 @@ class EscposDriver(Thread):
|
||||
except NoStatusError as e:
|
||||
print("Impossible to get the status of the printer %s" % e)
|
||||
except Exception as e:
|
||||
self.set_status('error', e)
|
||||
_logger.exception()
|
||||
self.set_status('error')
|
||||
_logger.exception(e)
|
||||
finally:
|
||||
if error:
|
||||
self.queue.put((timestamp, task, data))
|
||||
if printer:
|
||||
printer.close()
|
||||
printer = None
|
||||
|
||||
def push_task(self,task, data = None):
|
||||
self.lockedstart()
|
||||
|
||||
@@ -307,7 +307,7 @@ class AccountFrFec(models.TransientModel):
|
||||
TO_CHAR(am.date, 'YYYYMMDD') AS PieceDate,
|
||||
CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/'
|
||||
WHEN aml.name SIMILAR TO '[\t|\s|\n]*' THEN '/'
|
||||
ELSE replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', '') END AS EcritureLib,
|
||||
ELSE replace(replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', ''), '\r', '') END AS EcritureLib,
|
||||
replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit,
|
||||
replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit,
|
||||
CASE WHEN rec.name IS NULL THEN '' ELSE rec.name END AS EcritureLet,
|
||||
|
||||
@@ -534,6 +534,7 @@ class MailTemplate(models.Model):
|
||||
'message': self.env['mail.message'].sudo().new(dict(body=values['body_html'], record_name=record.display_name)),
|
||||
'model_description': self.env['ir.model']._get(record._name).display_name,
|
||||
'company': 'company_id' in record and record['company_id'] or self.env.user.company_id,
|
||||
'record': record,
|
||||
}
|
||||
body = template.render(template_ctx, engine='ir.qweb', minimal_qcontext=True)
|
||||
values['body_html'] = self.env['mail.thread']._replace_local_links(body)
|
||||
|
||||
@@ -1730,8 +1730,11 @@ exports.Orderline = Backbone.Model.extend({
|
||||
},
|
||||
_compute_all: function(tax, base_amount, quantity) {
|
||||
if (tax.amount_type === 'fixed') {
|
||||
var sign_base_amount = base_amount >= 0 ? 1 : -1;
|
||||
return (Math.abs(tax.amount) * sign_base_amount) * quantity;
|
||||
var sign_base_amount = Math.sign(base_amount) || 1;
|
||||
// Since base amount has been computed with quantity
|
||||
// we take the abs of quantity
|
||||
// Same logic as bb72dea98de4dae8f59e397f232a0636411d37ce
|
||||
return tax.amount * sign_base_amount * Math.abs(quantity);
|
||||
}
|
||||
if ((tax.amount_type === 'percent' && !tax.price_include) || (tax.amount_type === 'division' && tax.price_include)){
|
||||
return base_amount * tax.amount / 100;
|
||||
|
||||
@@ -33,7 +33,7 @@ class PosMakePayment(models.TransientModel):
|
||||
journal_id = fields.Many2one('account.journal', string='Payment Mode', required=True, default=_default_journal)
|
||||
amount = fields.Float(digits=0, required=True, default=_default_amount)
|
||||
payment_name = fields.Char(string='Payment Reference')
|
||||
payment_date = fields.Date(string='Payment Date', required=True, default=lambda *a: fields.Date.today())
|
||||
payment_date = fields.Date(string='Payment Date', required=True, default=lambda self: fields.Date.context_today(self))
|
||||
|
||||
@api.onchange('session_id')
|
||||
def _on_change_session(self):
|
||||
|
||||
@@ -29,6 +29,7 @@ following:
|
||||
'views/pos_mercury_views.xml',
|
||||
'views/pos_mercury_transaction_templates.xml',
|
||||
'views/pos_config_setting_views.xml',
|
||||
'views/pos_config_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/pos_mercury_demo.xml',
|
||||
|
||||
@@ -509,7 +509,7 @@ PaymentScreenWidget.include({
|
||||
self.credit_code_transaction(parsed_result);
|
||||
} else { // this is for supporting another payment system like mercury
|
||||
this.gui.show_popup('selection',{
|
||||
title: 'Pay ' + this.pos.get_order().get_due().toFixed(2) + ' with : ',
|
||||
title: _t('Pay with: '),
|
||||
list: online_payment_journals,
|
||||
confirm: function (item) {
|
||||
parsed_result.journal_id = item;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="pos_config_view_form_inherit_pos_mercury" model="ir.ui.view">
|
||||
<field name="name">pos.config.form.view.inherit.mercury</field>
|
||||
<field name="model">pos.config</field>
|
||||
<field name="inherit_id" ref="point_of_sale.pos_config_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<label for="iface_scan_via_proxy" position="attributes">
|
||||
<attribute name="string">Barcode Scanner/Card Reader</attribute>
|
||||
</label>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -45,8 +45,12 @@ class StockMove(models.Model):
|
||||
if line.product_uom.id != line.product_id.uom_id.id:
|
||||
price_unit *= line.product_uom.factor / line.product_id.uom_id.factor
|
||||
if order.currency_id != order.company_id.currency_id:
|
||||
# The date must be today, and not the date of the move since the move move is still
|
||||
# in assigned state. However, the move date is the scheduled date until move is
|
||||
# done, then date of actual move processing. See:
|
||||
# https://github.com/odoo/odoo/blob/2f789b6863407e63f90b3a2d4cc3be09815f7002/addons/stock/models/stock_move.py#L36
|
||||
price_unit = order.currency_id._convert(
|
||||
price_unit, order.company_id.currency_id, order.company_id, self.date, round=False)
|
||||
price_unit, order.company_id.currency_id, order.company_id, fields.Date.context_today(self), round=False)
|
||||
return price_unit
|
||||
return super(StockMove, self)._get_price_unit()
|
||||
|
||||
|
||||
@@ -821,12 +821,12 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
'company_id': company.id,
|
||||
})
|
||||
|
||||
# To allow testing validation of PO
|
||||
# To allow testing validation of PO and Delivery
|
||||
today = date_po
|
||||
def _today(*args, **kwargs):
|
||||
return date_po
|
||||
# To allow testing validation of Delivery
|
||||
return today
|
||||
def _now(*args, **kwargs):
|
||||
return date_delivery + ' 01:00:00'
|
||||
return today + ' 01:00:00'
|
||||
|
||||
patchers = [
|
||||
patch('odoo.fields.Date.context_today', _today),
|
||||
@@ -855,13 +855,17 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
|
||||
line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg)
|
||||
|
||||
today = date_delivery
|
||||
picking = po.picking_ids
|
||||
(picking.move_lines
|
||||
.filtered(lambda l: l.purchase_line_id == line_product_avg)
|
||||
.write({'quantity_done': 1.0}))
|
||||
|
||||
picking.button_validate()
|
||||
# 1 Unit received at rate 0.7 = 42.86
|
||||
self.assertAlmostEqual(product_avg.standard_price, 42.86)
|
||||
|
||||
today = date_invoice
|
||||
inv = self.env['account.invoice'].create({
|
||||
'type': 'in_invoice',
|
||||
'date_invoice': date_invoice,
|
||||
@@ -944,7 +948,7 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
product_avg = self.product1.product_tmpl_id.copy({
|
||||
'purchase_method': 'purchase',
|
||||
'name': 'AVG',
|
||||
'standard_price': 60,
|
||||
'standard_price': 0,
|
||||
'property_account_creditor_price_difference': self.price_diff_account.id
|
||||
}).product_variant_id
|
||||
product_avg.invoice_policy = 'order'
|
||||
@@ -991,13 +995,12 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
'company_id': company.id,
|
||||
})
|
||||
|
||||
# To allow testing validation of PO
|
||||
# To allow testing validation of PO and Delivery
|
||||
today = date_po
|
||||
def _today(*args, **kwargs):
|
||||
return date_po
|
||||
# To allow testing validation of Delivery
|
||||
delivery_now = date_delivery
|
||||
return today
|
||||
def _now(*args, **kwargs):
|
||||
return delivery_now + ' 01:00:00'
|
||||
return today + ' 01:00:00'
|
||||
|
||||
patchers = [
|
||||
patch('odoo.fields.Date.context_today', _today),
|
||||
@@ -1027,6 +1030,7 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
|
||||
line_product_avg = po.order_line.filtered(lambda l: l.product_id == product_avg)
|
||||
|
||||
today = date_delivery
|
||||
picking = po.picking_ids
|
||||
(picking.move_lines
|
||||
.filtered(lambda l: l.purchase_line_id == line_product_avg)
|
||||
@@ -1034,7 +1038,10 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
|
||||
picking.button_validate()
|
||||
picking.action_done() # Create Backorder
|
||||
# 5 Units received at rate 0.7 = 42.86
|
||||
self.assertAlmostEqual(product_avg.standard_price, 42.86)
|
||||
|
||||
today = date_invoice
|
||||
inv = self.env['account.invoice'].create({
|
||||
'type': 'in_invoice',
|
||||
'date_invoice': date_invoice,
|
||||
@@ -1057,13 +1064,16 @@ class TestStockValuationWithCOA(AccountingTestCase):
|
||||
|
||||
inv.action_invoice_open()
|
||||
|
||||
today = date_delivery1
|
||||
backorder_picking = self.env['stock.picking'].search([('backorder_id', '=', picking.id)])
|
||||
delivery_now = date_delivery1
|
||||
(backorder_picking.move_lines
|
||||
.filtered(lambda l: l.purchase_line_id == line_product_avg)
|
||||
.write({'quantity_done': 5.0}))
|
||||
backorder_picking.button_validate()
|
||||
# 5 Units received at rate 0.7 (42.86) + 5 Units received at rate 0.8 (37.50) = 40.18
|
||||
self.assertAlmostEqual(product_avg.standard_price, 40.18)
|
||||
|
||||
today = date_invoice1
|
||||
inv1 = self.env['account.invoice'].create({
|
||||
'type': 'in_invoice',
|
||||
'date_invoice': date_invoice1,
|
||||
|
||||
@@ -931,7 +931,7 @@ var VideoWidget = MediaWidget.extend({
|
||||
return {errorCode: 0};
|
||||
}
|
||||
|
||||
var autoplay = options.autoplay ? '?autoplay=1' : '?autoplay=0';
|
||||
var autoplay = options.autoplay ? '?autoplay=1&mute=1' : '?autoplay=0';
|
||||
|
||||
if (ytMatch && ytMatch[2].length === 11) {
|
||||
$video.attr('src', '//www.youtube' + (ytMatch[1] || '') + '.com/embed/' + ytMatch[2] + autoplay);
|
||||
@@ -960,7 +960,8 @@ var VideoWidget = MediaWidget.extend({
|
||||
$video.attr('src', $video.attr('src') + '&rel=0');
|
||||
}
|
||||
if (options.loop && (ytMatch || vimMatch)) {
|
||||
$video.attr('src', $video.attr('src') + '&loop=1');
|
||||
var videoSrc = _.str.sprintf('%s&loop=1', $video.attr('src'));
|
||||
$video.attr('src', ytMatch ? _.str.sprintf('%s&playlist=%s', videoSrc, ytMatch[2]) : videoSrc);
|
||||
}
|
||||
if (options.hide_controls && (ytMatch || dmMatch)) {
|
||||
$video.attr('src', $video.attr('src') + '&controls=0');
|
||||
|
||||
@@ -252,7 +252,11 @@
|
||||
</div>
|
||||
<div class="o_video_dialog_options d-none mt-4">
|
||||
<div class="o_yt_option o_vim_option o_dm_option">
|
||||
<label class="o_switch mb0"><input id="o_video_autoplay" type="checkbox"/><span/>Autoplay</label>
|
||||
<label class="o_switch mb0">
|
||||
<input id="o_video_autoplay" type="checkbox"/><span/>
|
||||
<span style="margin-right: 8px;">Autoplay</span>
|
||||
<span class="small text-muted" style="margin-left: auto;">Videos are muted when autoplay is enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="o_yt_option o_vim_option">
|
||||
<label class="o_switch mb0"><input id="o_video_loop" type="checkbox"/><span/>Loop</label>
|
||||
|
||||
@@ -18,3 +18,4 @@ from . import res_company
|
||||
from . import res_partner
|
||||
from . import res_users
|
||||
from . import res_config_settings
|
||||
from . import res_lang
|
||||
|
||||
@@ -18,7 +18,7 @@ class ResConfigSettings(models.TransientModel):
|
||||
website_domain = fields.Char('Website Domain', related='website_id.domain', readonly=False)
|
||||
website_country_group_ids = fields.Many2many(related='website_id.country_group_ids', readonly=False)
|
||||
website_company_id = fields.Many2one(related='website_id.company_id', string='Website Company', readonly=False)
|
||||
language_ids = fields.Many2many(related='website_id.language_ids', relation='res.lang', readonly=False)
|
||||
language_ids = fields.Many2many(related='website_id.language_ids', relation='res.lang', readonly=False, required=True)
|
||||
language_count = fields.Integer(string='Number of languages', compute='_compute_language_count', readonly=True)
|
||||
website_default_lang_id = fields.Many2one(
|
||||
string='Default language', related='website_id.default_lang_id', readonly=False,
|
||||
@@ -106,7 +106,9 @@ class ResConfigSettings(models.TransientModel):
|
||||
def _onchange_language_ids(self):
|
||||
# If current default language is removed from language_ids
|
||||
# update the website_default_lang_id
|
||||
if self.language_ids and self.website_default_lang_id not in self.language_ids:
|
||||
if not self.language_ids:
|
||||
self.website_default_lang_id = False
|
||||
elif self.website_default_lang_id not in self.language_ids:
|
||||
self.website_default_lang_id = self.language_ids[0]
|
||||
|
||||
@api.depends('language_ids')
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class Lang(models.Model):
|
||||
_inherit = "res.lang"
|
||||
|
||||
@api.multi
|
||||
def write(self, vals):
|
||||
if 'active' in vals and not vals['active']:
|
||||
if self.env['website'].search([('language_ids', 'in', self._ids)]):
|
||||
raise UserError(_("Cannot deactivate a language that is currently used on a website."))
|
||||
return super(Lang, self).write(vals)
|
||||
@@ -2,10 +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.addons.website.tools import MockRequest
|
||||
from odoo.modules.module import get_module_resource
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
@@ -66,42 +65,6 @@ class TestQweb(TransactionCase):
|
||||
"user_id": demo.id,
|
||||
}).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):
|
||||
@@ -128,7 +91,7 @@ class TestQwebProcessAtt(TransactionCase):
|
||||
self._test_att('/a', {'href': '/a'})
|
||||
|
||||
def test_process_att_no_website(self):
|
||||
with MockRequest(self.env) as request:
|
||||
with MockRequest(self.env):
|
||||
# no website so URL rewriting
|
||||
self._test_att('/', {'href': '/'})
|
||||
self._test_att('/en_US/', {'href': '/en_US/'})
|
||||
@@ -137,7 +100,7 @@ class TestQwebProcessAtt(TransactionCase):
|
||||
self._test_att('/a', {'href': '/a'})
|
||||
|
||||
def test_process_att_monolang_route(self):
|
||||
with MockRequest(self.env, website=self.website, multilang=False) as request:
|
||||
with MockRequest(self.env, website=self.website, multilang=False):
|
||||
# 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'})
|
||||
@@ -145,19 +108,19 @@ class TestQwebProcessAtt(TransactionCase):
|
||||
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:
|
||||
with MockRequest(self.env, website=self.website):
|
||||
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:
|
||||
with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}):
|
||||
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:
|
||||
with MockRequest(self.env, website=self.website):
|
||||
# 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'})
|
||||
@@ -167,13 +130,13 @@ class TestQwebProcessAtt(TransactionCase):
|
||||
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:
|
||||
with MockRequest(self.env, website=self.website, context={'lang': 'fr_FR'}, routing=False):
|
||||
# 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:
|
||||
with MockRequest(self.env, website=self.website) as request:
|
||||
# #{fragment} is stripped from URL when testing route
|
||||
self._test_att('/x#y?z', {'href': '/x#y?z'})
|
||||
self.assertEqual(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import odoo
|
||||
import werkzeug
|
||||
|
||||
from odoo.tools import DotDict
|
||||
|
||||
|
||||
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, **kw):
|
||||
app = MockObject(routing={
|
||||
'type': 'http',
|
||||
'website': True,
|
||||
'multilang': kw.get('multilang', True),
|
||||
})
|
||||
app.get_db_router = app.bind = app.match = app
|
||||
if not kw.get('routing', True):
|
||||
app.match = werkzeugRaiseNotFound
|
||||
self.request = DotDict({
|
||||
'context': kw.get('context', {}),
|
||||
'db': None,
|
||||
'debug': False,
|
||||
'env': env,
|
||||
'httprequest': {
|
||||
'path': '/hello/',
|
||||
'app': app,
|
||||
},
|
||||
'redirect': werkzeug.utils.redirect,
|
||||
'session': {
|
||||
'geoip': {
|
||||
'country_code': kw.get('country_code'),
|
||||
},
|
||||
'sale_order_id': kw.get('sale_order_id'),
|
||||
},
|
||||
'website': kw.get('website'),
|
||||
})
|
||||
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()
|
||||
@@ -192,7 +192,8 @@ class WebsiteCrmPartnerAssign(WebsitePartnerPage):
|
||||
if not qs or qs.lower() in loc:
|
||||
yield {'loc': loc}
|
||||
|
||||
partners_dom = [('is_company', '=', True), ('grade_id', '!=', False), ('website_published', '=', True), ('grade_id.website_published', '=', True)]
|
||||
partners_dom = [('is_company', '=', True), ('grade_id', '!=', False), ('website_published', '=', True),
|
||||
('grade_id.website_published', '=', True), ('country_id', '!=', False)]
|
||||
dom += sitemap_qs2dom(qs=qs, route='/partners/country/')
|
||||
countries = env['res.partner'].sudo().read_group(partners_dom, fields=['id', 'country_id'], groupby='country_id')
|
||||
for country in countries:
|
||||
|
||||
@@ -129,6 +129,8 @@ class WebsiteForm(http.Controller):
|
||||
# If it's not, we'll use attachments instead
|
||||
if field_name in authorized_fields and authorized_fields[field_name]['type'] == 'binary':
|
||||
data['record'][field_name] = base64.b64encode(field_value.read())
|
||||
if authorized_fields[field_name]['manual']:
|
||||
data['record'][field_name + "_filename"] = field_value.filename
|
||||
else:
|
||||
field_value.field_name = field_name
|
||||
data['attachments'].append(field_value)
|
||||
|
||||
@@ -609,10 +609,11 @@ class WebsiteSale(ProductConfiguratorController):
|
||||
new_values['customer'] = True
|
||||
new_values['team_id'] = request.website.salesteam_id and request.website.salesteam_id.id
|
||||
new_values['user_id'] = request.website.salesperson_id and request.website.salesperson_id.id
|
||||
new_values['website_id'] = request.website.id
|
||||
|
||||
if order.partner_id.id == request.website.user_id.sudo().partner_id.id:
|
||||
# If the partner is public, we assign the company of the website
|
||||
if request.website.specific_user_account:
|
||||
new_values['website_id'] = request.website.id
|
||||
|
||||
if mode[0] == 'new':
|
||||
new_values['company_id'] = request.website.company_id.id
|
||||
|
||||
lang = request.lang if request.lang in request.website.mapped('language_ids.code') else None
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import odoo.tests
|
||||
|
||||
from odoo import api
|
||||
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
||||
from odoo.addons.website.tools import MockRequest
|
||||
|
||||
|
||||
@odoo.tests.tagged('post_install', '-at_install')
|
||||
class TestUi(odoo.tests.HttpCase):
|
||||
@@ -14,4 +19,114 @@ class TestUi(odoo.tests.HttpCase):
|
||||
def test_03_demo_checkout(self):
|
||||
self.phantom_js("/", "odoo.__DEBUG__.services['web_tour.tour'].run('shop_buy_product')", "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_buy_product.ready", login="demo")
|
||||
|
||||
# TO DO - add public test with new address when convert to web.tour format.
|
||||
|
||||
@odoo.tests.tagged('post_install', '-at_install')
|
||||
class TestWebsiteSaleCheckoutAddress(odoo.tests.TransactionCase):
|
||||
''' The goal of this method class is to test the address management on
|
||||
the checkout (new/edit billing/shipping, company_id, website_id..).
|
||||
'''
|
||||
def setUp(self):
|
||||
super(TestWebsiteSaleCheckoutAddress, self).setUp()
|
||||
self.website = self.env['website'].browse(1)
|
||||
self.country_id = self.env['res.country'].search([], limit=1).id
|
||||
self.WebsiteSaleController = WebsiteSale()
|
||||
self.default_address_values = {
|
||||
'name': 'a res.partner address', 'email': 'email@email.email', 'street': 'ooo',
|
||||
'city': 'ooo', 'country_id': self.country_id, 'submitted': 1,
|
||||
}
|
||||
|
||||
def _create_so(self, partner_id=None):
|
||||
return self.env['sale.order'].create({
|
||||
'partner_id': partner_id,
|
||||
'website_id': self.website.id,
|
||||
'order_line': [(0, 0, {
|
||||
'product_id': self.env['product.product'].create({'name': 'Product A', 'list_price': 100}).id,
|
||||
'name': 'Product A',
|
||||
})]
|
||||
})
|
||||
|
||||
def _get_last_address(self, partner):
|
||||
''' Useful to retrieve the last created shipping address '''
|
||||
return partner.child_ids.sorted('id', reverse=True)[0]
|
||||
|
||||
# TEST WEBSITE
|
||||
def test_01_create_shipping_address_specific_user_account(self):
|
||||
''' Ensure `website_id` is correctly set (specific_user_account) '''
|
||||
p = self.env.user.partner_id
|
||||
so = self._create_so(p.id)
|
||||
|
||||
with MockRequest(self.env, website=self.website, sale_order_id=so.id):
|
||||
self.WebsiteSaleController.address(**self.default_address_values)
|
||||
self.assertFalse(self._get_last_address(p).website_id, "New shipping address should not have a website set on it (no specific_user_account).")
|
||||
|
||||
self.website.specific_user_account = True
|
||||
|
||||
self.WebsiteSaleController.address(**self.default_address_values)
|
||||
self.assertEqual(self._get_last_address(p).website_id, self.website, "New shipping address should have a website set on it (specific_user_account).")
|
||||
|
||||
# TEST COMPANY
|
||||
def _setUp_multicompany_env(self):
|
||||
''' Have 2 companies A & B.
|
||||
Have 1 website 1 which company is B
|
||||
Have admin on company A
|
||||
'''
|
||||
self.company_a = self.env['res.company'].create({
|
||||
'name': 'Company A',
|
||||
})
|
||||
self.company_b = self.env['res.company'].create({
|
||||
'name': 'Company B',
|
||||
})
|
||||
self.company_c = self.env['res.company'].create({
|
||||
'name': 'Company C',
|
||||
})
|
||||
self.website.company_id = self.company_b
|
||||
self.env.user.company_id = self.company_a
|
||||
|
||||
self.demo_user = self.env.ref('base.user_demo')
|
||||
self.demo_user.company_ids += self.company_c
|
||||
self.demo_user.company_id = self.company_c
|
||||
self.demo_partner = self.demo_user.partner_id
|
||||
|
||||
def test_02_demo_address_and_company(self):
|
||||
''' This test ensure that the company_id of the address (partner) is
|
||||
correctly set and also, is not wrongly changed.
|
||||
eg: new shipping should use the company of the website and not the
|
||||
one from the admin, and editing a billing should not change its
|
||||
company.
|
||||
'''
|
||||
self._setUp_multicompany_env()
|
||||
so = self._create_so(self.demo_partner.id)
|
||||
|
||||
env = api.Environment(self.env.cr, self.demo_user.id, {})
|
||||
# change also website env for `sale_get_order` to not change order partner_id
|
||||
with MockRequest(env, website=self.website.with_env(env), sale_order_id=so.id):
|
||||
# 1. Logged in user, new shipping
|
||||
self.WebsiteSaleController.address(**self.default_address_values)
|
||||
new_shipping = self._get_last_address(self.demo_partner)
|
||||
self.assertTrue(new_shipping.company_id != self.env.user.company_id, "Logged in user new shipping should not get the company of the sudo() neither the one from it's partner..")
|
||||
self.assertEqual(new_shipping.company_id, self.website.company_id, ".. but the one from the website.")
|
||||
|
||||
# 2. Logged in user, edit billing
|
||||
self.default_address_values['partner_id'] = self.demo_partner.id
|
||||
self.WebsiteSaleController.address(**self.default_address_values)
|
||||
self.assertEqual(self.demo_partner.company_id, self.company_c, "Logged in user edited billing (the partner itself) should not get its company modified.")
|
||||
|
||||
def test_03_public_user_address_and_company(self):
|
||||
''' Same as test_02 but with public user '''
|
||||
self._setUp_multicompany_env()
|
||||
so = self._create_so(self.website.user_id.partner_id.id)
|
||||
|
||||
env = api.Environment(self.env.cr, self.website.user_id.id, {})
|
||||
# change also website env for `sale_get_order` to not change order partner_id
|
||||
with MockRequest(env, website=self.website.with_env(env), sale_order_id=so.id):
|
||||
# 1. Public user, new billing
|
||||
self.default_address_values['partner_id'] = -1
|
||||
self.WebsiteSaleController.address(**self.default_address_values)
|
||||
new_partner = so.partner_id
|
||||
self.assertNotEqual(new_partner, self.website.user_id.partner_id, "New billing should have created a new partner and assign it on the SO")
|
||||
self.assertEqual(new_partner.company_id, self.website.company_id, "The new partner should get the company of the website")
|
||||
|
||||
# 2. Public user, edit billing
|
||||
self.default_address_values['partner_id'] = new_partner.id
|
||||
self.WebsiteSaleController.address(**self.default_address_values)
|
||||
self.assertEqual(new_partner.company_id, self.website.company_id, "Public user edited billing (the partner itself) should not get its company modified.")
|
||||
|
||||
@@ -5,6 +5,7 @@ try:
|
||||
except ImportError:
|
||||
from mock import patch
|
||||
from odoo.tests.common import HttpCase, TransactionCase
|
||||
from odoo.tools import DotDict
|
||||
|
||||
''' /!\/!\
|
||||
Calling `get_pricelist_available` after setting `property_product_pricelist` on
|
||||
@@ -154,13 +155,6 @@ def simulate_frontend_context(self, website_id=1):
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
|
||||
class DotDict(dict):
|
||||
"""dot.notation access to dictionary attributes"""
|
||||
def __getattr__(*args):
|
||||
val = dict.get(*args)
|
||||
return DotDict(val) if type(val) is dict else val
|
||||
|
||||
|
||||
class TestWebsitePriceListAvailable(TransactionCase):
|
||||
# This is enough to avoid a mock (request.session/website do not exist during test)
|
||||
def get_pricelist_available(self, show_visible=False, website_id=1, country_code=None, website_sale_current_pl=None):
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<field name="name">Orders</field>
|
||||
<field name="res_model">sale.order</field>
|
||||
<field name="view_mode">tree,form,kanban,activity</field>
|
||||
<field name="domain">[]</field>
|
||||
<field name="context">{'show_sale': True, 'search_default_order_confirmed': 1, 'search_default_from_website': 1}</field>
|
||||
<field name="search_view_id" ref="view_sales_order_filter_ecommerce"/>
|
||||
<field name="help" type="html">
|
||||
|
||||
@@ -266,12 +266,18 @@ class Channel(models.Model):
|
||||
return _("Publishing is restricted to the responsible of training courses or members of the publisher group for documentation courses")
|
||||
|
||||
@api.multi
|
||||
@api.depends('name')
|
||||
def get_base_url(self):
|
||||
self.ensure_one()
|
||||
icp = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
return self.website_id and self.website_id._get_http_domain() or icp
|
||||
|
||||
@api.multi
|
||||
@api.depends('name', 'website_id.domain')
|
||||
def _compute_website_url(self):
|
||||
super(Channel, self)._compute_website_url()
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
for channel in self:
|
||||
if channel.id: # avoid to perform a slug on a not yet saved record in case of an onchange.
|
||||
base_url = channel.get_base_url()
|
||||
channel.website_url = '%s/slides/%s' % (base_url, slug(channel))
|
||||
|
||||
@api.multi
|
||||
|
||||
@@ -242,13 +242,13 @@ class Slide(models.Model):
|
||||
self[key] = value
|
||||
|
||||
@api.multi
|
||||
@api.depends('name')
|
||||
@api.depends('name', 'channel_id.website_id.domain')
|
||||
def _compute_website_url(self):
|
||||
# TDE FIXME: clena this link.tracker strange stuff
|
||||
super(Slide, self)._compute_website_url()
|
||||
base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
|
||||
for slide in self:
|
||||
if slide.id: # avoid to perform a slug on a not yet saved record in case of an onchange.
|
||||
base_url = slide.channel_id.get_base_url()
|
||||
# link_tracker is not in dependencies, so use it to shorten url only if installed.
|
||||
if self.env.registry.get('link.tracker'):
|
||||
url = self.env['link.tracker'].sudo().create({
|
||||
|
||||
@@ -46,3 +46,4 @@ Erick Birbe erick@vauxoo.com https://github.com/ebirbe
|
||||
Tomas Alvarez tomas@vauxoo.com https://github.com/tomeyro
|
||||
Carmen Liliana Miranda González carmen@vauxoo.com https://github.com/CarmenMiranda
|
||||
Arturo Flores arturo@vauxoo.com https://github.com/umiphos
|
||||
Deivis Laya deivis@vauxoo.com https://github.com/deivislaya
|
||||
|
||||
@@ -7,6 +7,7 @@ from odoo.tools.misc import find_in_path
|
||||
from odoo.tools import config
|
||||
from odoo.sql_db import TestCursor
|
||||
from odoo.http import request
|
||||
from odoo.osv.expression import NEGATIVE_TERM_OPERATORS, FALSE_DOMAIN
|
||||
|
||||
import time
|
||||
import base64
|
||||
@@ -24,6 +25,7 @@ from distutils.version import LooseVersion
|
||||
from reportlab.graphics.barcode import createBarcodeDrawing
|
||||
from PyPDF2 import PdfFileWriter, PdfFileReader
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -85,7 +87,8 @@ class IrActionsReport(models.Model):
|
||||
name = fields.Char(translate=True)
|
||||
type = fields.Char(default='ir.actions.report')
|
||||
binding_type = fields.Selection(default='report')
|
||||
model = fields.Char(required=True)
|
||||
model = fields.Char(required=True, string='Model Name')
|
||||
model_id = fields.Many2one('ir.model', string='Model', compute='_compute_model_id', search='_search_model_id')
|
||||
|
||||
report_type = fields.Selection([
|
||||
('qweb-html', 'HTML'),
|
||||
@@ -111,6 +114,32 @@ class IrActionsReport(models.Model):
|
||||
attachment = fields.Char(string='Save as Attachment Prefix',
|
||||
help='This is the filename of the attachment used to store the printing result. Keep empty to not save the printed reports. You can use a python expression with the object and time variables.')
|
||||
|
||||
@api.depends('model')
|
||||
def _compute_model_id(self):
|
||||
for action in self:
|
||||
action.model_id = self.env['ir.model']._get(action.model).id
|
||||
|
||||
def _search_model_id(self, operator, value):
|
||||
ir_model_ids = None
|
||||
if isinstance(value, str):
|
||||
names = self.env['ir.model'].name_search(value, operator=operator)
|
||||
ir_model_ids = [n[0] for n in names]
|
||||
|
||||
elif isinstance(value, Iterable):
|
||||
ir_model_ids = value
|
||||
|
||||
elif isinstance(value, int) and not isinstance(value, bool):
|
||||
ir_model_ids = [value]
|
||||
|
||||
if ir_model_ids:
|
||||
operator = 'not in' if operator in NEGATIVE_TERM_OPERATORS else 'in'
|
||||
ir_model = self.env['ir.model'].browse(ir_model_ids)
|
||||
return [('model', operator, ir_model.mapped('model'))]
|
||||
elif isinstance(value, bool) or value is None:
|
||||
return [('model', operator, value)]
|
||||
else:
|
||||
return FALSE_DOMAIN
|
||||
|
||||
@api.multi
|
||||
def associated_view(self):
|
||||
"""Used in the ir.actions.report form view in order to search naively after the view(s)
|
||||
|
||||
@@ -428,6 +428,16 @@ class IrAttachment(models.Model):
|
||||
|
||||
# sort result according to the original sort ordering
|
||||
result = [id for id in orig_ids if id in ids]
|
||||
|
||||
# If the original search reached the limit, it is important the
|
||||
# filtered record set does so too. When a JS view recieve a
|
||||
# record set whose length is bellow the limit, it thinks it
|
||||
# reached the last page.
|
||||
if len(orig_ids) == limit and len(result) < len(orig_ids):
|
||||
result.extend(self._search(args, offset=offset + len(orig_ids),
|
||||
limit=limit, order=order, count=count,
|
||||
access_rights_uid=access_rights_uid)[:limit - len(result)])
|
||||
|
||||
return len(result) if count else list(result)
|
||||
|
||||
@api.multi
|
||||
|
||||
@@ -279,6 +279,11 @@ class IrMailServer(models.Model):
|
||||
smtp_user = pycompat.to_text(ustr(smtp_user))
|
||||
smtp_password = pycompat.to_text(ustr(smtp_password))
|
||||
connection.login(smtp_user, smtp_password)
|
||||
|
||||
# Some methods of SMTP don't check whether EHLO/HELO was sent.
|
||||
# Anyway, as it may have been sent by login(), all subsequent usages should consider this command as sent.
|
||||
connection.ehlo_or_helo_if_needed()
|
||||
|
||||
return connection
|
||||
|
||||
def build_email(self, email_from, email_to, subject, body, email_cc=None, email_bcc=None, reply_to=False,
|
||||
|
||||
@@ -855,8 +855,13 @@ class IrModelFields(models.Model):
|
||||
keys = [key for key in new_vals if old_vals[key] != new_vals[key]]
|
||||
self.pool.post_init(record.modified, keys)
|
||||
old_vals.update(new_vals)
|
||||
if module and not field.manual and (module == model._original_module or module in field._modules):
|
||||
to_xmlids.append(name)
|
||||
if module and (module == model._original_module or module in field._modules):
|
||||
# remove this and only keep the else clause if version >= saas-12.4
|
||||
if field.manual:
|
||||
self.pool.loaded_xmlids.add(
|
||||
'%s.field_%s__%s' % (module, model._name.replace('.', '_'), name))
|
||||
else:
|
||||
to_xmlids.append(name)
|
||||
|
||||
if to_insert:
|
||||
# insert missing fields
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
"name": "Test RPC",
|
||||
"version": "1.0",
|
||||
"category": "Tests",
|
||||
"description": """A module to test the RPC requests.""",
|
||||
"depends": ["base", "web"],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
"data": ["ir.model.access.csv"],
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
|
||||
access_test_rpc_model_a,access_test_rpc_model_a,model_test_rpc_model_a,,1,1,1,1
|
||||
access_test_rpc_model_b,access_test_rpc_model_b,model_test_rpc_model_b,,1,1,1,1
|
||||
|
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ModelA(models.Model):
|
||||
_name = "test_rpc.model_a"
|
||||
_description = "Model A"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
field_b1 = fields.Many2one("test_rpc.model_b", string="required field", required=True)
|
||||
field_b2 = fields.Many2one("test_rpc.model_b", string="restricted field", ondelete="restrict")
|
||||
|
||||
|
||||
class ModelB(models.Model):
|
||||
_name = "test_rpc.model_b"
|
||||
_description = "Model B"
|
||||
|
||||
name = fields.Char(required=True)
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import test_error
|
||||
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from functools import partial
|
||||
|
||||
from odoo.tests import common
|
||||
from odoo.tools.misc import mute_logger
|
||||
|
||||
|
||||
class TestError(common.HttpCase):
|
||||
def setUp(self):
|
||||
super(TestError, self).setUp()
|
||||
uid = self.ref("base.user_admin")
|
||||
self.rpc = partial(self.xmlrpc_object.execute, common.get_db_name(), uid, "admin")
|
||||
|
||||
# Reset the admin's lang to avoid breaking tests due to admin not in English
|
||||
self.rpc("res.users", "write", [uid], {"lang": False})
|
||||
|
||||
def test_01_create(self):
|
||||
""" Create: mandatory field not provided """
|
||||
self.rpc("test_rpc.model_b", "create", {"name": "B1"})
|
||||
try:
|
||||
with mute_logger("odoo.sql_db"):
|
||||
self.rpc("test_rpc.model_b", "create", {})
|
||||
raise
|
||||
except Exception as e:
|
||||
self.assertIn("The operation cannot be completed:", e.faultString)
|
||||
self.assertIn("Create/update: a mandatory field is not set.", e.faultString)
|
||||
self.assertIn(
|
||||
"Delete: another model requires the record being deleted. If possible, archive it instead.",
|
||||
e.faultString,
|
||||
)
|
||||
self.assertIn("Model: Model B (test_rpc.model_b), Field: Name (name)", e.faultString)
|
||||
|
||||
def test_02_delete(self):
|
||||
""" Delete: NOT NULL and ON DELETE RESTRICT constraints """
|
||||
b1 = self.rpc("test_rpc.model_b", "create", {"name": "B1"})
|
||||
b2 = self.rpc("test_rpc.model_b", "create", {"name": "B2"})
|
||||
self.rpc("test_rpc.model_a", "create", {"name": "A1", "field_b1": b1, "field_b2": b2})
|
||||
|
||||
# Unlink b1 => NOT NULL constraint raises
|
||||
# FORWARD-PORT: in master, `required=True` implies `ondelete="restrict"`
|
||||
# => the message should be the same than 'Unlink b2' case.
|
||||
try:
|
||||
with mute_logger("odoo.sql_db"):
|
||||
self.rpc("test_rpc.model_b", "unlink", b1)
|
||||
raise
|
||||
except Exception as e:
|
||||
self.assertIn("The operation cannot be completed:", e.faultString)
|
||||
self.assertIn("Create/update: a mandatory field is not set.", e.faultString)
|
||||
self.assertIn(
|
||||
"Delete: another model requires the record being deleted. If possible, archive it instead.",
|
||||
e.faultString,
|
||||
)
|
||||
self.assertIn(
|
||||
"Model: Model A (test_rpc.model_a), Field: required field (field_b1)", e.faultString
|
||||
)
|
||||
|
||||
# Unlink b2 => ON DELETE RESTRICT constraint raises
|
||||
try:
|
||||
with mute_logger("odoo.sql_db"):
|
||||
self.rpc("test_rpc.model_b", "unlink", b2)
|
||||
raise
|
||||
except Exception as e:
|
||||
self.assertIn("The operation cannot be completed:", e.faultString)
|
||||
self.assertIn(
|
||||
" another model requires the record being deleted. If possible, archive it instead.",
|
||||
e.faultString,
|
||||
)
|
||||
self.assertIn(
|
||||
"Model: Model A (test_rpc.model_a), Constraint: test_rpc_model_a_field_b2_fkey",
|
||||
e.faultString,
|
||||
)
|
||||
+32
-14
@@ -118,22 +118,40 @@ def check(f):
|
||||
if key in inst.pgerror:
|
||||
raise ValidationError(tr(registry._sql_error[key], 'sql_constraint') or inst.pgerror)
|
||||
if inst.pgcode in (errorcodes.NOT_NULL_VIOLATION, errorcodes.FOREIGN_KEY_VIOLATION, errorcodes.RESTRICT_VIOLATION):
|
||||
msg = _('The operation cannot be completed, probably due to the following:\n- deletion: you may be trying to delete a record while other records still reference it\n- creation/update: a mandatory field is not correctly set')
|
||||
msg = _('The operation cannot be completed:')
|
||||
_logger.debug("IntegrityError", exc_info=True)
|
||||
try:
|
||||
errortxt = inst.pgerror.replace('«','"').replace('»','"')
|
||||
if '"public".' in errortxt:
|
||||
context = errortxt.split('"public".')[1]
|
||||
model_name = table = context.split('"')[1]
|
||||
else:
|
||||
last_quote_end = errortxt.rfind('"')
|
||||
last_quote_begin = errortxt.rfind('"', 0, last_quote_end)
|
||||
model_name = table = errortxt[last_quote_begin+1:last_quote_end].strip()
|
||||
model = table.replace("_",".")
|
||||
if model in registry:
|
||||
model_class = registry[model]
|
||||
model_name = model_class._description or model_class._name
|
||||
msg += _('\n\n[object with reference: %s - %s]') % (model_name, model)
|
||||
# Get corresponding model and field
|
||||
model = field = None
|
||||
for name, rclass in registry.items():
|
||||
if inst.diag.table_name == rclass._table:
|
||||
model = rclass
|
||||
field = model._fields.get(inst.diag.column_name)
|
||||
break
|
||||
if inst.pgcode == errorcodes.NOT_NULL_VIOLATION:
|
||||
# This is raised when a field is set with `required=True`. 2 cases:
|
||||
# - Create/update: a mandatory field is not set.
|
||||
# - Delete: another model has a not nullable using the deleted record.
|
||||
msg += '\n'
|
||||
msg += _(
|
||||
'- Create/update: a mandatory field is not set.\n'
|
||||
'- Delete: another model requires the record being deleted. If possible, archive it instead.'
|
||||
)
|
||||
if model:
|
||||
msg += '\n\n{} {} ({}), {} {} ({})'.format(
|
||||
_('Model:'), model._description, model._name,
|
||||
_('Field:'), field.string if field else _('Unknown'), field.name if field else _('Unknown'),
|
||||
)
|
||||
elif inst.pgcode == errorcodes.FOREIGN_KEY_VIOLATION:
|
||||
# This is raised when a field is set with `ondelete='restrict'`, at
|
||||
# unlink only.
|
||||
msg += _(' another model requires the record being deleted. If possible, archive it instead.')
|
||||
constraint = inst.diag.constraint_name
|
||||
if model or constraint:
|
||||
msg += '\n\n{} {} ({}), {} {}'.format(
|
||||
_('Model:'), model._description if model else _('Unknown'), model._name if model else _('Unknown'),
|
||||
_('Constraint:'), constraint if constraint else _('Unknown'),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ValidationError(msg)
|
||||
|
||||
@@ -1241,3 +1241,14 @@ def wrap_module(module, attr_list):
|
||||
raise AttributeError(attrib)
|
||||
# module and attr_list are in the closure
|
||||
return WrappedModule()
|
||||
|
||||
|
||||
class DotDict(dict):
|
||||
"""Helper for dot.notation access to dictionary attributes
|
||||
E.g.
|
||||
foo = DotDict({'bar': False})
|
||||
return foo.bar
|
||||
"""
|
||||
def __getattr__(self, attrib):
|
||||
val = self.get(attrib)
|
||||
return DotDict(val) if type(val) is dict else val
|
||||
|
||||
Reference in New Issue
Block a user