[MERGE] Forward port of saas-12.2 to saas-12.3 up to cf8e74a1a7

closes odoo/odoo#34907

Signed-off-by: Martin Trigaux (mat) <mat@odoo.com>
This commit is contained in:
Martin Trigaux
2019-07-16 11:44:38 +00:00
67 changed files with 495 additions and 175 deletions
+3 -1
View File
@@ -440,7 +440,7 @@ class AccountInvoice(models.Model):
vendor_display_name = _('From: ') + invoice.source_email
invoice.invoice_icon = '@'
else:
vendor_display_name = ('Created by: ') + invoice.sudo().create_uid.name
vendor_display_name = _('Created by: %s') % invoice.sudo().create_uid.name
invoice.invoice_icon = '#'
invoice.vendor_display_name = vendor_display_name
@@ -1635,6 +1635,8 @@ class AccountInvoice(models.Model):
values['type'] = TYPE2REFUND[invoice['type']]
values['date_invoice'] = date_invoice or fields.Date.context_today(invoice)
if values.get('date_due', False) and values['date_invoice'] > values['date_due']:
values['date_due'] = values['date_invoice']
values['state'] = 'draft'
values['number'] = False
values['origin'] = invoice.number
+1
View File
@@ -451,6 +451,7 @@ class ResPartner(models.Model):
return {'domain': {'property_account_position_id': [('company_id', 'in', [company.id, False])]}}
def can_edit_vat(self):
''' Can't edit `vat` if there is (non draft) issued invoices. '''
can_edit_vat = super(ResPartner, self).can_edit_vat()
if not can_edit_vat:
return can_edit_vat
@@ -38,7 +38,7 @@
<template id="portal_invoice_payment" name="Invoice Payment">
<div class="row" t-if="not tx_ids and invoice.state == 'open' and invoice.amount_total" id="portal_pay">
<div class="modal fade" id="pay_with" role="dialog">
<div class="modal-dialog modal-content">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Pay with</h3>
@@ -353,14 +353,19 @@ class AccountVoucher(models.Model):
temp['amount_currency'] = company_cur._convert(tax_vals['amount'], current_cur, line.company_id, self.account_date or fields.Date.today(), round=True)
self.env['account.move.line'].create(temp)
self.env['account.move.line'].create(move_line)
# When global rounding is activated, we must wait until all tax lines are computed to
# merge them.
if tax_calculation_rounding_method == 'round_globally':
# _apply_taxes modifies the dict move_line in place to account for included/excluded taxes
tax_lines_vals += self.env['account.move.line'].with_context(round=False)._apply_taxes(
move_line,
move_line.get('debit', 0.0) - move_line.get('credit', 0.0)
)
# rounding False means the move_line's amount are not rounded
currency = self.env['res.currency'].browse(company_currency)
move_line['debit'] = currency.round(move_line['debit'])
move_line['credit'] = currency.round(move_line['credit'])
self.env['account.move.line'].create(move_line)
# When round globally is set, we merge the tax lines
if tax_calculation_rounding_method == 'round_globally':
@@ -17,6 +17,7 @@ class TimesheetAttendance(models.Model):
@api.model_cr
def init(self):
self._cr.execute("DROP VIEW IF EXISTS hr_timesheet_attendance_report")
self._cr.execute("""CREATE OR REPLACE VIEW %s AS (
SELECT
max(id) AS id,
@@ -31,7 +32,7 @@ class TimesheetAttendance(models.Model):
resource_resource.user_id AS user_id,
hr_attendance.worked_hours AS attendance,
NULL AS timesheet,
date_trunc('day', hr_attendance.check_in) AS date
hr_attendance.check_in::date AS date
FROM hr_attendance
LEFT JOIN hr_employee ON hr_employee.id = hr_attendance.employee_id
LEFT JOIN resource_resource on resource_resource.id = hr_employee.resource_id
@@ -41,7 +42,7 @@ class TimesheetAttendance(models.Model):
ts.user_id AS user_id,
NULL AS attendance,
ts.unit_amount AS timesheet,
date_trunc('day', ts.date) AS date
ts.date AS date
FROM account_analytic_line AS ts
WHERE ts.project_id IS NOT NULL
) AS t
@@ -15,7 +15,7 @@
<field name="name">HR timesheet attendance report: Pivot</field>
<field name="model">hr.timesheet.attendance.report</field>
<field name="arch" type="xml">
<pivot string="timesheet attendance">
<pivot string="timesheet attendance" disable_linking="True">
<field name="user_id" type="row"/>
<field name="date" interval="day" type="col"/>
<field name="total_difference" type="measure" widget="float_time"/>
+1 -1
View File
@@ -55,7 +55,7 @@ def get_token():
return read_file_first_line('token')
def get_version():
return '19_04'
return '19_07'
#----------------------------------------------------------
# Controllers
@@ -91,7 +91,7 @@
</tr>
<tr>
<td class="heading">Version</td>
<td>V19.04 <a class="btn btn-sm float-right" href='/hw_proxy/upgrade/'>update</a></td>
<td>V19.07 <a class="btn btn-sm float-right" href='/hw_proxy/upgrade/'>update</a></td>
</tr>
<tr>
<td class="heading">IP Address</td>
@@ -3,7 +3,8 @@
<data noupdate="1">
<!-- add VAT, codice fiscal and tax system for main company -->
<record id="base.main_company" model="res.company">
<field name="vat">IT00410123456</field>
<field name="country_id" ref="base.it"/>
<field name="vat">IT00410128888</field>
<field name="l10n_it_codice_fiscale">0123456789987654</field>
<field name="l10n_it_tax_system">RF01</field>
<field name="zip">12345</field>
+4
View File
@@ -702,6 +702,10 @@ class MailThread(models.AbstractModel):
params['token'] = token
link = '%s?%s' % (base_link, url_encode(params))
if self and hasattr(self, 'get_base_url'):
link = self[0].get_base_url() + link
return link
@api.multi
+1 -1
View File
@@ -22,7 +22,7 @@ class MassMailController(http.Controller):
model_display = mailing.mailing_model_id.display_name
blacklist_entry._message_log(description + " ({})".format(model_display))
@http.route(['/unsubscribe_from_list'], type='http', website=True, multilang=False, auth='public')
@http.route(['/unsubscribe_from_list'], type='http', website=True, multilang=False, auth='public', sitemap=False)
def unsubscribe_placeholder_link(self, **post):
"""Dummy route so placeholder is not prefixed by language, MUST have multilang=False"""
raise werkzeug.exceptions.NotFound()
+7 -7
View File
@@ -557,12 +557,13 @@ class MrpProduction(models.Model):
move[0]._recompute_state()
move[0]._action_assign()
move[0].unit_factor = quantity / move[0].raw_material_production_id.product_qty
elif quantity < 0: # Do not remove 0 lines
return move[0], old_qty, quantity
else:
if move[0].quantity_done > 0:
raise UserError(_('Lines need to be deleted, but can not as you still have some quantities to consume in them. '))
move[0]._action_cancel()
move[0].unlink()
return move[0], old_qty, quantity
return self.env['stock.move'], old_qty, quantity
else:
move_values = self._get_move_raw_values(bom_line, line_data)
move = self.env['stock.move'].create(move_values)
@@ -848,15 +849,14 @@ class MrpProduction(models.Model):
raise UserError(_('Work order %s is still running') % wo.name)
self._check_lots()
# Cancel unfinished move
move_to_cancel = self.move_raw_ids.filtered(lambda m: m.state not in ('done', 'cancel') and float_is_zero(m.quantity_done, precision_rounding=m.product_uom.rounding))
move_to_cancel._action_cancel()
self.post_inventory()
# Moves without quantity done are not posted => set them as done instead of canceling. In
# case the user edits the MO later on and sets some consumed quantity on those, we do not
# want the move lines to be canceled.
(self.move_raw_ids | self.move_finished_ids).filtered(lambda x: x.state not in ('done', 'cancel')).write({'state': 'done'})
(self.move_raw_ids | self.move_finished_ids).filtered(lambda x: x.state not in ('done', 'cancel')).write({
'state': 'done',
'product_uom_qty': 0.0,
})
self.write({'date_finished': fields.Datetime.now()})
return True
+45
View File
@@ -518,6 +518,51 @@ class TestMrpOrder(TestMrpCommon):
self.assertEqual(mo.move_raw_ids.filtered(lambda m: m.product_id == p1).quantity_done, 20, 'Update the produce quantity should not impact already produced quantity.')
mo.button_mark_done()
def test_product_produce_6(self):
""" Plan 5 finished products, reserve and produce 3. Post the current production.
Simulate an unlock and edit and, on the opened moves, set the consumed quantity
to 3. Now, try to update the quantity to produce to 3. It should fail since there
are consumed quantities. Unlock and edit, remove the consumed quantities and
update the quantity to produce to 3."""
self.stock_location = self.env.ref('stock.stock_location_stock')
mo, bom, p_final, p1, p2 = self.generate_mo()
self.assertEqual(len(mo), 1, 'MO should have been created')
self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 20)
self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5)
mo.action_assign()
produce_wizard = self.env['mrp.product.produce'].with_context({
'active_id': mo.id,
'active_ids': [mo.id],
}).create({
'product_qty': 3.0,
})
produce_wizard._onchange_product_qty()
produce_wizard.do_produce()
mo.post_inventory()
self.assertEqual(len(mo.move_raw_ids), 4)
mo.move_raw_ids.filtered(lambda m: m.state != 'done')[0].quantity_done = 3
update_quantity_wizard = self.env['change.production.qty'].create({
'mo_id': mo.id,
'product_qty': 3,
})
with self.assertRaises(UserError):
update_quantity_wizard.change_prod_qty()
mo.move_raw_ids.filtered(lambda m: m.state != 'done')[0].quantity_done = 0
update_quantity_wizard.change_prod_qty()
self.assertEqual(len(mo.move_raw_ids), 2)
mo.button_mark_done()
self.assertTrue(all(s == 'done' for s in mo.move_raw_ids.mapped('state')))
self.assertEqual(sum(mo.move_raw_ids.mapped('move_line_ids.product_uom_qty')), 0)
def test_product_produce_6(self):
""" Checks that, for a BOM with two components, when creating a manufacturing order for one
finished products and without reserving, the produce wizards proposes the corrects lines
+9 -2
View File
@@ -60,15 +60,22 @@ class ChangeProductionQty(models.TransientModel):
if line.child_bom_id and line.child_bom_id.type == 'phantom' or\
line.product_id.type not in ['product', 'consu']:
continue
move, old_qty, new_qty = production._update_raw_move(line, line_data)
move = production.move_raw_ids.filtered(lambda x: x.bom_line_id.id == line.id and x.state not in ('done', 'cancel'))
if move:
old_qty = move[0].product_uom_qty
else:
old_qty = 0
iterate_key = production._get_document_iterate_key(move)
if iterate_key:
document = self.env['stock.picking']._log_activity_get_documents({move: (new_qty, old_qty)}, iterate_key, 'UP')
document = self.env['stock.picking']._log_activity_get_documents({move: (line_data['qty'], old_qty)}, iterate_key, 'UP')
for key, value in document.items():
if documents.get(key):
documents[key] += [value]
else:
documents[key] = [value]
production._update_raw_move(line, line_data)
production._log_manufacture_exception(documents)
operation_bom_qty = {}
for bom, bom_data in boms:
+1 -1
View File
@@ -272,7 +272,7 @@ class WebsitePayment(http.Controller):
tx_id = int(kw.get('tx_id', 0))
if tx_id:
tx = request.env['payment.transaction'].browse(tx_id)
if tx.state == 'done':
if tx.state in ['done', 'authorized']:
status = 'success'
message = tx.acquirer_id.done_msg
elif tx.state == 'pending':
+1
View File
@@ -5,4 +5,5 @@ from . import account_invoice
from . import res_partner
from . import account_payment
from . import chart_template
from . import ir_http
from . import res_company
+14
View File
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.osv import expression
class IrHttp(models.AbstractModel):
_inherit = 'ir.http'
@classmethod
def _get_translation_frontend_modules_domain(cls):
domain = super(IrHttp, cls)._get_translation_frontend_modules_domain()
return expression.OR([domain, [('name', '=', 'payment')]])
+1 -1
View File
@@ -259,7 +259,7 @@ class PosConfig(models.Model):
" the Accounting application."))
if self.invoice_journal_id.currency_id and self.invoice_journal_id.currency_id != self.currency_id:
raise ValidationError(_("The invoice journal must be in the same currency as the Sales Journal or the company currency if that is not set."))
if any(self.journal_ids.mapped(lambda journal: journal.currency_id and journal.currency_id != self.currency_id)):
if any(self.journal_ids.mapped(lambda journal: self.currency_id not in (journal.company_id.currency_id, journal.currency_id))):
raise ValidationError(_("All payment methods must be in the same currency as the Sales Journal or the company currency if that is not set."))
@api.constrains('company_id', 'available_pricelist_ids')
+1 -1
View File
@@ -215,7 +215,7 @@ class PosSession(models.Model):
'journal_id': journal.id,
'user_id': self.env.user.id,
'name': pos_name,
'balance_start': self.env["account.bank.statement"]._get_opening_balance(journal.id)
'balance_start': self.env["account.bank.statement"]._get_opening_balance(journal.id) if journal.type == 'cash' else 0
}
statements.append(ABS.with_context(ctx).sudo(uid).create(st_values).id)
@@ -0,0 +1,41 @@
#!/bin/bash
### BEGIN INIT INFO
# Provides: timesyncd
# Required-Start: $remote_fs $network
# Required-Stop: $remote_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: Start systemd-timesyncd daemon at boot time
# Description: Enable service provided by daemon.
### END INIT INFO
start()
{
echo "start timesyncd"
service systemd-timesyncd start
}
stop()
{
echo "stop timesyncd"
service systemd-timesyncd stop
}
restart()
{
stop;
sleep 1;
start;
}
case $1 in
start)
start;;
stop)
stop;;
restart)
restart;;
*)
start;;
esac
exit 0
@@ -138,6 +138,7 @@ echo "* * * * * rm /var/run/odoo/sessions/*" | crontab -
update-rc.d -f hostapd remove
update-rc.d -f nginx remove
update-rc.d -f dnsmasq remove
update-rc.d timesyncd defaults
systemctl daemon-reload
systemctl enable ramdisks.service
+1 -1
View File
@@ -13,7 +13,7 @@ class MailThread(models.AbstractModel):
_mail_post_token_field = 'access_token' # token field for external posts, to be overridden
website_message_ids = fields.One2many('mail.message', 'res_id', string='Website Messages',
domain=lambda self: [('model', '=', self._name), ('message_type', '=', 'comment')], auto_join=True,
domain=lambda self: [('model', '=', self._name), '|', ('message_type', '=', 'comment'), ('message_type', '=', 'email')], auto_join=True,
help="Website communication history")
@api.multi
+4 -1
View File
@@ -8,4 +8,7 @@ class ResPartner(models.Model):
_inherit = 'res.partner'
def can_edit_vat(self):
return True
''' `vat` is a commercial field, synced between the parent (commercial
entity) and the children. Only the commercial entity should be able to
edit it (as in backend). '''
return not self.parent_id
+5 -2
View File
@@ -374,8 +374,11 @@
<div class="clearfix" />
<div t-attf-class="form-group #{error.get('company_name') and 'o_has_error' or ''} col-xl-6">
<label class="col-form-label label-optional" for="company_name">Company Name</label>
<p t-if="partner.commercial_partner_id.is_company" t-attf-class="form-control #{error.get('company_name') and 'is-invalid' or ''}" t-esc="partner.commercial_company_name"/>
<input t-else="" type="text" name="company_name" t-attf-class="form-control #{error.get('company_name') and 'is-invalid' or ''}" t-att-value="company_name or partner.company_name" t-att-readonly="None if partner.can_edit_vat() else '1'" t-att-title="None if partner.can_edit_vat() else 'Changing company name is not allowed once document(s) have been issued for your account. Please contact us directly for this operation.'"/>
<!-- The <input> is replace by a <p> to avoid sending an unauthorized value on form submit.
The user might not have rights to change company_name but should still be able to see it.
-->
<p t-if="not partner.can_edit_vat()" t-attf-class="form-control" readonly="1" t-esc="partner.commercial_company_name" title="Changing company name is not allowed once document(s) have been issued for your account. Please contact us directly for this operation."/>
<input t-else="" type="text" name="company_name" t-attf-class="form-control #{error.get('company_name') and 'is-invalid' or ''}" t-att-value="company_name or partner.commercial_company_name"/>
</div>
<div t-attf-class="form-group #{error.get('vat') and 'o_has_error' or ''} col-xl-6">
<label class="col-form-label label-optional" for="vat">VAT Number</label>
+1 -1
View File
@@ -34,7 +34,7 @@ class Partner(models.Model):
# update at each change country, and so erase old pricelist
if self.property_product_pricelist or (actual and default_for_country and default_for_country.id != actual.id):
# keep the company of the current user before sudo
self.env['ir.property'].with_context(force_company=self.env.user.company_id.id).sudo().set_multi(
self.env['ir.property'].with_context(force_company=self._context.get('force_company', self.env.user.company_id.id)).sudo().set_multi(
'property_product_pricelist',
self._name,
{self.id: self.property_product_pricelist or default_for_country.id},
+1
View File
@@ -398,6 +398,7 @@
<field name="type">ir.actions.act_window</field>
<field name="res_model">purchase.order</field>
<field name="view_mode">tree,kanban,form,pivot,graph,calendar,activity</field>
<field name="domain">[]</field>
<field name="search_view_id" ref="view_purchase_order_filter"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
+1
View File
@@ -30,6 +30,7 @@ class ResPartner(models.Model):
partner = partner.parent_id
def can_edit_vat(self):
''' Can't edit `vat` if there is (non draft) issued SO. '''
can_edit_vat = super(ResPartner, self).can_edit_vat()
if not can_edit_vat:
return can_edit_vat
+2 -2
View File
@@ -209,9 +209,9 @@ class SaleOrder(models.Model):
def _get_reward_line_values(self, program):
self.ensure_one()
if program.reward_type == 'discount':
return self._get_reward_values_discount(program)
return self._get_reward_values_discount(program.with_context(lang=self.partner_id.lang))
elif program.reward_type == 'product':
return [self._get_reward_values_product(program)]
return [self._get_reward_values_product(program.with_context(lang=self.partner_id.lang))]
def _create_reward_line(self, program):
self.write({'order_line': [(0, False, value) for value in self._get_reward_line_values(program)]})
+2 -1
View File
@@ -499,7 +499,8 @@ class ProductTemplate(models.Model):
@api.depends(
'product_variant_ids',
'product_variant_ids.stock_quant_ids',
'product_variant_ids.stock_move_ids.product_qty',
'product_variant_ids.stock_move_ids.state',
)
def _compute_quantities(self):
res = self._compute_quantities_dict()
+1 -1
View File
@@ -93,7 +93,7 @@
<td t-if="has_serial_number and move_line.lot_name" groups="stock.group_lot_on_delivery_slip">
<span t-field="move_line.lot_name"/>
</td>
<td t-else="" groups="stock.group_lot_on_delivery_slip">
<td t-elif="has_serial_number" groups="stock.group_lot_on_delivery_slip">
<span t-field="move_line.lot_id.name"/>
</td>
<td class="text-center">
@@ -181,6 +181,8 @@
}
// checkboxes in editable lists should be clickable and activate the row
.o_data_row:not(.o_selected_row) .o_data_cell {
&.o_toggle_button_cell:not(.o_readonly_modifier) button, // registry: toggle_button
&.o_boolean_favorite_cell:not(.o_readonly_modifier) .o_favorite, // registry: boolean_favorite
.custom-checkbox:not(.o_readonly_modifier) {
pointer-events: none;
}
+2 -5
View File
@@ -1,13 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import tagged
import unittest
from odoo.tests.common import BaseCase
from ..controllers import main
@tagged('standard', 'at_install')
class ActionMungerTest(unittest.TestCase):
class ActionMungerTest(BaseCase):
def test_actual_treeview(self):
action = {
"views": [[False, "tree"], [False, "form"],
+2 -4
View File
@@ -2,9 +2,8 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import random
import unittest
from odoo.tests.common import tagged
from odoo.tests.common import BaseCase
from odoo.tools import topological_sort
@@ -14,8 +13,7 @@ def sample(population):
random.randint(0, min(len(population), 5)))
@tagged('standard', 'at_install')
class TestModulesLoading(unittest.TestCase):
class TestModulesLoading(BaseCase):
def setUp(self):
self.mods = [str(i) for i in range(1000)]
@@ -1885,6 +1885,9 @@ var HelperPlugin = AbstractPlugin.extend({
// Create empty text node to have a range into the node
if (range.sc.tagName && !dom.isVoid(range.sc) && !range.sc.childNodes[range.so]) {
$(range.sc).append(invisible);
if (invisible.previousSibling && invisible.previousSibling.tagName === "BR") {
invisible.previousSibling.remove();
}
range = this.context.invoke('editor.setRange', invisible, 0);
}
@@ -780,7 +780,10 @@ var TextPlugin = AbstractPlugin.extend({
clipboardData = e.originalEvent.clipboardData.getData('text/plain');
// get that text as an array of text nodes separated by <br> where needed
var allNewlines = /\n/g;
clipboardData = $('<p>' + clipboardData.replace(allNewlines, '<br>') + '</p>').contents().toArray();
clipboardData = _.str.trim(clipboardData)
.replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(allNewlines, '<br>');
clipboardData = $('<p>' + clipboardData + '</p>').contents().toArray();
}
// Delete selection
@@ -814,6 +814,18 @@ var keyboardTestsChar = [{
start: "b:contents(0)->1",
},
},
{
name: "'a' in empty li",
content: '<ul><li><p><br></p></li></ul>',
steps: [{
start: "p->1",
key: 'a',
}],
test: {
content: '<ul><li><p>a</p></li></ul>',
start: "p:contents(0)->1",
},
},
];
QUnit.test('Char', function (assert) {
@@ -2248,6 +2260,25 @@ var keyboardTestsComplex = [{
start: "p:eq(1)->0",
},
},
// List indent
{
name: "ENTER -> TAB -> a' at end of li > p (split -> indent -> write)",
content: '<ul><li><p>a</p></li></ul>',
steps: [{
start: "p:contents()[0]->1",
key: 'ENTER',
},
{
key: 'TAB',
},
{
key: 'a',
}],
test: {
content: '<ul><li><p>a</p></li><li class="o_indent"><ul><li><p>a</p></li></ul></li></ul>',
start: "p:eq(1):contents()[0]->1",
},
},
];
QUnit.test('Complex', function (assert) {
+2 -3
View File
@@ -2,17 +2,16 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import textwrap
import unittest
from lxml import etree, html
from lxml.builder import E
from odoo.tests import common
from odoo.tests.common import BaseCase
from odoo.addons.web_editor.models.ir_qweb import html_to_text
@common.tagged('standard', 'at_install')
class TestHTMLToText(unittest.TestCase):
class TestHTMLToText(BaseCase):
def test_rawstring(self):
self.assertEqual(
"foobar",
+1 -2
View File
@@ -22,8 +22,7 @@ class ResConfigSettings(models.TransientModel):
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,
relation='res.lang', required=False,
oldname='default_lang_id')
relation='res.lang', oldname='default_lang_id')
website_default_lang_code = fields.Char(
'Default language code', related='website_id.default_lang_code', readonly=False,
oldname='default_lang_code')
+3 -7
View File
@@ -1,13 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import unittest
from odoo.addons.http_routing.models.ir_http import slugify, unslug
from odoo.tests.common import tagged
from odoo.tests.common import BaseCase
@tagged('standard', 'at_install')
class TestUnslug(unittest.TestCase):
class TestUnslug(BaseCase):
def test_unslug(self):
tests = {
@@ -28,9 +26,7 @@ class TestUnslug(unittest.TestCase):
for slug, expected in tests.items():
self.assertEqual(unslug(slug), expected)
@tagged('standard', 'at_install')
class TestTitleToSlug(unittest.TestCase):
class TestTitleToSlug(BaseCase):
"""
Those tests should pass with or without python-slugify
See website/models/website.py slugify method
@@ -69,7 +69,7 @@
</div>
<div class="content-group">
<div class="mt16">
<field name="language_ids" widget="many2many_tags" options="{'no_create': True, 'no_open': True}"/>
<field name="language_ids" widget="many2many_tags" options="{'no_create': True, 'no_open': True}" attrs="{'required': [('website_id', '!=', False)]}"/>
</div>
<field name="language_count" invisible="1"/>
<div class="mt8" attrs="{'invisible':[('language_count', '&lt;', 2)]}">
@@ -23,7 +23,10 @@ publicWidget.registry.js_get_posts = publicWidget.Widget.extend({
this.$target.empty(); // Compatibility with db that saved content inside by mistake
this.$target.attr('contenteditable', 'False'); // Prevent user edition
var domain = [['website_published', '=', true]];
var domain = [
['website_published', '=', true],
['post_date', '<=', moment().utc().locale('en').format('YYYY-MM-DD HH:mm:ss')],
];
if (blogID) {
domain.push(['blog_id', '=', parseInt(blogID)]);
}
@@ -227,7 +227,9 @@ publicWidget.registry.crmPartnerAssign = publicWidget.Widget.extend({
_onEditOppConfirm: function (ev) {
ev.preventDefault();
ev.stopPropagation();
this._buttonExec($(ev.currentTarget), this._editOpportunity);
if ($(".edit_opp_form")[0].checkValidity()) {
this._buttonExec($(ev.currentTarget), this._editOpportunity);
}
},
/**
* @private
@@ -671,7 +671,7 @@
<label>Expected Closing:</label>
<div class="input-group date" id="exp_closing_div" data-target-input="nearest">
<t t-set='date_formatted'><t t-options='{"widget": "date"}' t-esc="opportunity.date_deadline"/></t>
<input type="text" name="date_deadline" t-att-value="date_formatted" class="datetimepicker-input form-control date_deadline" t-att-name="prefix"/>
<input type="date" min="1900-01-01" name="date_deadline" t-att-value="date_formatted" class="datetimepicker-input form-control date_deadline" data-date-format="YYYY-MM-DD" t-att-name="prefix" placeholder="YYYY-MM-DD"/>
<div class="input-group-append" data-target="#exp_closing_div" data-toggle="datetimepicker">
<span class="input-group-text">
<span class="fa fa-calendar" role="img" aria-label="Calendar"></span>
@@ -697,7 +697,7 @@
<label class="col-form-label" for="activity_date_deadline">Next Activity Date</label>
<div class="input-group date" id="next_activity_div" data-target-input="nearest">
<t t-set='date_formatted'><t t-options='{"widget": "date"}' t-esc="user_activity.date_deadline"/></t>
<input type="text" name="activity_date_deadline" t-att-value="date_formatted" class="form-control activity_date_deadline datetimepicker-input" t-att-name="prefix"/>
<input type="date" min="1900-01-01" name="activity_date_deadline" t-att-value="date_formatted" class="form-control activity_date_deadline datetimepicker-input" data-date-format="YYYY-MM-DD" t-att-name="prefix" placeholder="YYYY-MM-DD"/>
<div class="input-group-append" data-target="#next_activity_div" data-toggle="datetimepicker">
<span class="input-group-text">
<span class="fa fa-calendar" role="img" aria-label="Calendar" title="Calendar"></span>
+1 -1
View File
@@ -327,7 +327,7 @@ class Website(models.Model):
if code_pricelist:
pricelist_id = code_pricelist.id
update_pricelist = True
elif code is not None and sale_order.pricelist_id.code:
elif code is not None and sale_order.pricelist_id.code and code != sale_order.pricelist_id.code:
# code is not None when user removes code and click on "Apply"
pricelist_id = partner.property_product_pricelist.id
update_pricelist = True
+1 -1
View File
@@ -1269,7 +1269,7 @@
<t t-if="mode == ('new', 'billing') or (mode == ('edit', 'billing') and (can_edit_vat or 'vat' in checkout and checkout['vat']))">
<div t-attf-class="form-group #{error.get('company_name') and 'o_has_error' or ''} col-lg-6">
<label class="col-form-label font-weight-normal label-optional" for="company_name">Company Name</label>
<input type="text" name="company_name" t-attf-class="form-control #{error.get('company_name') and 'is-invalid' or ''}" t-att-value="'company_name' in checkout and checkout['company_name']" t-att-readonly="'1' if 'vat' in checkout and checkout['vat'] and not can_edit_vat else None" t-att-title="'Changing company name is not allowed once document(s) have been issued for your account. Please contact us directly for this operation.' if 'vat' in checkout and checkout['vat'] and not can_edit_vat else None" />
<input type="text" name="company_name" t-attf-class="form-control #{error.get('company_name') and 'is-invalid' or ''}" t-att-value="'commercial_company_name' in checkout and checkout['commercial_company_name'] or 'company_name' in checkout and checkout['company_name']" t-att-readonly="'1' if 'vat' in checkout and checkout['vat'] and not can_edit_vat else None" t-att-title="'Changing company name is not allowed once document(s) have been issued for your account. Please contact us directly for this operation.' if 'vat' in checkout and checkout['vat'] and not can_edit_vat else None" />
</div>
<div t-attf-class="form-group #{error.get('vat') and 'o_has_error' or ''} col-lg-6 div_vat">
<label class="col-form-label font-weight-normal label-optional" for="vat">TIN / VAT </label>
@@ -22,12 +22,13 @@ class ChannelUsersRelation(models.Model):
partner_id = fields.Many2one('res.partner', index=True, required=True)
partner_email = fields.Char(related='partner_id.email', readonly=True)
@api.depends('channel_id.slide_partner_ids.partner_id', 'channel_id.slide_partner_ids.completed', 'partner_id')
@api.depends('channel_id.slide_partner_ids.partner_id', 'channel_id.slide_partner_ids.completed', 'partner_id', 'channel_id.slide_partner_ids.slide_id.is_published')
def _compute_completion(self):
read_group_res = self.env['slide.slide.partner'].sudo().read_group(
['&', '&', ('channel_id', 'in', self.mapped('channel_id').ids),
('partner_id', 'in', self.mapped('partner_id').ids),
('completed', '=', True)],
('completed', '=', True),
('slide_id.is_published', '=', True)],
['channel_id', 'partner_id'],
groupby=['channel_id', 'partner_id'], lazy=False)
mapped_data = dict()
@@ -75,6 +75,15 @@ class TestStatistics(common.SlidesCase):
self.assertEqual(channel_emp.completion, 100)
self.assertTrue(channel_emp.completed)
self.slide_3.is_published = False
self.assertEqual(channel_emp.completion, 100)
self.assertTrue(channel_emp.completed)
self.slide_3.is_published = True
self.slide_3.active = False
self.assertEqual(channel_emp.completion, 100)
self.assertTrue(channel_emp.completed)
@mute_logger('odoo.models')
def test_channel_user_statistics_complete_check_member(self):
(self.slide | self.slide_2).write({'is_preview': True})
+1 -1
View File
@@ -3,6 +3,6 @@
<li><a href="https://www.odoo.com/documentation/user/12.0/index.html">User</a></li>
<li><a href="https://www.odoo.com/documentation/12.0/index.html">Developer</a></li>
<li><a href="https://www.odoo.com/documentation/12.0/setup/install.html">Installation</a></li>
<li><a href="http://training.odoo.com/courses/odoo-functional">Training center</a></li>
<li><a href="https://odoo.com/slides">eLearning</a></li>
<li><a href="https://www.odoo.com/page/odoo-white-paper">White Papers</a></li>
<li><a href="https://www.odoo.com/page/legal">Legal</a></li>
+28
View File
@@ -0,0 +1,28 @@
Chile, 2019-07-12
Blanco Martín & Asociados 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,
Daniel Blanco daniel@blancomartin.cl https://github.com/danisan
Corporation name: Blanco Martin & Asociados EIRL
Corporation address: Av. Apoquindo 6410
Of. 212 Las Condes, (RM)
Country: Chile
Point of contact: Daniel Blanco
Title: Director - Representante Legal
Email: daniel@blancomartin.cl
Telephone: +56 2 28400990
List of contributors:
* Fernando de La Barrera fernando@blancomartin.cl https://github.com/bmya-fed
* Alejandro Paciotti alejandro@blancomartin.cl https://github.com/alp-bmya
* Albert Nieriz albert@blancomartin.cl https://github.com/aln-bmya
* Bruno Figares bruno@blancomartin.cl https://github.com/brf-bmya
* Susana Vazquez susana@blancomartin.cl https://github.com/suv-bmya
+2
View File
@@ -9,7 +9,9 @@ declaration.
Signed,
Baskhuu Lodoikhuu baskhuujacara@gmail.com https://github.com/jacara
Baskhuu Lodoikhuu baskhuu@bumanit.mn https://github.com/bumanit
List of contributors:
Baskhuu Lodoikhuu baskhuujacara@gmail.com https://github.com/jacara
Baskhuu Lodoikhuu baskhuu@bumanit.mn https://github.com/bumanit
+11
View File
@@ -0,0 +1,11 @@
France, 2019-07-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,
Valérian Dorcy valerian.dorcy@gmail.com https://github.com/dorcyv
+9
View File
@@ -0,0 +1,9 @@
France, 2019-07-11
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,
Grégory Schreiner gregory@mokatourisme.fr https://github.com/gregory-moka
+2 -4
View File
@@ -2,17 +2,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import unittest
from odoo import SUPERUSER_ID
from odoo.exceptions import UserError, ValidationError
from odoo.tests.common import TransactionCase, tagged
from odoo.tests.common import TransactionCase, BaseCase
from odoo.tools import mute_logger
from odoo.tools.safe_eval import safe_eval, const_eval
@tagged('standard', 'at_install')
class TestSafeEval(unittest.TestCase):
class TestSafeEval(BaseCase):
def test_const(self):
# NB: True and False are names in Python 2 not consts
expected = (1, {"a": {2.5}}, [None, u"foo"])
+2 -3
View File
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import unittest
import odoo
from odoo.sql_db import TestCursor
from odoo.tests import common
from odoo.tests.common import BaseCase
from odoo.tools.misc import mute_logger
ADMIN_USER_ID = common.ADMIN_USER_ID
@@ -14,8 +14,7 @@ def registry():
return odoo.registry(common.get_db_name())
@common.tagged('standard', 'at_install')
class TestExecute(unittest.TestCase):
class TestExecute(BaseCase):
""" Try cr.execute with wrong parameters """
@mute_logger('odoo.sql_db')
+3 -6
View File
@@ -2,15 +2,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import functools
import unittest
from odoo.tests.common import tagged
from odoo.tests.common import BaseCase
from odoo.tools import frozendict
from odoo.tools.func import compose
@tagged('standard', 'at_install')
class TestCompose(unittest.TestCase):
class TestCompose(BaseCase):
def test_basic(self):
str_add = compose(str, lambda a, b: a + b)
self.assertEqual(str_add(1, 2), "3")
@@ -25,8 +23,7 @@ class TestCompose(unittest.TestCase):
self.assertEqual(mul(5, 42), u"210")
@tagged('standard', 'at_install')
class TestFrozendict(unittest.TestCase):
class TestFrozendict(BaseCase):
def test_frozendict_immutable(self):
""" Ensure that a frozendict is immutable. """
vals = {'name': 'Joe', 'age': 42}
+5 -9
View File
@@ -2,13 +2,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from contextlib import contextmanager
import unittest
import psycopg2
import psycopg2.errorcodes
import odoo
from odoo.tests import common
from odoo.tests.common import BaseCase
ADMIN_USER_ID = common.ADMIN_USER_ID
@@ -28,8 +28,7 @@ def drop_sequence(code):
seq.unlink()
@common.tagged('standard', 'at_install')
class TestIrSequenceStandard(unittest.TestCase):
class TestIrSequenceStandard(BaseCase):
""" A few tests for a 'Standard' (i.e. PostgreSQL) sequence. """
def test_ir_sequence_create(self):
@@ -67,8 +66,7 @@ class TestIrSequenceStandard(unittest.TestCase):
drop_sequence('test_sequence_type')
@common.tagged('standard', 'at_install')
class TestIrSequenceNoGap(unittest.TestCase):
class TestIrSequenceNoGap(BaseCase):
""" Copy of the previous tests for a 'No gap' sequence. """
def test_ir_sequence_create_no_gap(self):
@@ -105,8 +103,7 @@ class TestIrSequenceNoGap(unittest.TestCase):
drop_sequence('test_sequence_type_2')
@common.tagged('standard', 'at_install')
class TestIrSequenceChangeImplementation(unittest.TestCase):
class TestIrSequenceChangeImplementation(BaseCase):
""" Create sequence objects and change their ``implementation`` field. """
def test_ir_sequence_1_create(self):
@@ -143,8 +140,7 @@ class TestIrSequenceChangeImplementation(unittest.TestCase):
drop_sequence('test_sequence_type_4')
@common.tagged('standard', 'at_install')
class TestIrSequenceGenerate(unittest.TestCase):
class TestIrSequenceGenerate(BaseCase):
""" Create sequence objects and generate some values. """
def test_ir_sequence_create(self):
+4 -8
View File
@@ -1,15 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import unittest
from odoo.tests.common import tagged
from odoo.tests.common import BaseCase
from odoo.tools import html_sanitize, append_content_to_html, plaintext2html, email_split, misc
from . import test_mail_examples
@tagged('standard', 'at_install')
class TestSanitizer(unittest.TestCase):
class TestSanitizer(BaseCase):
""" Test the html sanitizer that filters html to remove unwanted attributes """
def test_basic_sanitizer(self):
@@ -285,8 +283,7 @@ class TestSanitizer(unittest.TestCase):
# self.assertNotIn(ext, new_html)
@tagged('standard', 'at_install')
class TestHtmlTools(unittest.TestCase):
class TestHtmlTools(BaseCase):
""" Test some of our generic utility functions about html """
def test_plaintext2html(self):
@@ -313,8 +310,7 @@ class TestHtmlTools(unittest.TestCase):
self.assertEqual(append_content_to_html(html, content, plaintext_flag, preserve_flag, container_tag), expected, 'append_content_to_html is broken')
@tagged('standard', 'at_install')
class TestEmailTools(unittest.TestCase):
class TestEmailTools(BaseCase):
""" Test some of our generic utility functions for emails """
def test_email_split(self):
+2 -3
View File
@@ -1,7 +1,7 @@
import base64
import unittest
from odoo.tests.common import tagged
from odoo.tests.common import BaseCase
from odoo.tools.mimetypes import guess_mimetype
PNG = b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC'
@@ -23,8 +23,7 @@ hbnNsYXRlKDUwLDUwKSI+CiAgICA8cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjU
pyZWQ7IiAvPgogIDwvZz4KCjwvc3ZnPgo="""
@tagged('standard', 'at_install')
class test_guess_mimetype(unittest.TestCase):
class test_guess_mimetype(BaseCase):
def test_default_mimetype_empty(self):
mimetype = guess_mimetype(b'')
+3 -6
View File
@@ -4,14 +4,12 @@
import datetime
from dateutil.relativedelta import relativedelta
import pytz
import unittest
from odoo.tools import misc, date_utils
from odoo.tests.common import TransactionCase, tagged
from odoo.tests.common import TransactionCase, BaseCase
@tagged('standard', 'at_install')
class TestCountingStream(unittest.TestCase):
class TestCountingStream(BaseCase):
def test_empty_stream(self):
s = misc.CountingStream(iter([]))
self.assertEqual(s.index, -1)
@@ -42,8 +40,7 @@ class TestCountingStream(unittest.TestCase):
self.assertEqual(s.index, 0)
@tagged('standard', 'at_install')
class TestDateRangeFunction(unittest.TestCase):
class TestDateRangeFunction(BaseCase):
""" Test on date_range generator. """
def test_date_range_with_naive_datetimes(self):
+2 -5
View File
@@ -1,14 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import unittest
from odoo.osv.query import Query
from odoo.tests.common import tagged
from odoo.tests.common import BaseCase
@tagged('standard', 'at_install')
class QueryTestCase(unittest.TestCase):
class QueryTestCase(BaseCase):
def test_basic_query(self):
query = Query()
+84 -29
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase, tagged, TagsSelector
from odoo.tests.common import TransactionCase, tagged, TagsSelector, BaseCase
@tagged('nodatabase')
@@ -17,7 +17,8 @@ class TestSetTags(TransactionCase):
fc = FakeClass()
self.assertTrue(hasattr(fc, 'test_tags'))
self.assertEqual(fc.test_tags, {'at_install', 'standard', 'base'})
self.assertEqual(fc.test_tags, {'at_install', 'standard'})
self.assertEqual(fc.test_module, 'base')
def test_set_tags_not_decorated(self):
"""Test that a TransactionCase has some test_tags by default"""
@@ -28,7 +29,8 @@ class TestSetTags(TransactionCase):
fc = FakeClass()
self.assertTrue(hasattr(fc, 'test_tags'))
self.assertEqual(fc.test_tags, {'at_install', 'standard', 'base'})
self.assertEqual(fc.test_tags, {'at_install', 'standard'})
self.assertEqual(fc.test_module, 'base')
def test_set_tags_single_tag(self):
"""Test the set_tags decorator with a single tag"""
@@ -39,7 +41,8 @@ class TestSetTags(TransactionCase):
fc = FakeClass()
self.assertEqual(fc.test_tags, {'at_install', 'standard', 'base', 'slow'})
self.assertEqual(fc.test_tags, {'at_install', 'standard', 'slow'})
self.assertEqual(fc.test_module, 'base')
def test_set_tags_multiple_tags(self):
"""Test the set_tags decorator with multiple tags"""
@@ -50,7 +53,8 @@ class TestSetTags(TransactionCase):
fc = FakeClass()
self.assertEqual(fc.test_tags, {'at_install', 'standard', 'base', 'slow', 'nightly'})
self.assertEqual(fc.test_tags, {'at_install', 'standard', 'slow', 'nightly'})
self.assertEqual(fc.test_module, 'base')
def test_inheritance(self):
"""Test inheritance when using the 'tagged' decorator"""
@@ -64,13 +68,15 @@ class TestSetTags(TransactionCase):
pass
fc = FakeClassB()
self.assertEqual(fc.test_tags, {'at_install', 'standard', 'base', 'nightly'})
self.assertEqual(fc.test_tags, {'at_install', 'standard', 'nightly'})
self.assertEqual(fc.test_module, 'base')
class FakeClassC(FakeClassA):
pass
fc = FakeClassC()
self.assertEqual(fc.test_tags, {'at_install', 'standard', 'base'})
self.assertEqual(fc.test_tags, {'at_install', 'standard'})
self.assertEqual(fc.test_module, 'base')
def test_untagging(self):
"""Test that one can remove the 'standard' tag"""
@@ -80,7 +86,8 @@ class TestSetTags(TransactionCase):
pass
fc = FakeClassA()
self.assertEqual(fc.test_tags, {'at_install', 'base'})
self.assertEqual(fc.test_tags, {'at_install'})
self.assertEqual(fc.test_module, 'base')
@tagged('-standard', '-base', '-at_install')
class FakeClassB(TransactionCase):
@@ -104,61 +111,110 @@ class TestSelector(TransactionCase):
"""Test the parser part of the TagsSelector class"""
tags = TagsSelector('+slow')
self.assertEqual({'slow', }, tags.include)
self.assertEqual({('slow', None, None, None),}, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('+slow,nightly')
self.assertEqual({'slow', 'nightly'}, tags.include)
self.assertEqual({('slow', None, None, None), ('nightly', None, None, None)}, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('+slow,-standard')
self.assertEqual({'slow', }, tags.include)
self.assertEqual({'standard', }, tags.exclude)
self.assertEqual({('slow', None, None, None),}, tags.include)
self.assertEqual({('standard', None, None, None),}, tags.exclude)
# same with space after the comma
tags = TagsSelector('+slow, -standard')
self.assertEqual({'slow', }, tags.include)
self.assertEqual({'standard', }, tags.exclude)
self.assertEqual({('slow', None, None, None),}, tags.include)
self.assertEqual({('standard', None, None, None),}, tags.exclude)
# same with space befaore and after the comma
# same with space before and after the comma
tags = TagsSelector('+slow , -standard')
self.assertEqual({'slow', }, tags.include)
self.assertEqual({'standard', }, tags.exclude)
self.assertEqual({('slow', None, None, None), }, tags.include)
self.assertEqual({('standard', None, None, None), }, tags.exclude)
tags = TagsSelector('+slow ,-standard,+js')
self.assertEqual({'slow', 'js', }, tags.include)
self.assertEqual({'standard', }, tags.exclude)
self.assertEqual({('slow', None, None, None),('js', None, None, None)}, tags.include)
self.assertEqual({('standard', None, None, None),}, tags.exclude)
# without +
tags = TagsSelector('slow, ')
self.assertEqual({'slow', }, tags.include)
self.assertEqual({('slow', None, None, None), }, tags.include)
self.assertEqual(set(), tags.exclude)
# duplicates
tags = TagsSelector('+slow,-standard, slow,-standard ')
self.assertEqual({'slow', }, tags.include)
self.assertEqual({'standard', }, tags.exclude)
self.assertEqual({('slow', None, None, None), }, tags.include)
self.assertEqual({('standard', None, None, None), }, tags.exclude)
tags = TagsSelector('')
self.assertEqual(set(), tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('/module') # all standard test of a module
self.assertEqual({('standard', 'module', None, None), }, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('*/module') # all tests of a module
self.assertEqual({(None, 'module', None, None), }, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector(':class') # all standard test of a class
self.assertEqual({('standard', None, 'class', None), }, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('.method')
self.assertEqual({('standard', None, None, 'method'), }, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector(':class.method')
self.assertEqual({('standard', None, 'class', 'method'), }, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('/module:class.method') # only a specific test func in a module (standard)
self.assertEqual({('standard', 'module', 'class', 'method'), }, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('*/module:class.method') # only a specific test func in a module
self.assertEqual({(None, 'module', 'class', 'method'), }, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('-/module:class.method') # disable a specific test func in a module
self.assertEqual({('standard', None, None, None), }, tags.include) # all strandard
self.assertEqual({(None, 'module', 'class', 'method'), }, tags.exclude) # exept the test func
tags = TagsSelector('-*/module:class.method')
self.assertEqual({('standard', None, None, None), }, tags.include)
self.assertEqual({(None, 'module', 'class', 'method'), }, tags.exclude)
tags = TagsSelector('tag/module')
self.assertEqual({('tag', 'module', None, None), }, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('tag.method')
self.assertEqual({('tag', None, None, 'method'), }, tags.include)
self.assertEqual(set(), tags.exclude)
tags = TagsSelector('*/module,-standard') # all non standard test of a module
self.assertEqual({(None, 'module', None, None), }, tags.include) # all in module
self.assertEqual({('standard', None, None, None), }, tags.exclude) # exept standard ones
@tagged('nodatabase')
class TestSelectorSelection(TransactionCase):
def test_selector_selection(self):
"""Test check_tags use cases"""
class Test_A(TransactionCase):
pass
@tagged('stock')
class Test_B():
class Test_B(BaseCase):
pass
@tagged('stock', 'slow')
class Test_C():
class Test_C(BaseCase):
pass
@tagged('standard', 'slow')
class Test_D():
class Test_D(BaseCase):
pass
@tagged('-at_install', 'post_install')
@@ -175,7 +231,6 @@ class TestSelectorSelection(TransactionCase):
# considered tagged standard and they are run by default if
# not explicitly deselected with '-standard' or if 'standard' is not
# selectected along with another test tag
# same as "--test-tags=''" parameters:
tags = TagsSelector('')
self.assertFalse(tags.check(no_tags_obj))
@@ -211,10 +266,10 @@ class TestSelectorSelection(TransactionCase):
self.assertFalse(tags.check(stock_tag_obj))
tags = TagsSelector('standard')
self.assertFalse(tags.check(stock_tag_obj))
self.assertTrue(tags.check(stock_tag_obj))
tags = TagsSelector('slow,standard')
self.assertFalse(tags.check(stock_tag_obj))
self.assertTrue(tags.check(stock_tag_obj))
tags = TagsSelector('slow,-standard')
self.assertFalse(tags.check(stock_tag_obj))
+2 -6
View File
@@ -1,16 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import unittest
from odoo.tools import mute_logger
from odoo.tools.translate import quote, unquote, xml_translate, html_translate
from odoo.tests.common import TransactionCase, tagged
from odoo.tests.common import TransactionCase, BaseCase
from psycopg2 import IntegrityError
@tagged('standard', 'at_install')
class TranslationToolsTestCase(unittest.TestCase):
class TranslationToolsTestCase(BaseCase):
def assertItemsEqual(self, a, b, msg=None):
self.assertEqual(sorted(a), sorted(b), msg)
+3 -2
View File
@@ -8,6 +8,8 @@ import unittest
from odoo import api, registry, SUPERUSER_ID
from odoo.tests import common
from odoo.tests.common import BaseCase
from odoo.modules.registry import Registry
@@ -25,8 +27,7 @@ MODULE = 'test_uninstall'
MODEL = 'test_uninstall.model'
@common.tagged('standard', 'at_install')
class TestUninstall(unittest.TestCase):
class TestUninstall(BaseCase):
"""
Test the install/uninstall of a test module. The module is available in
`odoo.tests` which should be present in the addons-path.
@@ -3,9 +3,8 @@
from lxml import etree
import io
import unittest
from odoo.tests.common import tagged
from odoo.tests.common import BaseCase
from odoo.tools.view_validation import (
valid_page_in_book, valid_att_in_form, valid_type_in_colspan,
valid_type_in_col, valid_att_in_field, valid_att_in_label,
@@ -131,8 +130,7 @@ valid_tree = etree.parse(io.BytesIO(b'''\
''')).getroot()
@tagged('standard', 'at_install')
class TestViewValidation(unittest.TestCase):
class TestViewValidation(BaseCase):
""" Test the view validation code (but not the views themselves). """
def test_page_validation(self):
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
import os.path
import unittest
from odoo.tests.common import tagged
from odoo.tests.common import BaseCase
from odoo.tools.mimetypes import guess_mimetype
def contents(extension):
@@ -14,8 +13,7 @@ def contents(extension):
return f.read()
@tagged('standard', 'at_install')
class TestMimeGuessing(unittest.TestCase):
class TestMimeGuessing(BaseCase):
def test_doc(self):
self.assertEqual(
guess_mimetype(contents('doc')),
+83 -22
View File
@@ -189,8 +189,9 @@ class MetaCase(type):
super(MetaCase, cls).__init__(name, bases, attrs)
# assign default test tags
if cls.__module__.startswith('odoo.addons.'):
module = cls.__module__.split('.')[2]
cls.test_tags = {'standard', 'at_install', module}
cls.test_tags = {'standard', 'at_install'}
cls.test_module = cls.__module__.split('.')[2]
cls.test_class = cls.__name__
class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})):
@@ -244,7 +245,10 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})):
def _assertRaises(self, exception):
""" Context manager that clears the environment upon failure. """
with super(BaseCase, self).assertRaises(exception) as cm:
with self.env.clear_upon_failure():
if hasattr(self, 'env'):
with self.env.clear_upon_failure():
yield cm
else:
yield cm
def assertRaises(self, exception, func=None, *args, **kwargs):
@@ -333,9 +337,23 @@ class BaseCase(TreeCase, MetaCase('DummyCase', (object,), {})):
return False
return True
def _repr_field_value(record, field_name):
record_value = record[field_name]
field_type = record._fields[field_name].type
if field_type == 'monetary':
currency_field_name = record._fields[field_name].currency_field
record_currency = record[currency_field_name]
return record_currency and record_currency.round(record_value) or record_value
elif field_type in ('one2many', 'many2many'):
return set(record_value.ids)
elif field_type == 'many2one':
return record_value.id
else:
return record_value
def _format_message(records, expected_values):
''' Return a formatted representation of records/expected_values. '''
all_records_values = records.read(list(expected_values[0].keys()), load=False)
all_records_values = [{key: _repr_field_value(record, key) for key in expected_values[0]} for record in records]
msg1 = '\n'.join(pprint.pformat(dic) for dic in all_records_values)
msg2 = '\n'.join(pprint.pformat(dic) for dic in expected_values)
return 'Current values:\n\n%s\n\nExpected values:\n\n%s' % (msg1, msg2)
@@ -706,6 +724,8 @@ class ChromeBrowser():
res = None
if res and res.get('id') == ready_id:
if res.get('result') == awaited_result:
if has_exceeded:
self._logger.info('The ready code tooks too much time : %s', tdiff)
return True
else:
last_bad_res = res
@@ -713,7 +733,6 @@ class ChromeBrowser():
tdiff = time.time() - start_time
if tdiff >= 2 and not has_exceeded:
has_exceeded = True
self._logger.warning('The ready code takes too much time : %s', tdiff)
self.take_screenshot(prefix='failed_ready')
self._logger.info('Ready code last try result: %s', last_bad_res or res)
@@ -1806,7 +1825,7 @@ def _get_node(view, f, *arg):
def tagged(*tags):
"""
A decorator to tag TestCase objects
A decorator to tag BaseCase objects
Tags are stored in a set that can be accessed from a 'test_tags' attribute
A tag prefixed by '-' will remove the tag e.g. to remove the 'standard' tag
By default, all Test classes from odoo.tests.common have a test_tags
@@ -1816,33 +1835,75 @@ def tagged(*tags):
def tags_decorator(obj):
include = {t for t in tags if not t.startswith('-')}
exclude = {t[1:] for t in tags if t.startswith('-')}
obj.test_tags = (getattr(obj, 'test_tags', set()) | include) - exclude
obj.test_tags = (getattr(obj, 'test_tags', set()) | include) - exclude # todo remove getattr in master since we want to limmit tagged to BaseCase and always have +standard tag
return obj
return tags_decorator
class TagsSelector(object):
""" Test selector based on tags. """
filter_spec_re = re.compile(r'^([+-]?)(\*|\w*)(?:/(\w*))?(?::(\w*))?(?:\.(\w*))?$') # [-][tag][/module][:class][.method]
def __init__(self, spec):
""" Parse the spec to determine tags to include and exclude. """
clean_tags = {t.strip() for t in spec.split(',') if t.strip() != ''}
self.exclude = {t[1:] for t in clean_tags if t.startswith('-')}
self.include = {t.replace('+', '') for t in clean_tags if not t.startswith('-')}
filter_specs = {t.strip() for t in spec.split(',') if t.strip()}
self.exclude = set()
self.include = set()
def check(self, arg):
for filter_spec in filter_specs:
match = self.filter_spec_re.match(filter_spec)
if not match:
_logger.error('Invalid tag %s', filter_spec)
continue
sign, tag, module, klass, method = match.groups()
is_include = sign != '-'
if not tag and is_include:
# including /module:class.method implicitly requires 'standard'
tag = 'standard'
elif not tag or tag == '*':
# '*' indicates all tests (instead of 'standard' tests only)
tag = None
test_filter = (tag, module, klass, method)
if is_include:
self.include.add(test_filter)
else:
self.exclude.add(test_filter)
if self.exclude and not self.include:
self.include.add(('standard', None, None, None))
def check(self, test):
""" Return whether ``arg`` matches the specification: it must have at
least one tag in ``self.include`` and none in ``self.exclude``.
least one tag in ``self.include`` and none in ``self.exclude`` for each tag category.
"""
# handle the case where the Test does not inherit from TransactionCase
tags = getattr(arg, 'test_tags', set())
inter_no_test = self.exclude.intersection(tags)
if inter_no_test:
_logger.debug("Test '%s' not selected because it is tagged with : %s (exclusions: %s)", arg, inter_no_test, self.exclude)
if not hasattr(test, 'test_tags'): # handle the case where the Test does not inherit from BaseCase and has no test_tags
_logger.debug("Skipping test '%s' because no test_tag found.", test)
return False
inter_to_test = self.include.intersection(tags)
if not inter_to_test:
_logger.debug("Test '%s' not selected because it was not tagged with %s", arg, self.include)
test_module = getattr(test, 'test_module', None)
test_class = getattr(test, 'test_class', None)
test_tags = test.test_tags | {test_module} # module as test_tags deprecated, keep for retrocompatibility,
test_method = getattr(test, '_testMethodName', None)
def _is_matching(test_filter):
(tag, module, klass, method) = test_filter
if tag and tag not in test_tags:
return False
elif module and module != test_module:
return False
elif klass and klass != test_class:
return False
elif method and test_method and method != test_method:
return False
return True
if any(_is_matching(test_filter) for test_filter in self.exclude):
return False
_logger.debug("Test '%s' selected: tagged with %s, exclusions: %s, inclusions: %s", arg, tags, self.exclude, self.include)
return True
if any(_is_matching(test_filter) for test_filter in self.include):
return True
return False
+8 -1
View File
@@ -160,7 +160,14 @@ class configmanager(object):
dest='test_enable',
help="Enable unit tests.")
group.add_option("--test-tags", dest="test_tags",
help="Comma separated list of tags to filter which tests to execute. Enable unit tests if set.")
help="""Comma separated list of spec to filter which tests to execute. Enable unit tests if set.
A filter spec has the format: [-][tag][/module][:class][.method]
The '-' specifies if we want to include or exclude tests matching this spec.
The tag will match tags added on a class with a @tagged decorator. By default tag value is 'standard' when not
given on include mode. '*' will match all tags. Tag will also match module name (deprecated, use /module)
The module, class, and method will respectively match the module name, test class name and test method name.
examples: :TestClass.test_func,/test_module,external
""")
parser.add_option_group(group)