[MERGE] forward port branch saas-14 up to b2fa9f2ad1

This commit is contained in:
Christophe Simonis
2017-03-17 18:03:00 +01:00
38 changed files with 503 additions and 146 deletions
@@ -410,18 +410,17 @@ class AccountBankStatementLine(models.Model):
@api.multi
def button_cancel_reconciliation(self):
aml_to_unbind = self.env['account.move.line']
aml_to_cancel = self.env['account.move']
aml_to_cancel = self.env['account.move.line']
payment_to_unreconcile = self.env['account.payment']
payment_to_cancel = self.env['account.payment']
for st_line in self:
aml_to_unbind |= st_line.journal_entry_ids
for move in st_line.journal_entry_ids:
for line in move.line_ids:
payment_to_unreconcile |= line.payment_id
if st_line.move_name and line.payment_id.payment_reference == st_line.move_name:
#there can be several moves linked to a statement line but maximum one created by the line itself
aml_to_cancel |= st_line.journal_entry_ids
payment_to_cancel |= line.payment_id
for line in st_line.journal_entry_ids:
payment_to_unreconcile |= line.payment_id
if st_line.move_name and line.payment_id.payment_reference == st_line.move_name:
#there can be several moves linked to a statement line but maximum one created by the line itself
aml_to_cancel |= st_line.journal_entry_ids
payment_to_cancel |= line.payment_id
aml_to_unbind = aml_to_unbind - aml_to_cancel
if aml_to_unbind:
@@ -433,7 +432,7 @@ class AccountBankStatementLine(models.Model):
if aml_to_cancel:
aml_to_cancel.remove_move_reconcile()
moves_to_cancel = set([x.move_id for x in aml_to_cancel])
moves_to_cancel = aml_to_cancel.mapped('move_id')
moves_to_cancel.button_cancel()
moves_to_cancel.unlink()
if payment_to_cancel:
+13 -2
View File
@@ -34,9 +34,9 @@ class AccountConfigSettings(models.TransientModel):
module_account_reports = fields.Boolean("Dynamic Reports")
module_account_reports_followup = fields.Boolean("Enable payment followup management")
default_sale_tax_id = fields.Many2one('account.tax', string="Default Sale Tax",
default_model="account.config.settings", company_dependent=True, oldname="default_sale_tax")
company_dependent=True, oldname="default_sale_tax")
default_purchase_tax_id = fields.Many2one('account.tax', string="Default Purchase Tax",
default_model="account.config.settings", company_dependent=True, oldname="default_purchase_tax")
company_dependent=True, oldname="default_purchase_tax")
module_l10n_us_check_printing = fields.Boolean("Allow check printing and deposits")
module_account_batch_deposit = fields.Boolean(string='Use batch deposit',
help='This allows you to group received checks before you deposit them to the bank.\n'
@@ -53,6 +53,17 @@ class AccountConfigSettings(models.TransientModel):
module_product_margin = fields.Boolean(string="Allow Product Margin")
module_l10n_eu_service = fields.Boolean(string="EU Digital Goods VAT")
@api.model
def get_default_tax_fields(self, fields):
default_purchase_tax_id = self.env['ir.config_parameter'].sudo().get_param('account.default_purchase_tax_id', default=False)
default_sale_tax_id = self.env['ir.config_parameter'].sudo().get_param('account.default_sale_tax_id', default=False)
return dict(default_purchase_tax_id=int(default_purchase_tax_id), default_sale_tax_id=int(default_sale_tax_id))
@api.multi
def set_default_tax_fields(self):
self.env['ir.config_parameter'].sudo().set_param("account.default_purchase_tax_id", self.default_purchase_tax_id.id)
self.env['ir.config_parameter'].sudo().set_param("account.default_sale_tax_id", self.default_sale_tax_id.id)
@api.depends('company_id')
def _compute_has_chart_of_accounts(self):
self.has_chart_of_accounts = bool(self.company_id.chart_template_id)
+20 -3
View File
@@ -11,8 +11,8 @@ class BaseConfigSettings(models.TransientModel):
group_multi_company = fields.Boolean("Manage multiple companies", implied_group='base.group_multi_company')
company_id = fields.Many2one('res.company', string='Company', required=True,
default=lambda self: self.env.user.company_id)
default_user_rights = fields.Boolean("Default Access Rights", default_model='base.config.settings')
default_external_email_server = fields.Boolean("External Email Servers", default_model='base.config.settings')
default_user_rights = fields.Boolean("Default Access Rights")
default_external_email_server = fields.Boolean("External Email Servers")
module_base_import = fields.Boolean("Allow users to import data from CSV/XLS/XLSX/ODS files")
module_pad = fields.Boolean("External Pads")
module_google_calendar = fields.Boolean(
@@ -27,12 +27,29 @@ class BaseConfigSettings(models.TransientModel):
help="Share your partners to all companies defined in your instance.\n"
" * Checked : Partners are visible for every companies, even if a company is defined on the partner.\n"
" * Unchecked : Each company can see only its partner (partners where company is defined). Partners not related to a company are visible for all companies.")
default_custom_report_footer = fields.Boolean("Custom Report Footer", default_model='base.config.settings')
default_custom_report_footer = fields.Boolean("Custom Report Footer")
rml_footer = fields.Text(related="company_id.rml_footer", string='Custom Report Footer', help="Footer text displayed at the bottom of all reports.")
group_multi_currency = fields.Boolean(string='Allow multi currencies',
implied_group='base.group_multi_currency',
help="Allows to work in a multi currency environment")
@api.model
def get_default_fields(self, fields):
default_external_email_server = self.env['ir.config_parameter'].sudo().get_param('base_setup.default_external_email_server', default=False)
default_user_rights = self.env['ir.config_parameter'].sudo().get_param('base_setup.default_user_rights', default=False)
default_custom_report_footer = self.env['ir.config_parameter'].sudo().get_param('base_setup.default_custom_report_footer', default=False)
return {
'default_external_email_server': default_external_email_server,
'default_user_rights': default_user_rights,
'default_custom_report_footer': default_custom_report_footer,
}
@api.multi
def set_default_fields(self):
self.env['ir.config_parameter'].sudo().set_param("base_setup.default_external_email_server", self.default_external_email_server)
self.env['ir.config_parameter'].sudo().set_param("base_setup.default_user_rights", self.default_user_rights)
self.env['ir.config_parameter'].sudo().set_param("base_setup.default_custom_report_footer", self.default_custom_report_footer)
@api.multi
def open_company(self):
return {
+1 -2
View File
@@ -30,7 +30,6 @@ CRM_LEAD_FIELDS_TO_MERGE = [
'city',
'contact_name',
'description',
'email',
'fax',
'mobile',
'partner_name',
@@ -468,7 +467,7 @@ class Lead(models.Model):
body = [title]
fields = self.env['ir.model.fields'].search([('name', 'in', fields or []), ('model_id.model', '=', self._name)])
for field in fields:
value = self[field.name]
value = getattr(self, field.name, False)
if field.ttype == 'selection':
value = dict(field.get_values(self.env)).get(value, value)
elif field.ttype == 'many2one':
+1 -1
View File
@@ -11,7 +11,7 @@ class CRMSettings(models.TransientModel):
help="Odoo will generate an email alias based on the sales channel name")
alias_prefix = fields.Char('Default Alias Name for Leads')
alias_domain = fields.Char('Alias Domain', default=lambda self: self.env["ir.config_parameter"].sudo().get_param("mail.catchall.domain"))
default_generate_lead_from_alias = fields.Boolean(default_model='sale.config.settings')
default_generate_lead_from_alias = fields.Boolean()
group_use_lead = fields.Boolean(string="Leads", implied_group='crm.group_use_lead')
module_crm_voip = fields.Boolean("Asterisk (VoIP)")
@@ -8,7 +8,7 @@
<field name="arch" type="xml">
<pivot string="Pipeline Analysis" disable_linking="True">
<field name="create_date" interval="month" type="col"/>
<field name="stage_name" type="row"/>
<field name="stage_id" type="row"/>
<field name="expected_revenue" type="measure"/>
</pivot>
</field>
@@ -31,7 +31,7 @@
<field name="model">crm.opportunity.report</field>
<field name="arch" type="xml">
<graph string="Pipeline Analysis" stacked="True">
<field name="stage_name" type="row"/>
<field name="stage_id" type="row"/>
<field name="date_deadline" type="row" interval="month"/>
<field name="expected_revenue" type="measure"/>
</graph>
+8 -1
View File
@@ -210,7 +210,6 @@ class MailMail(models.Model):
for mail_batch in tools.split_every(batch_size, record_ids):
yield server_id, mail_batch
@api.multi
def send(self, auto_commit=False, raise_exception=False):
""" Sends the selected emails immediately, ignoring their current
@@ -231,6 +230,14 @@ class MailMail(models.Model):
smtp_session = None
try:
smtp_session = self.env['ir.mail_server'].connect(mail_server_id=server_id)
except Exception as exc:
if raise_exception:
# To be consistent and backward compatible with mail_mail.send() raised
# exceptions, it is encapsulated into an Odoo MailDeliveryException
raise MailDeliveryException(_('Unable to connect to SMTP Server'), exc)
else:
self.browse(batch_ids).write({'state': 'exception', 'failure_reason': exc})
else:
self.browse(batch_ids)._send(
auto_commit=auto_commit,
raise_exception=raise_exception,
@@ -9,6 +9,7 @@
}
.kanban_html_preview {
pointer-events: none;
width: 600px;
-webkit-transform: scale(.50);
-ms-transform: scale(.50);
+6
View File
@@ -3168,6 +3168,12 @@ msgstr ""
msgid "You cannot cancel a stock move having already consumed material"
msgstr ""
#. module: mrp
#: code:addons/mrp/models/stock_move.py:40
#, python-format
msgid "You cannot use the same serial number in two different lines."
msgstr ""
#. module: mrp
#: code:addons/mrp/wizard/change_production_qty.py:44
#, python-format
+10
View File
@@ -28,6 +28,16 @@ class StockMoveLots(models.Model):
done_move = fields.Boolean('Move Done', related='move_id.is_done', store=True) # TDE FIXME: naming
plus_visible = fields.Boolean("Plus Visible", compute='_compute_plus')
@api.one
@api.constrains('lot_id')
def _check_lot_id(self):
if self.move_id.product_id.tracking == 'serial':
lots = set([])
for move_lot in self.move_id.move_lot_ids.filtered(lambda r: not r.lot_produced_id):
if move_lot.lot_id in lots:
raise exceptions.UserError(_('You cannot use the same serial number in two different lines.'))
lots.add(move_lot.lot_id)
def _compute_plus(self):
for movelot in self:
if movelot.move_id.product_id.tracking == 'serial':
+2 -2
View File
@@ -125,8 +125,8 @@ class MrpProductProduce(models.TransientModel):
#Possibly the entire move is selected
remaining_qty = movelots.quantity - movelots.quantity_done
if remaining_qty > 0:
new_move_lot = movelots.copy()
new_move_lot.write({'quantity':movelots.quantity_done, 'lot_produced_id': self.lot_id.id})
default = {'quantity': movelots.quantity_done, 'lot_produced_id': self.lot_id.id}
new_move_lot = movelots.copy(default=default)
movelots.write({'quantity': remaining_qty, 'quantity_done': 0})
else:
movelots.write({'lot_produced_id': self.lot_id.id})
+2 -2
View File
@@ -453,7 +453,7 @@ class RepairLine(models.Model):
if not self.to_invoice:
self.price_subtotal = 0.0
else:
taxes = self.env['account.tax'].compute_all(self.price_unit, self.repair_id.pricelist_id.currency_id, self.product_uom_qty, self.product_id, self.repair_id.partner_id)
taxes = self.tax_id.compute_all(self.price_unit, self.repair_id.pricelist_id.currency_id, self.product_uom_qty, self.product_id, self.repair_id.partner_id)
self.price_subtotal = taxes['total_excluded']
@api.onchange('type', 'repair_id')
@@ -536,7 +536,7 @@ class RepairFee(models.Model):
if not self.to_invoice:
self.price_subtotal = 0.0
else:
taxes = self.env['account.tax'].compute_all(self.price_unit, self.repair_id.pricelist_id.currency_id, self.product_uom_qty, self.product_id, self.repair_id.partner_id)
taxes = self.tax_id.compute_all(self.price_unit, self.repair_id.pricelist_id.currency_id, self.product_uom_qty, self.product_id, self.repair_id.partner_id)
self.price_subtotal = taxes['total_excluded']
@api.onchange('repair_id', 'product_id', 'product_uom_qty')
+2 -2
View File
@@ -250,8 +250,8 @@ class SaleOrder(models.Model):
@api.model
def create(self, vals):
if vals.get('name', 'New') == 'New':
vals['name'] = self.env['ir.sequence'].next_by_code('sale.order') or 'New'
if vals.get('name', _('New')) == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('sale.order') or _('New')
# Makes sure partner_invoice_id', 'partner_shipping_id' and 'pricelist_id' are defined
if any(f not in vals for f in ['partner_invoice_id', 'partner_shipping_id', 'pricelist_id']):
+10 -1
View File
@@ -8,7 +8,7 @@ class SaleConfiguration(models.TransientModel):
_inherit = 'sale.config.settings'
sale_note = fields.Text(related='company_id.sale_note', string="Terms & Conditions")
default_use_sale_note = fields.Boolean(string='Default Terms & Conditions', default_model='sale.config.settings')
default_use_sale_note = fields.Boolean(string='Default Terms & Conditions')
group_product_variant = fields.Boolean("Attributes & Variants",
implied_group='product.group_product_variant')
group_sale_pricelist = fields.Boolean("Use pricelists to adapt your price per customers",
@@ -85,6 +85,15 @@ class SaleConfiguration(models.TransientModel):
module_product_email_template = fields.Boolean("Specific Email")
module_sale_coupon = fields.Boolean("Coupons & Promotions")
@api.model
def get_default_use_sale_note(self, fields):
default_use_sale_note = self.env['ir.config_parameter'].sudo().get_param('sale.default_use_sale_note', default=False)
return dict(default_use_sale_note=default_use_sale_note)
@api.multi
def set_default_use_sale_note(self):
self.env['ir.config_parameter'].sudo().set_param("sale.default_use_sale_note", self.default_use_sale_note)
@api.model
def get_default_sale_pricelist_setting(self, fields):
sale_pricelist_setting = self.env['ir.values'].get_default('sale.config.settings', 'sale_pricelist_setting')
+2 -1
View File
@@ -183,7 +183,8 @@ class SaleOrderLine(models.Model):
qty = 0.0
for move in self.procurement_ids.mapped('move_ids').filtered(lambda r: r.state == 'done' and not r.scrapped):
if move.location_dest_id.usage == "customer":
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom)
if not move.origin_returned_move_id:
qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom)
elif move.location_dest_id.usage == "internal" and move.to_refund_so:
qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom)
return qty
+42 -16
View File
@@ -386,31 +386,57 @@ class InventoryLine(models.Model):
('owner_id', '=', self.partner_id.id),
('package_id', '=', self.package_id.id)])
def _get_move_values(self, qty, location_id, location_dest_id):
self.ensure_one()
return {
'name': _('INV:') + (self.inventory_id.name or ''),
'product_id': self.product_id.id,
'product_uom': self.product_uom_id.id,
'product_uom_qty': qty,
'date': self.inventory_id.date,
'company_id': self.inventory_id.company_id.id,
'inventory_id': self.inventory_id.id,
'state': 'confirmed',
'restrict_lot_id': self.prod_lot_id.id,
'restrict_partner_id': self.partner_id.id,
'location_id': location_id,
'location_dest_id': location_dest_id,
}
def _fixup_negative_quants(self):
""" This will handle the irreconciable quants created by a force availability followed by a
return. When generating the moves of an inventory line, we look for quants of this line's
product created to compensate a force availability. If there are some and if the quant
which it is propagated from is still in the same location, we move it to the inventory
adjustment location before getting it back. Getting the quantity from the inventory
location will allow the negative quant to be compensated.
"""
self.ensure_one()
for quant in self._get_quants().filtered(lambda q: q.propagated_from_id.location_id.id == self.location_id.id):
# send the quantity to the inventory adjustment location
move_out_vals = self._get_move_values(quant.qty, self.location_id.id, self.product_id.property_stock_inventory.id)
move_out = self.env['stock.move'].create(move_out_vals)
self.env['stock.quant'].quants_reserve([(quant, quant.qty)], move_out)
move_out.action_done()
# get back the quantity from the inventory adjustment location
move_in_vals = self._get_move_values(quant.qty, self.product_id.property_stock_inventory.id, self.location_id.id)
move_in = self.env['stock.move'].create(move_in_vals)
move_in.action_done()
def _generate_moves(self):
moves = self.env['stock.move']
Quant = self.env['stock.quant']
for line in self:
line._fixup_negative_quants()
if float_utils.float_compare(line.theoretical_qty, line.product_qty, precision_rounding=line.product_id.uom_id.rounding) == 0:
continue
diff = line.theoretical_qty - line.product_qty
vals = {
'name': _('INV:') + (line.inventory_id.name or ''),
'product_id': line.product_id.id,
'product_uom': line.product_uom_id.id,
'date': line.inventory_id.date,
'company_id': line.inventory_id.company_id.id,
'inventory_id': line.inventory_id.id,
'state': 'confirmed',
'restrict_lot_id': line.prod_lot_id.id,
'restrict_partner_id': line.partner_id.id}
if diff < 0: # found more than expected
vals['location_id'] = line.product_id.property_stock_inventory.id
vals['location_dest_id'] = line.location_id.id
vals['product_uom_qty'] = abs(diff)
vals = self._get_move_values(abs(diff), line.product_id.property_stock_inventory.id, line.location_id.id)
else:
vals['location_id'] = line.location_id.id
vals['location_dest_id'] = line.product_id.property_stock_inventory.id
vals['product_uom_qty'] = diff
vals = self._get_move_values(abs(diff), line.location_id.id, line.product_id.property_stock_inventory.id)
move = moves.create(vals)
if diff > 0:
+4 -2
View File
@@ -245,8 +245,10 @@ class Picking(models.Model):
picking_type_code = fields.Selection([
('incoming', 'Vendors'),
('outgoing', 'Customers'),
('internal', 'Internal')], related='picking_type_id.code')
picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs')
('internal', 'Internal')], related='picking_type_id.code',
readonly=True)
picking_type_entire_packs = fields.Boolean(related='picking_type_id.show_entire_packs',
readonly=True)
quant_reserved_exist = fields.Boolean(
'Has quants already reserved', compute='_compute_quant_reserved_exist',
+1 -1
View File
@@ -578,7 +578,7 @@ class Warehouse(models.Model):
routes = self.env['stock.location.route'].search([('supplied_wh_id', 'in', self.ids)])
self.env['procurement.rule'].search([
'&', ('route_id', 'in', routes.ids),
('location_src_id.usage', '=', 'transit')]).write({'location_id': new_location})
('location_src_id.usage', '=', 'transit')]).write({'location_id': new_location.id})
@api.multi
def _update_routes(self):
+128
View File
@@ -103,6 +103,134 @@ class TestInventory(TestStockCommon):
self.assertEqual(product.qty_available, 8.0)
self.assertEqual(product.with_context(location=self.env.ref('stock.stock_location_customers').id).qty_available, 7.0)
def test_inventory_adjustment_and_negative_quants_1(self):
"""Make sure negative quants from returns get wiped out with an inventory adjustment"""
productA = self.env['product.product'].create({'name': 'Product A', 'type': 'product'})
stock_location = self.env.ref('stock.stock_location_stock')
customer_location = self.env.ref('stock.stock_location_customers')
location_loss = self.env.ref('stock.location_inventory')
# Create a picking out and force availability
picking_out = self.env['stock.picking'].create({
'partner_id': self.env.ref('base.res_partner_2').id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
'location_id': stock_location.id,
'location_dest_id': customer_location.id,
})
self.env['stock.move'].create({
'name': productA.name,
'product_id': productA.id,
'product_uom_qty': 1,
'product_uom': productA.uom_id.id,
'picking_id': picking_out.id,
'location_id': stock_location.id,
'location_dest_id': customer_location.id,
})
picking_out.action_confirm()
picking_out.force_assign()
picking_out.do_transfer()
# Create return picking for all goods
default_data = self.env['stock.return.picking']\
.with_context(active_ids=picking_out.ids, active_id=picking_out.ids[0])\
.default_get([
'move_dest_exists',
'original_location_id',
'product_return_moves',
'parent_location_id',
'location_id',
])
return_wiz = self.env['stock.return.picking']\
.with_context(active_ids=picking_out.ids, active_id=picking_out.ids[0])\
.create(default_data)
res = return_wiz.create_returns()
return_pick = self.env['stock.picking'].browse(res['res_id'])
return_pick.action_assign()
return_pick.do_transfer()
# Make an inventory adjustment to set the quantity to 0
inventory = self.env['stock.inventory'].create({
'name': 'Starting for product_1',
'filter': 'product',
'location_id': stock_location.id,
'product_id': productA.id,
})
inventory.prepare_inventory()
self.assertEqual(len(inventory.line_ids), 1, "Wrong inventory lines generated.")
self.assertEqual(inventory.line_ids.theoretical_qty, 0, "Theoretical quantity should be zero.")
inventory.action_done()
# The inventory adjustment should have created two moves
self.assertEqual(len(inventory.move_ids), 2)
quantity = inventory.move_ids.mapped('product_qty')
self.assertEqual(quantity, [1, 1], "Moves created with wrong quantity.")
location_ids = inventory.move_ids.mapped('location_id').ids
self.assertEqual(set(location_ids), {stock_location.id, location_loss.id})
# There should be no quant in the stock location
quants = self.env['stock.quant'].search([('product_id', '=', productA.id), ('location_id', '=', stock_location.id)])
self.assertEqual(len(quants), 0)
# There should be one quant in the inventory loss location
quant = self.env['stock.quant'].search([('product_id', '=', productA.id), ('location_id', '=', location_loss.id)])
self.assertEqual(len(quant), 1)
self.assertEqual(quant.qty, 1)
def test_inventory_adjustment_and_negative_quants_2(self):
"""Make sure negative quants get wiped out with an inventory adjustment"""
productA = self.env['product.product'].create({'name': 'Product A', 'type': 'product'})
stock_location = self.env.ref('stock.stock_location_stock')
customer_location = self.env.ref('stock.stock_location_customers')
location_loss = self.env.ref('stock.location_inventory')
# Create a picking out and force availability
picking_out = self.env['stock.picking'].create({
'partner_id': self.env.ref('base.res_partner_2').id,
'picking_type_id': self.env.ref('stock.picking_type_out').id,
'location_id': stock_location.id,
'location_dest_id': customer_location.id,
})
self.env['stock.move'].create({
'name': productA.name,
'product_id': productA.id,
'product_uom_qty': 1,
'product_uom': productA.uom_id.id,
'picking_id': picking_out.id,
'location_id': stock_location.id,
'location_dest_id': customer_location.id,
})
picking_out.action_confirm()
picking_out.force_assign()
picking_out.do_transfer()
# Make an inventory adjustment to set the quantity to 0
inventory = self.env['stock.inventory'].create({
'name': 'Starting for product_1',
'filter': 'product',
'location_id': stock_location.id,
'product_id': productA.id,
})
inventory.prepare_inventory()
self.assertEqual(len(inventory.line_ids), 1, "Wrong inventory lines generated.")
self.assertEqual(inventory.line_ids.theoretical_qty, -1, "Theoretical quantity should be -1.")
inventory.line_ids.product_qty = 0 # Put the quantity back to 0
inventory.action_done()
# The inventory adjustment should have created one
self.assertEqual(len(inventory.move_ids), 1)
quantity = inventory.move_ids.mapped('product_qty')
self.assertEqual(quantity, [1], "Moves created with wrong quantity.")
location_ids = inventory.move_ids.mapped('location_id').ids
self.assertEqual(set(location_ids), {location_loss.id})
# There should be no quant in the stock location
quants = self.env['stock.quant'].search([('product_id', '=', productA.id), ('location_id', '=', stock_location.id)])
self.assertEqual(len(quants), 0)
# There should be no quant in the inventory loss location
quant = self.env['stock.quant'].search([('product_id', '=', productA.id), ('location_id', '=', location_loss.id)])
self.assertEqual(len(quant), 0)
class TestResupply(TestStockCommon):
def setUp(self):
+1 -1
View File
@@ -179,7 +179,7 @@
.o_form_invalid {
&.o_form_input, .o_form_input, &.o_form_textarea, .o_form_textarea {
border-color: @brand-danger;
border-color: @brand-danger!important; // enterprise o_form_required rule overrides this without !important
}
}
@@ -642,8 +642,8 @@ var CalendarView = View.extend({
date_stop = m_start.toDate();
}
var r = {
'start': moment(date_start).format('YYYY-MM-DD HH:mm:ss'),
'end': moment(date_stop).format('YYYY-MM-DD HH:mm:ss'),
'start': moment(date_start).toISOString(),
'end': moment(date_stop).toISOString(),
'title': the_title,
'allDay': (this.fields[this.date_start].type == 'date' || (this.all_day && evt[this.all_day]) || false),
'id': evt.id,
+13 -5
View File
@@ -2313,11 +2313,19 @@ eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) {
eventHandler.modules.clipboard.attach = function(layoutInfo) {
var $editable = layoutInfo.editable();
$editable.on('paste', function(e) {
e.preventDefault();
$editable.data('NoteHistory').recordUndo($editable);
var pastedText = ((e.originalEvent || e).clipboardData || window.clipboardData).getData('Text');
var formattedText = pastedText.replace(/([^.!?:;])\r?\n/g, "$1").trim(); // Remove linebreaks which are not at the end of a sentence
document.execCommand("insertText", false, formattedText);
var clipboardData = ((e.originalEvent || e).clipboardData || window.clipboardData);
// Change nothing if pasting html (copy from text editor / web / ...) or
// if clipboardData is not available (IE / ...)
if (clipboardData && clipboardData.types && clipboardData.types.length === 1 && clipboardData.types[0] === "text/plain") {
e.preventDefault();
$editable.data('NoteHistory').recordUndo($editable); // FIXME
var pastedText = clipboardData.getData("text/plain");
// Try removing linebreaks which are not really linebreaks (in a PDF,
// when a sentence goes over the next line, copying it considers it
// a linebreak for example).
var formattedText = pastedText.replace(/([\w-])\r?\n([\w-])/g, "$1 $2").trim();
document.execCommand("insertText", false, formattedText);
}
});
};
+22 -14
View File
@@ -98,20 +98,28 @@ var getMatchedCSSRules = function (a) {
if (style.display === 'block') {
delete style.display;
}
if (style['margin-top']) {
style.margin = (style['margin-top'] || 0) + ' ' + (style['margin-right'] || 0) + ' ' + (style['margin-bottom'] || 0) + ' ' + (style['margin-left'] || 0);
delete style['margin-top'];
delete style['margin-right'];
delete style['margin-bottom'];
delete style['margin-left'];
}
if (style['padding-top']) {
style.padding = (style['padding-top'] || 0) + ' ' + (style['padding-right'] || 0) + ' ' + (style['padding-bottom'] || 0) + ' ' + (style['padding-left'] || 0);
delete style['padding-top'];
delete style['padding-right'];
delete style['padding-bottom'];
delete style['padding-left'];
}
_.each(['margin', 'padding'], function(p) {
if (style[p+'-top'] || style[p+'-right'] || style[p+'-bottom'] || style[p+'-left']) {
if (style[p+'-top'] === style[p+'-right'] && style[p+'-top'] === style[p+'-bottom'] && style[p+'-top'] === style[p+'-left']) {
// keep => property: [top/right/bottom/left value];
style[p] = style[p+'-top'];
}
else {
// keep => property: [top value] [right value] [bottom value] [left value];
style[p] = (style[p+'-top'] || 0) + ' ' + (style[p+'-right'] || 0) + ' ' + (style[p+'-bottom'] || 0) + ' ' + (style[p+'-left'] || 0);
if (style[p].indexOf('inherit') !== -1 || style[p].indexOf('initial') !== -1) {
// keep => property-top: [top value]; property-right: [right value]; property-bottom: [bottom value]; property-left: [left value];
delete style[p];
return;
}
}
delete style[p+'-top'];
delete style[p+'-right'];
delete style[p+'-bottom'];
delete style[p+'-left'];
}
});
return style;
};
+4 -4
View File
@@ -38,11 +38,11 @@
-webkit-border-radius: 3px;
border-radius: 3px;
background: white;">
<option value="en_US" t-att-selected="'en_US' == widget.lang ? 'selected' : null">
<t t-if="!widget.get('effective_readonly')">English (edit mode)</t>
<t t-if="widget.get('effective_readonly')">English</t>
</option>
<t t-foreach="widget.languages" t-as="lang">
<option value="en_US" t-att-selected="'en_US' == widget.lang ? 'selected' : null">
<t t-if="!widget.get('effective_readonly')">English (edit mode)</t>
<t t-if="widget.get('effective_readonly')">English</t>
</option>
<option t-att-value="lang.code" t-att-selected="lang.code == widget.lang ? 'selected' : null"><t t-esc="lang.name"/></option>
</t>
</select>
@@ -63,7 +63,7 @@ var KanbanView = View.extend({
this.qweb = new QWeb(session.debug, {_s: session.origin});
this.limit = this.options.limit || parseInt(this.fields_view.arch.attrs.limit, 10) || 40;
this.fields = {};
this.fields = this.fields_view.fields;
this.fields_keys = _.keys(this.fields_view.fields);
this.grouped = undefined;
this.group_by_field = undefined;
@@ -105,7 +105,7 @@ var KanbanView = View.extend({
this.qweb.add_template(utils.json_node_to_xml(child));
break;
} else if (child.tag === 'field') {
var ftype = child.attrs.widget || this.fields_view.fields[child.attrs.name].type;
var ftype = child.attrs.widget || this.fields[child.attrs.name].type;
if(ftype === "many2many" && "context" in child.attrs) {
this.m2m_context[child.attrs.name] = child.attrs.context;
}
@@ -122,7 +122,7 @@ var KanbanView = View.extend({
do_search: function(domain, context, group_by) {
var self = this;
var group_by_field = group_by[0] || this.default_group_by;
var field = this.fields_view.fields[group_by_field];
var field = this.fields[group_by_field];
var options = {};
var fields_def;
if (field === undefined) {
@@ -248,7 +248,7 @@ var KanbanView = View.extend({
_.each(groups, function (group) {
var value = group.attributes.value;
group.id = value instanceof Array ? value[0] : value;
var field = self.fields_view.fields[options.group_by_field];
var field = self.fields[options.group_by_field];
if (field && field.type === "selection") {
value= _.find(field.selection, function (s) { return s[0] === group.id; });
}
@@ -304,7 +304,7 @@ var KanbanView = View.extend({
return this._super(action);
},
has_active_field: function() {
return this.fields_view.fields.active;
return this.fields.active;
},
_is_quick_create_enabled: function() {
if (!this.quick_creatable || !this.is_action_enabled('create'))
@@ -465,7 +465,7 @@ var KanbanView = View.extend({
var self = this;
// Drag'n'drop activation/deactivation
var group_by_field_attrs = this.fields_view.fields[this.group_by_field] || this.fields[this.group_by_field];
var group_by_field_attrs = this.fields[this.group_by_field];
// Deactivate the drag'n'drop if:
// - field is a date or datetime since we group by month
@@ -106,12 +106,12 @@
}
.o_kanban_tags {
margin: 0 0 @o-kanban-inside-vgutter -@o-kanban-inside-hgutter;
margin-left: -@o-kanban-inside-hgutter;
@media (max-width: @screen-xs-max) {
margin-left: -@o-kanban-inside-hgutter-mobile;
}
max-height: 9px;
.o_tag {
margin-bottom: @o-kanban-inside-vgutter;
width: 40px;
height: 9px;
display: inline-block;
+1 -1
View File
@@ -159,7 +159,7 @@ class WebsiteForm(http.Controller):
# def website_form_input_filter(self, values):
# values['name'] = '%s\'s Application' % values['partner_name']
# return values
dest_model = request.env[model.model]
dest_model = request.env[model.sudo().model]
if hasattr(dest_model, "website_form_input_filter"):
data['record'] = dest_model.website_form_input_filter(request, data['record'])
+3
View File
@@ -167,6 +167,9 @@ class IrModel(models.Model):
# prevent screwing up fields that depend on these models' fields
model.field_id._prepare_update()
imc = self.env['ir.model.constraint'].search([('model', 'in', self.ids)])
imc.unlink()
self._drop_table()
res = super(IrModel, self).unlink()
+3 -3
View File
@@ -122,7 +122,7 @@ class IntegerConverter(models.AbstractModel):
@api.model
def value_to_html(self, value, options):
return unicodifier(self.user_lang().format('%d', value, grouping=True))
return unicodifier(self.user_lang().format('%d', value, grouping=True).replace(r'-', u'\u2011'))
class FloatConverter(models.AbstractModel):
@@ -142,7 +142,7 @@ class FloatConverter(models.AbstractModel):
value = float_utils.float_round(value, precision_digits=precision)
fmt = '%.{precision}f'.format(precision=precision)
formatted = self.user_lang().format(fmt, value, grouping=True)
formatted = self.user_lang().format(fmt, value, grouping=True).replace(r'-', u'\u2011')
# %f does not strip trailing zeroes. %g does but its precision causes
# it to switch to scientific notation starting at a million *and* to
@@ -312,7 +312,7 @@ class MonetaryConverter(models.AbstractModel):
lang = self.user_lang()
formatted_amount = lang.format(fmt, display_currency.round(value),
grouping=True, monetary=True).replace(r' ', u'\N{NO-BREAK SPACE}')
grouping=True, monetary=True).replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'\u2011')
pre = post = u''
if display_currency.position == 'before':
+10 -1
View File
@@ -4,7 +4,7 @@
from ast import literal_eval
from odoo import api, fields, models, tools, _
from odoo.exceptions import AccessError, MissingError
from odoo.exceptions import AccessError, MissingError, ValidationError
from odoo.tools import pickle
EXCLUDED_FIELDS = set(('code',
@@ -218,6 +218,15 @@ class IrValues(models.Model):
# should be company-specific, need to get company id
company_id = self.env.user.company_id.id
# check consistency of model, field_name and value
try:
field = self.env[model]._fields[field_name]
field.convert_to_cache(value, self.browse())
except KeyError:
raise ValidationError(_("Invalid field %s.%s") % (model, field_name))
except Exception:
raise ValidationError(_("Invalid value for %s.%s: %s") % (model, field_name, value))
# remove existing defaults for the same scope
search_criteria = [
('key', '=', 'default'),
+1 -1
View File
@@ -177,7 +177,7 @@ class Currency(models.Model):
function += "if (arguments[1] === %s) { %s }" % (currency.id, return_str)
if (currency == company_currency):
company_currency_format = return_str
function = "if (arguments[1] === false || arguments[1] === undefined) {" + company_currency_format + " }" + function
function = "if (arguments[1] === false || arguments[1] === undefined) {" + company_currency_format + " }" + function
return function
def _select_companies_rates(self):
+2 -1
View File
@@ -218,8 +218,9 @@ class Lang(models.Model):
raise UserError(_("Language code cannot be modified."))
if vals.get('active') == False and self.env['res.users'].search([('lang', 'in', lang_codes)]):
raise UserError(_("Cannot unactivate a language that is currently used by users."))
res = super(Lang, self).write(vals)
self.clear_caches()
return super(Lang, self).write(vals)
return res
@api.multi
def unlink(self):
+48 -43
View File
@@ -1,90 +1,95 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import ValidationError
from odoo.tests.common import TransactionCase
class TestIrValues(TransactionCase):
def test_00(self):
# Create some default value for some (non-existing) model, for all users.
def test_defaults(self):
# Create some default value for some model, for all users.
ir_values = self.env['ir.values']
# use the old API
ir_values.set_default('unexisting_model', 'my_test_field', 'global value', condition=False)
# use the new API
ir_values.set_default('other_unexisting_model', 'my_other_test_field',
'conditional value', condition='foo=bar')
ir_values.set_default('res.partner', 'ref', 'X11')
ir_values.set_default('res.partner.title', 'shortcut', 'Mr', condition='name=Mister')
# Retrieve them.
# Retrieve them: ds is a list of triplets (id, name, value)
ds = ir_values.get_defaults('res.partner')
d = next((d for d in ds if d[1] == 'ref'), None)
self.assertTrue(d, "At least one value should be retrieved for this model.")
self.assertEqual(d[2], 'X11', "Can't retrieve the created default value.")
# d is a list of triplets (id, name, value)
# Old API
d = ir_values.get_defaults('unexisting_model', condition=False)
self.assertEqual(len(d), 1, "Only one single value should be retrieved for this model")
self.assertEqual(d[0][1], 'my_test_field', "Can't retrieve the created default value. (1)")
self.assertEqual(d[0][2], 'global value', "Can't retrieve the created default value. (2)")
ds = ir_values.get_defaults('res.partner.title')
d = next((d for d in ds if d[1] == 'shortcut'), None)
self.assertFalse(d, "No value should be retrieved, the condition is not met.")
# New API, Conditional version
d = ir_values.get_defaults('other_unexisting_model')
self.assertEqual(len(d), 0, "No value should be retrieved, the condition is not met")
d = ir_values.get_defaults('other_unexisting_model', condition="foo=eggs")
self.assertEqual(len(d), 0, 'Condition is not met either, no defaults should be returned')
d = ir_values.get_defaults('other_unexisting_model', condition="foo=bar")
self.assertEqual(len(d), 1, "Only one single value should be retrieved")
self.assertEqual(d[0][1], 'my_other_test_field', "Can't retrieve the created default value. (5)")
self.assertEqual(d[0][2], 'conditional value', "Can't retrieve the created default value. (6)")
ds = ir_values.get_defaults('res.partner.title', condition="name=Miss")
d = next((d for d in ds if d[1] == 'shortcut'), None)
self.assertFalse(d, "No value should be retrieved, the condition is not met.")
ds = ir_values.get_defaults('res.partner.title', condition="name=Mister")
d = next((d for d in ds if d[1] == 'shortcut'), None)
self.assertTrue(d, "At least one value should be retrieved.")
self.assertEqual(d[2], 'Mr', "Can't retrieve the created default value.")
# Do it again but for a specific user.
ir_values.set_default('unexisting_model', 'my_test_field', 'specific value', for_all_users=False, condition=False)
ir_values.set_default('res.partner', 'ref', '007', for_all_users=False)
# Retrieve it and check it is the one for the current user.
ds = ir_values.get_defaults('res.partner')
d = next((d for d in ds if d[1] == 'ref'), None)
self.assertTrue(d, "At least one value should be retrieved for this model.")
self.assertEqual(d[2], '007', "Can't retrieve the created default value.")
d = ir_values.get_defaults('unexisting_model', condition=False)
self.assertEqual(len(d), 1, "Only one default must be returned per field")
self.assertEqual(d[0][1], 'my_test_field', "Can't retrieve the created default value.")
self.assertEqual(d[0][2], 'specific value', "Can't retrieve the created default value.")
# Create some action bindings for a non-existing model.
# create invalid defaults
with self.assertRaises(ValidationError):
ir_values.set_default('unknown_model', 'unknown_field', 42)
with self.assertRaises(ValidationError):
ir_values.set_default('res.partner', 'unknown_field', 42)
with self.assertRaises(ValidationError):
ir_values.set_default('res.partner', 'lang', 'some_LANG')
with self.assertRaises(ValidationError):
ir_values.set_default('res.partner', 'credit_limit', 'foo')
def test_actions(self):
# Create some action bindings for a model.
act_id_1 = self.ref('base.act_values_form_action')
act_id_2 = self.ref('base.act_values_form_defaults')
act_id_3 = self.ref('base.action_res_company_form')
ir_values.set_action('OnDblClick Action', action_slot='tree_but_open', model='unexisting_model', action='ir.actions.act_window,%d' % act_id_1, res_id=False)
ir_values.set_action('OnDblClick Action 2', action_slot='tree_but_open', model='unexisting_model', action='ir.actions.act_window,%d' % act_id_2, res_id=False)
ir_values.set_action('Side Wizard', action_slot='client_action_multi', model='unexisting_model', action='ir.actions.act_window,%d' % act_id_3, res_id=False)
ir_values = self.env['ir.values']
ir_values.set_action('OnDblClick Action', action_slot='tree_but_open', model='res.partner', action='ir.actions.act_window,%d' % act_id_1, res_id=False)
ir_values.set_action('OnDblClick Action 2', action_slot='tree_but_open', model='res.partner', action='ir.actions.act_window,%d' % act_id_2, res_id=False)
ir_values.set_action('Side Wizard', action_slot='client_action_multi', model='res.partner', action='ir.actions.act_window,%d' % act_id_3, res_id=False)
reports = self.env['ir.actions.report.xml'].search([])
report_id = next(report.id for report in reports if not report.groups_id)
ir_values.set_action('Nice Report', action_slot='client_print_multi', model='unexisting_model', action='ir.actions.report.xml,%d' % report_id, res_id=False)
ir_values.set_action('Nice Report', action_slot='client_print_multi', model='res.partner', action='ir.actions.report.xml,%d' % report_id, res_id=False)
# Replace one action binding to set a new name.
ir_values.set_action('OnDblClick Action New', action_slot='tree_but_open', model='unexisting_model', action='ir.actions.act_window,%d' % act_id_1, res_id=False)
ir_values.set_action('OnDblClick Action New', action_slot='tree_but_open', model='res.partner', action='ir.actions.act_window,%d' % act_id_1, res_id=False)
# Retrieve the action bindings and check they're correct
actions = ir_values.get_actions(action_slot='tree_but_open', model='unexisting_model', res_id=False)
actions = ir_values.get_actions(action_slot='tree_but_open', model='res.partner', res_id=False)
self.assertEqual(len(actions), 2, "Mismatching number of bound actions")
#first action
# first action
self.assertEqual(len(actions[0]), 3, "Malformed action definition")
self.assertEqual(actions[0][1], 'OnDblClick Action 2', 'Bound action does not match definition')
self.assertTrue(isinstance(actions[0][2], dict) and actions[0][2]['id'] == act_id_2,
'Bound action does not match definition')
#second action - this ones comes last because it was re-created with a different name
# second action - this ones comes last because it was re-created with a different name
self.assertEqual(len(actions[1]), 3, "Malformed action definition")
self.assertEqual(actions[1][1], 'OnDblClick Action New', 'Re-Registering an action should replace it')
self.assertTrue(isinstance(actions[1][2], dict) and actions[1][2]['id'] == act_id_1,
'Bound action does not match definition')
actions = ir_values.get_actions(action_slot='client_action_multi', model='unexisting_model', res_id=False)
actions = ir_values.get_actions(action_slot='client_action_multi', model='res.partner', res_id=False)
self.assertEqual(len(actions), 1, "Mismatching number of bound actions")
self.assertEqual(len(actions[0]), 3, "Malformed action definition")
self.assertEqual(actions[0][1], 'Side Wizard', 'Bound action does not match definition')
self.assertTrue(isinstance(actions[0][2], dict) and actions[0][2]['id'] == act_id_3,
'Bound action does not match definition')
actions = ir_values.get_actions(action_slot='client_print_multi', model='unexisting_model', res_id=False)
actions = ir_values.get_actions(action_slot='client_print_multi', model='res.partner', res_id=False)
self.assertEqual(len(actions), 1, "Mismatching number of bound actions")
self.assertEqual(len(actions[0]), 3, "Malformed action definition")
self.assertEqual(actions[0][1], 'Nice Report', 'Bound action does not match definition')
@@ -72,8 +72,8 @@ class TestFloatExport(TestBasicExport):
def test_float(self):
converter = self.get_converter('float')
value = converter(42.0)
self.assertEqual(value, "42.0")
value = converter(-42.0)
self.assertEqual(value, u"\u201142.0")
value = converter(42.0100)
self.assertEqual(value, "42.01")
@@ -115,12 +115,12 @@ class TestCurrencyExport(TestExport):
def test_currency_post(self):
currency = self.create(self.Currency, name="Test", symbol=u"test")
obj = self.create(self.Model, value=0.12)
obj = self.create(self.Model, value=-0.12)
converted = self.convert(obj, dest=currency)
self.assertEqual(
converted, '<span class="oe_currency_value">0.12</span>'
converted, '<span class="oe_currency_value">\u20110.12</span>'
u'\N{NO-BREAK SPACE}{symbol}'.format(
obj=obj,
symbol=currency.symbol.encode('utf-8')
@@ -5,6 +5,9 @@ access_message,test_new_api_message,test_new_api.model_test_new_api_message,,1,1
access_emailmessage,test_new_api_emailmessage,test_new_api.model_test_new_api_emailmessage,,1,1,1,1
access_multi,test_new_api_multi,test_new_api.model_test_new_api_multi,,1,1,1,1
access_multi_line,test_new_api_multi_line,test_new_api.model_test_new_api_multi_line,,1,1,1,1
access_creativework_edition,test_new_api_creativework_edition,model_test_new_api_creativework_edition,,1,1,1,1
access_creativework_book,test_new_api_creativework_book,model_test_new_api_creativework_book,,1,1,1,1
access_creativework_movie,test_new_api_creativework_movie,model_test_new_api_creativework_movie,,1,1,1,1
access_mixed,test_new_api_mixed,test_new_api.model_test_new_api_mixed,,1,1,1,1
access_domain_bool,access_domain_bool,model_domain_bool,,1,1,1,1
access_test_new_api_foo,access_test_new_api_foo,model_test_new_api_foo,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_emailmessage test_new_api_emailmessage test_new_api.model_test_new_api_emailmessage 1 1 1 1
6 access_multi test_new_api_multi test_new_api.model_test_new_api_multi 1 1 1 1
7 access_multi_line test_new_api_multi_line test_new_api.model_test_new_api_multi_line 1 1 1 1
8 access_creativework_edition test_new_api_creativework_edition model_test_new_api_creativework_edition 1 1 1 1
9 access_creativework_book test_new_api_creativework_book model_test_new_api_creativework_book 1 1 1 1
10 access_creativework_movie test_new_api_creativework_movie model_test_new_api_creativework_movie 1 1 1 1
11 access_mixed test_new_api_mixed test_new_api.model_test_new_api_mixed 1 1 1 1
12 access_domain_bool access_domain_bool model_domain_bool 1 1 1 1
13 access_test_new_api_foo access_test_new_api_foo model_test_new_api_foo 1 1 1 1
+27
View File
@@ -199,6 +199,33 @@ class MultiLine(models.Model):
partner = fields.Many2one('res.partner')
class Edition(models.Model):
_name = 'test_new_api.creativework.edition'
name = fields.Char()
res_id = fields.Integer(required=True)
res_model_id = fields.Many2one('ir.model', required=True)
res_model = fields.Char(related='res_model_id.model', store=True)
class Book(models.Model):
_name = 'test_new_api.creativework.book'
name = fields.Char()
editions = fields.One2many(
'test_new_api.creativework.edition', 'res_id', domain=[('res_model', '=', _name)]
)
class Movie(models.Model):
_name = 'test_new_api.creativework.movie'
name = fields.Char()
editions = fields.One2many(
'test_new_api.creativework.edition', 'res_id', domain=[('res_model', '=', _name)]
)
class MixedModel(models.Model):
_name = 'test_new_api.mixed'
@@ -10,6 +10,36 @@ class One2manyCase(TransactionCase):
"name": "What is up?"
})
# data for One2many with inverse field Integer
self.Edition = self.env["test_new_api.creativework.edition"]
self.Book = self.env["test_new_api.creativework.book"]
self.Movie = self.env["test_new_api.creativework.movie"]
book_model_id = self.env['ir.model'].search([('model', '=', self.Book._name)]).id
movie_model_id = self.env['ir.model'].search([('model', '=', self.Movie._name)]).id
books_data = (
('Imaginary book', ()),
('Another imaginary book', ()),
('Nineteen Eighty Four', ('First edition', 'Fourth Edition'))
)
movies_data = (
('The Gold Rush', ('1925 (silent)', '1942')),
('Imaginary movie', ()),
('Another imaginary movie', ())
)
for name, editions in books_data:
book_id = self.Book.create({'name': name}).id
for edition in editions:
self.Edition.create({'res_model_id': book_model_id, 'name': edition, 'res_id': book_id})
for name, editions in movies_data:
movie_id = self.Movie.create({'name': name}).id
for edition in editions:
self.Edition.create({'res_model_id': movie_model_id, 'name': edition, 'res_id': movie_id})
def operations(self):
"""Run operations on o2m fields to check all works fine."""
# Check the lines first
@@ -63,3 +93,40 @@ class One2manyCase(TransactionCase):
"""Check lines created with RPC style and added in one step"""
self.multi.lines = [(0, 0, {'name': str(name)}) for name in range(10)]
self.operations()
def test_many2one_integer(self):
"""Test several models one2many with same inverse Integer field"""
# utility function to convert records to tuples with id,name
t = lambda records: records.mapped(lambda r: (r.id, r.name))
books = self.Book.search([])
books_with_edition = books.filtered(lambda r: r.editions)
movies = self.Movie.search([])
movies_without_edition = movies.filtered(lambda r: not r.editions)
movies_with_edition = movies.filtered(lambda r: r.editions)
movie_editions = movies_with_edition.mapped('editions')
one_movie_edition = movie_editions[0]
res_movies_without_edition = self.Movie.search([('editions', '=', False)])
self.assertItemsEqual(t(res_movies_without_edition), t(movies_without_edition))
res_movies_with_edition = self.Movie.search([('editions', '!=', False)])
self.assertItemsEqual(t(res_movies_with_edition), t(movies_with_edition))
res_books_with_movie_edition = self.Book.search([('editions', 'in', movie_editions.ids)])
self.assertFalse(t(res_books_with_movie_edition))
res_books_without_movie_edition = self.Book.search([('editions', 'not in', movie_editions.ids)])
self.assertItemsEqual(t(res_books_without_movie_edition), t(books_with_edition))
res_books_without_one_movie_edition = self.Book.search([('editions', 'not in', movie_editions[:1].ids)])
self.assertItemsEqual(t(res_books_without_one_movie_edition), t(books_with_edition))
res_books_with_one_movie_edition_name = self.Book.search([('editions', '=', movie_editions[:1].name)])
self.assertFalse(t(res_books_with_one_movie_edition_name))
res_books_without_one_movie_edition_name = self.Book.search([('editions', '!=', movie_editions[:1].name)])
self.assertItemsEqual(t(res_books_without_one_movie_edition_name), t(books_with_edition))
res_movies_not_of_edition_name = self.Movie.search([('editions', '!=', one_movie_edition.name)])
self.assertItemsEqual(t(res_movies_not_of_edition_name), t(movies.filtered(lambda r: one_movie_edition not in r.editions)))
+21 -11
View File
@@ -920,19 +920,23 @@ class expression(object):
elif field.type == 'one2many':
call_null = True
domain = field.domain
if callable(domain):
domain = domain(model)
is_integer_m2o = comodel._fields[field.inverse_name].type == 'integer'
if right is not False:
if isinstance(right, basestring):
op = {'!=': '=', 'not like': 'like', 'not ilike': 'ilike'}.get(operator, operator)
domain = field.domain
if callable(domain):
domain = domain(model)
ids2 = [x[0] for x in comodel.name_search(right, domain or [], op, limit=None)]
if ids2:
operator = 'not in' if operator in NEGATIVE_TERM_OPERATORS else 'in'
elif isinstance(right, collections.Iterable):
ids2 = right
else:
ids2 = [right]
if isinstance(right, collections.Iterable):
ids2 = right
else:
ids2 = [right]
if ids2 and is_integer_m2o and domain:
ids2 = comodel.search([('id', 'in', ids2)] + domain).ids
if not ids2:
if operator in ['like', 'ilike', 'in', '=']:
@@ -945,7 +949,9 @@ class expression(object):
ids1 = select_from_where(cr, field.inverse_name, comodel._table, 'id', ids2, operator)
else:
recs = comodel.browse(ids2).sudo().with_context(prefetch_fields=False)
ids1 = recs.mapped(field.inverse_name).ids
ids1 = recs.mapped(field.inverse_name)
if not is_integer_m2o:
ids1 = ids1.ids
if ids1:
call_null = False
o2m_op = 'not in' if operator in NEGATIVE_TERM_OPERATORS else 'in'
@@ -958,12 +964,16 @@ class expression(object):
if call_null:
o2m_op = 'in' if operator in NEGATIVE_TERM_OPERATORS else 'not in'
# determine ids from field.inverse_name
if comodel._fields[field.inverse_name].store:
if comodel._fields[field.inverse_name].store and not (is_integer_m2o and domain):
ids1 = select_distinct_from_where_not_null(cr, field.inverse_name, comodel._table)
else:
domain = [(field.inverse_name, '!=', False)]
recs = comodel.search(domain).sudo().with_context(prefetch_fields=False)
ids1 = recs.mapped(field.inverse_name).ids
comodel_domain = [(field.inverse_name, '!=', False)]
if is_integer_m2o and domain:
comodel_domain += domain
recs = comodel.search(comodel_domain).sudo().with_context(prefetch_fields=False)
ids1 = recs.mapped(field.inverse_name)
if not is_integer_m2o:
ids1 = ids1.ids
push(create_substitution_leaf(leaf, ('id', o2m_op, ids1), model))
elif field.type == 'many2many':