diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index 882172dd917..2b3b9a83e60 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -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: diff --git a/addons/account/models/res_config.py b/addons/account/models/res_config.py index 630dad79e00..49675a7af46 100644 --- a/addons/account/models/res_config.py +++ b/addons/account/models/res_config.py @@ -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) diff --git a/addons/base_setup/models/res_config.py b/addons/base_setup/models/res_config.py index 971694940dd..ceeb7c3bdbf 100644 --- a/addons/base_setup/models/res_config.py +++ b/addons/base_setup/models/res_config.py @@ -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 { diff --git a/addons/crm/models/crm_lead.py b/addons/crm/models/crm_lead.py index 5dd40ea80c3..50b20b94fd8 100644 --- a/addons/crm/models/crm_lead.py +++ b/addons/crm/models/crm_lead.py @@ -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': diff --git a/addons/crm/models/res_config.py b/addons/crm/models/res_config.py index 4d22ef0ebd8..a786f1e2c0b 100644 --- a/addons/crm/models/res_config.py +++ b/addons/crm/models/res_config.py @@ -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)") diff --git a/addons/crm/report/crm_opportunity_report_views.xml b/addons/crm/report/crm_opportunity_report_views.xml index 67a700867ec..b8b934f7330 100644 --- a/addons/crm/report/crm_opportunity_report_views.xml +++ b/addons/crm/report/crm_opportunity_report_views.xml @@ -8,7 +8,7 @@ - + @@ -31,7 +31,7 @@ crm.opportunity.report - + diff --git a/addons/mail/models/mail_mail.py b/addons/mail/models/mail_mail.py index 20f1a8de7ce..6fcae69b21b 100644 --- a/addons/mail/models/mail_mail.py +++ b/addons/mail/models/mail_mail.py @@ -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, diff --git a/addons/mass_mailing/static/src/css/email_template.css b/addons/mass_mailing/static/src/css/email_template.css index f0b25ba612d..edd9c0c3f0d 100644 --- a/addons/mass_mailing/static/src/css/email_template.css +++ b/addons/mass_mailing/static/src/css/email_template.css @@ -9,6 +9,7 @@ } .kanban_html_preview { + pointer-events: none; width: 600px; -webkit-transform: scale(.50); -ms-transform: scale(.50); diff --git a/addons/mrp/i18n/mrp.pot b/addons/mrp/i18n/mrp.pot index 7ed0640b829..be8d2e5b1f0 100644 --- a/addons/mrp/i18n/mrp.pot +++ b/addons/mrp/i18n/mrp.pot @@ -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 diff --git a/addons/mrp/models/stock_move.py b/addons/mrp/models/stock_move.py index c9c0bce8ed5..a9066344b53 100644 --- a/addons/mrp/models/stock_move.py +++ b/addons/mrp/models/stock_move.py @@ -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': diff --git a/addons/mrp/wizard/mrp_product_produce.py b/addons/mrp/wizard/mrp_product_produce.py index f6a70c4a1f6..b7cd863a5fc 100644 --- a/addons/mrp/wizard/mrp_product_produce.py +++ b/addons/mrp/wizard/mrp_product_produce.py @@ -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}) diff --git a/addons/mrp_repair/models/mrp_repair.py b/addons/mrp_repair/models/mrp_repair.py index cdbca9811a3..c23e810fd1f 100644 --- a/addons/mrp_repair/models/mrp_repair.py +++ b/addons/mrp_repair/models/mrp_repair.py @@ -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') diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index 5ffc101b6cc..42e6228a3df 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -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']): diff --git a/addons/sale/models/sale_config_settings.py b/addons/sale/models/sale_config_settings.py index 1c220073834..797bc4fc6af 100644 --- a/addons/sale/models/sale_config_settings.py +++ b/addons/sale/models/sale_config_settings.py @@ -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') diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py index 3e0f24ab275..d39872a8a5a 100644 --- a/addons/sale_stock/models/sale_order.py +++ b/addons/sale_stock/models/sale_order.py @@ -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 diff --git a/addons/stock/models/stock_inventory.py b/addons/stock/models/stock_inventory.py index 3acf26ad8a9..5c5822a83d7 100644 --- a/addons/stock/models/stock_inventory.py +++ b/addons/stock/models/stock_inventory.py @@ -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: diff --git a/addons/stock/models/stock_picking.py b/addons/stock/models/stock_picking.py index 827c920a0e9..ba57a0352f7 100644 --- a/addons/stock/models/stock_picking.py +++ b/addons/stock/models/stock_picking.py @@ -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', diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 013bccd1050..33362f927e1 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -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): diff --git a/addons/stock/tests/test_warehouse.py b/addons/stock/tests/test_warehouse.py index 6e71ec74276..ef18411a79e 100644 --- a/addons/stock/tests/test_warehouse.py +++ b/addons/stock/tests/test_warehouse.py @@ -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): diff --git a/addons/web/static/src/less/form_view.less b/addons/web/static/src/less/form_view.less index a8422938b2e..7a804d311cc 100644 --- a/addons/web/static/src/less/form_view.less +++ b/addons/web/static/src/less/form_view.less @@ -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 } } diff --git a/addons/web_calendar/static/src/js/web_calendar.js b/addons/web_calendar/static/src/js/web_calendar.js index cf44f7283e6..7aed6e10860 100644 --- a/addons/web_calendar/static/src/js/web_calendar.js +++ b/addons/web_calendar/static/src/js/web_calendar.js @@ -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, diff --git a/addons/web_editor/static/src/js/summernote.js b/addons/web_editor/static/src/js/summernote.js index 9d7304caf75..71647828cc6 100644 --- a/addons/web_editor/static/src/js/summernote.js +++ b/addons/web_editor/static/src/js/summernote.js @@ -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); + } }); }; diff --git a/addons/web_editor/static/src/js/transcoder.js b/addons/web_editor/static/src/js/transcoder.js index 20d4f5844af..fa7b4023377 100644 --- a/addons/web_editor/static/src/js/transcoder.js +++ b/addons/web_editor/static/src/js/transcoder.js @@ -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; }; diff --git a/addons/web_editor/static/src/xml/backend.xml b/addons/web_editor/static/src/xml/backend.xml index 4035752cea5..5c85996e890 100644 --- a/addons/web_editor/static/src/xml/backend.xml +++ b/addons/web_editor/static/src/xml/backend.xml @@ -38,11 +38,11 @@ -webkit-border-radius: 3px; border-radius: 3px; background: white;"> + - diff --git a/addons/web_kanban/static/src/js/kanban_view.js b/addons/web_kanban/static/src/js/kanban_view.js index 1df6c56e139..8c382caacf4 100644 --- a/addons/web_kanban/static/src/js/kanban_view.js +++ b/addons/web_kanban/static/src/js/kanban_view.js @@ -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 diff --git a/addons/web_kanban/static/src/less/kanban_view.less b/addons/web_kanban/static/src/less/kanban_view.less index bc4166c7df4..1259fd355cc 100644 --- a/addons/web_kanban/static/src/less/kanban_view.less +++ b/addons/web_kanban/static/src/less/kanban_view.less @@ -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; diff --git a/addons/website_form/controllers/main.py b/addons/website_form/controllers/main.py index 2f5f7ec2292..4ded7c4ddb7 100644 --- a/addons/website_form/controllers/main.py +++ b/addons/website_form/controllers/main.py @@ -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']) diff --git a/odoo/addons/base/ir/ir_model.py b/odoo/addons/base/ir/ir_model.py index a27c813d517..9cd07f64386 100644 --- a/odoo/addons/base/ir/ir_model.py +++ b/odoo/addons/base/ir/ir_model.py @@ -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() diff --git a/odoo/addons/base/ir/ir_qweb/fields.py b/odoo/addons/base/ir/ir_qweb/fields.py index 430fa48fa0a..c1d525c83b3 100644 --- a/odoo/addons/base/ir/ir_qweb/fields.py +++ b/odoo/addons/base/ir/ir_qweb/fields.py @@ -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': diff --git a/odoo/addons/base/ir/ir_values.py b/odoo/addons/base/ir/ir_values.py index 3d1ac0869ba..866cb8a8504 100644 --- a/odoo/addons/base/ir/ir_values.py +++ b/odoo/addons/base/ir/ir_values.py @@ -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'), diff --git a/odoo/addons/base/res/res_currency.py b/odoo/addons/base/res/res_currency.py index 5c91e55b3f0..aef7bb97866 100644 --- a/odoo/addons/base/res/res_currency.py +++ b/odoo/addons/base/res/res_currency.py @@ -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): diff --git a/odoo/addons/base/res/res_lang.py b/odoo/addons/base/res/res_lang.py index c43baf0ad49..e19d5175f90 100644 --- a/odoo/addons/base/res/res_lang.py +++ b/odoo/addons/base/res/res_lang.py @@ -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): diff --git a/odoo/addons/base/tests/test_ir_values.py b/odoo/addons/base/tests/test_ir_values.py index 50fba76615b..b8aa0cdb331 100644 --- a/odoo/addons/base/tests/test_ir_values.py +++ b/odoo/addons/base/tests/test_ir_values.py @@ -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') diff --git a/odoo/addons/test_converter/tests/test_html.py b/odoo/addons/test_converter/tests/test_html.py index d084e0c4055..85d2c511fcd 100644 --- a/odoo/addons/test_converter/tests/test_html.py +++ b/odoo/addons/test_converter/tests/test_html.py @@ -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, '0.12' + converted, '\u20110.12' u'\N{NO-BREAK SPACE}{symbol}'.format( obj=obj, symbol=currency.symbol.encode('utf-8') diff --git a/odoo/addons/test_new_api/ir.model.access.csv b/odoo/addons/test_new_api/ir.model.access.csv index ee467a56828..9c35118a5a1 100644 --- a/odoo/addons/test_new_api/ir.model.access.csv +++ b/odoo/addons/test_new_api/ir.model.access.csv @@ -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 diff --git a/odoo/addons/test_new_api/models.py b/odoo/addons/test_new_api/models.py index b4f14abef50..1d3d5ab404b 100644 --- a/odoo/addons/test_new_api/models.py +++ b/odoo/addons/test_new_api/models.py @@ -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' diff --git a/odoo/addons/test_new_api/tests/test_one2many.py b/odoo/addons/test_new_api/tests/test_one2many.py index 38dee13ab54..9ade2c0fe19 100644 --- a/odoo/addons/test_new_api/tests/test_one2many.py +++ b/odoo/addons/test_new_api/tests/test_one2many.py @@ -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))) diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py index da093dad323..eea14988eb7 100644 --- a/odoo/osv/expression.py +++ b/odoo/osv/expression.py @@ -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':