[MERGE] forward port branch saas-14 up to b2fa9f2ad1
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user