diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index fadd4a6028c..feaba47626b 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -483,9 +483,9 @@ class AccountInvoice(models.Model):
else:
account_id = pay_account.id
payment_term_id = p.property_supplier_payment_term_id.id
- addr = self.partner_id.address_get(['delivery'])
- fiscal_position = self.env['account.fiscal.position'].get_fiscal_position(self.partner_id.id, delivery_id=addr['delivery'])
+ delivery_partner_id = self.get_delivery_partner_id()
+ fiscal_position = self.env['account.fiscal.position'].get_fiscal_position(self.partner_id.id, delivery_id=delivery_partner_id)
# If partner has no warning, check its company
if p.invoice_warn == 'no-message' and p.parent_id:
@@ -513,6 +513,10 @@ class AccountInvoice(models.Model):
return {'domain': {'partner_bank_id': [('id', 'in', bank_ids.ids)]}}
return {}
+ @api.multi
+ def get_delivery_partner_id(self):
+ self.ensure_one()
+ return self.partner_id.address_get(['delivery'])['delivery']
@api.onchange('journal_id')
def _onchange_journal_id(self):
@@ -1030,7 +1034,7 @@ class AccountInvoice(models.Model):
values['invoice_line_ids'] = self._refund_cleanup_lines(invoice.invoice_line_ids)
- tax_lines = filter(lambda l: l.manual, invoice.tax_line_ids)
+ tax_lines = invoice.tax_line_ids
values['tax_line_ids'] = self._refund_cleanup_lines(tax_lines)
if journal_id:
diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py
index e0df2075a29..62a760aa017 100644
--- a/addons/account/models/account_journal_dashboard.py
+++ b/addons/account/models/account_journal_dashboard.py
@@ -304,11 +304,13 @@ class account_journal(models.Model):
'default_type': invoice_type,
'type': invoice_type
})
+
[action] = self.env.ref('account.%s' % action_name).read()
action['context'] = ctx
action['domain'] = self._context.get('use_domain', [])
- action['views'] = False
- action['view_id'] = False
+ if action_name in ['action_bank_statement_tree', 'action_view_bank_statement_tree']:
+ action['views'] = False
+ action['view_id'] = False
return action
@api.multi
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index d922e3b7410..c7bf678a2d8 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -8,6 +8,7 @@ from odoo.exceptions import RedirectWarning, UserError, ValidationError
from odoo.tools.misc import formatLang
from odoo.tools import float_is_zero, float_compare
from odoo.tools.safe_eval import safe_eval
+import odoo.addons.decimal_precision as dp
from lxml import etree
#----------------------------------------------------------
@@ -370,7 +371,7 @@ class AccountMoveLine(models.Model):
self.counterpart = ",".join(counterpart)
name = fields.Char(required=True, string="Label")
- quantity = fields.Float(digits=(16, 2),
+ quantity = fields.Float(digits=dp.get_precision('Product Unit of Measure'),
help="The optional quantity expressed by this line, eg: number of product sold. The quantity is not a legal requirement but is very useful for some reports.")
product_uom_id = fields.Many2one('product.uom', string='Unit of Measure')
product_id = fields.Many2one('product.product', string='Product')
@@ -763,7 +764,7 @@ class AccountMoveLine(models.Model):
"""
for datum in data:
if len(datum['mv_line_ids']) >= 1 or len(datum['mv_line_ids']) + len(datum['new_mv_line_dicts']) >= 2:
- self.process_reconciliation(datum['mv_line_ids'], datum['new_mv_line_dicts'])
+ self.env['account.move.line'].browse(datum['mv_line_ids']).process_reconciliation(datum['new_mv_line_dicts'])
if datum['type'] == 'partner':
partners = self.env['res.partner'].browse(datum['id'])
@@ -1426,9 +1427,9 @@ class AccountPartialReconcile(models.Model):
for rec in self:
if not rec.company_id.currency_exchange_journal_id:
raise UserError(_("You should configure the 'Exchange Rate Journal' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates."))
- if not self.company_id.income_currency_exchange_account_id.id:
+ if not rec.company_id.income_currency_exchange_account_id.id:
raise UserError(_("You should configure the 'Gain Exchange Rate Account' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates."))
- if not self.company_id.expense_currency_exchange_account_id.id:
+ if not rec.company_id.expense_currency_exchange_account_id.id:
raise UserError(_("You should configure the 'Loss Exchange Rate Account' in the accounting settings, to manage automatically the booking of accounting entries related to differences between exchange rates."))
move_vals = {'journal_id': rec.company_id.currency_exchange_journal_id.id}
@@ -1488,6 +1489,8 @@ class AccountPartialReconcile(models.Model):
#amount is the current cash_basis amount minus the one before the reconciliation
amount = line.balance * percentage_after - line.balance * percentage_before
rounded_amt = line.company_id.currency_id.round(amount)
+ if float_is_zero(rounded_amt, precision_rounding=line.company_id.currency_id.rounding):
+ continue
if line.tax_line_id and line.tax_line_id.use_cash_basis:
if not newly_created_move:
newly_created_move = self._create_tax_basis_move()
diff --git a/addons/account/report/account_partner_ledger.py b/addons/account/report/account_partner_ledger.py
index c01d22cac04..ba2503d41a9 100644
--- a/addons/account/report/account_partner_ledger.py
+++ b/addons/account/report/account_partner_ledger.py
@@ -11,6 +11,7 @@ class ReportPartnerLedger(models.AbstractModel):
def _lines(self, data, partner):
full_account = []
+ currency = self.env['res.currency']
query_get_data = self.env['account.move.line'].with_context(data['form'].get('used_context', {}))._query_get()
reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".reconciled = false '
params = [partner.id, tuple(data['computed']['move_state']), tuple(data['computed']['account_ids'])] + query_get_data[2]
@@ -40,6 +41,7 @@ class ReportPartnerLedger(models.AbstractModel):
)
sum += r['debit'] - r['credit']
r['progress'] = sum
+ r['currency_id'] = currency.browse(r.get('currency_id'))
full_account.append(r)
return full_account
diff --git a/addons/account/static/src/xml/account_reconciliation.xml b/addons/account/static/src/xml/account_reconciliation.xml
index f6b11825a1e..fafcb833205 100644
--- a/addons/account/static/src/xml/account_reconciliation.xml
+++ b/addons/account/static/src/xml/account_reconciliation.xml
@@ -301,10 +301,10 @@
diff --git a/addons/stock/tests/test_stock_flow.py b/addons/stock/tests/test_stock_flow.py
index 3ee20467a88..32f77c06267 100644
--- a/addons/stock/tests/test_stock_flow.py
+++ b/addons/stock/tests/test_stock_flow.py
@@ -1726,4 +1726,69 @@ class TestStockFlow(TestStockCommon):
# force assign on the delivery order, it should be assigned
picking_out.force_assign()
- self.assertEquals(picking_out.state, "assigned")
\ No newline at end of file
+ self.assertEquals(picking_out.state, "assigned")
+
+ def test_74_move_state_waiting_mto(self):
+ """ This test will check that when a move is unreserved, it state change to 'waiting' if
+ it has ancestors or is has a 'procure_method' equal to 'make_to_order' else the state
+ changes to 'confirmed'.
+ """
+ picking_out = self.PickingObj.create({
+ 'partner_id': self.partner_agrolite_id,
+ 'picking_type_id': self.picking_type_out,
+ 'location_id': self.stock_location,
+ 'location_dest_id': self.customer_location})
+ move_mto_alone = self.MoveObj.create({
+ 'name': self.productA.name,
+ 'product_id': self.productA.id,
+ 'product_uom_qty': 2,
+ 'product_uom': self.productA.uom_id.id,
+ 'picking_id': picking_out.id,
+ 'location_id': self.stock_location,
+ 'location_dest_id': self.customer_location,
+ 'procure_method':'make_to_order'})
+ move_with_ancestors = self.MoveObj.create({
+ 'name': self.productA.name,
+ 'product_id': self.productA.id,
+ 'product_uom_qty': 2,
+ 'product_uom': self.productA.uom_id.id,
+ 'picking_id': picking_out.id,
+ 'location_id': self.stock_location,
+ 'location_dest_id': self.customer_location})
+ the_ancestor = self.MoveObj.create({
+ 'name': self.productA.name,
+ 'product_id': self.productA.id,
+ 'product_uom_qty': 2,
+ 'product_uom': self.productA.uom_id.id,
+ 'picking_id': picking_out.id,
+ 'location_id': self.stock_location,
+ 'location_dest_id': self.customer_location,
+ 'move_dest_id': move_with_ancestors.id})
+ other_move = self.MoveObj.create({
+ 'name': self.productA.name,
+ 'product_id': self.productA.id,
+ 'product_uom_qty': 2,
+ 'product_uom': self.productA.uom_id.id,
+ 'picking_id': picking_out.id,
+ 'location_id': self.stock_location,
+ 'location_dest_id': self.customer_location})
+
+ move_mto_alone.action_confirm()
+ move_with_ancestors.action_confirm()
+ other_move.action_confirm()
+
+ move_mto_alone.do_unreserve()
+ move_with_ancestors.do_unreserve()
+ other_move.do_unreserve()
+
+ self.assertEquals(move_mto_alone.state, "waiting")
+ self.assertEquals(move_with_ancestors.state, "waiting")
+ self.assertEquals(other_move.state, "confirmed")
+
+ move_mto_alone.recalculate_move_state()
+ move_with_ancestors.recalculate_move_state()
+ other_move.recalculate_move_state()
+
+ self.assertEquals(move_mto_alone.state, "waiting")
+ self.assertEquals(move_with_ancestors.state, "waiting")
+ self.assertEquals(other_move.state, "confirmed")
diff --git a/addons/stock/views/stock_warehouse_views.xml b/addons/stock/views/stock_warehouse_views.xml
index 62d1e3d748c..7a8caea9e5a 100644
--- a/addons/stock/views/stock_warehouse_views.xml
+++ b/addons/stock/views/stock_warehouse_views.xml
@@ -98,6 +98,7 @@
stock.warehouse
+
diff --git a/addons/stock_account/models/stock.py b/addons/stock_account/models/stock.py
index 4706bbc6b45..9fec58c20ab 100644
--- a/addons/stock_account/models/stock.py
+++ b/addons/stock_account/models/stock.py
@@ -188,7 +188,7 @@ class StockMove(models.Model):
tmpl_dict = defaultdict(lambda: 0.0)
# adapt standard price on incomming moves if the product cost_method is 'average'
std_price_update = {}
- for move in self.filtered(lambda move: move.location_id.usage == 'supplier' and move.product_id.cost_method == 'average'):
+ for move in self.filtered(lambda move: move.location_id.usage in ('supplier', 'production') and move.product_id.cost_method == 'average'):
product_tot_qty_available = move.product_id.qty_available + tmpl_dict[move.product_id.id]
# if the incoming move is for a purchase order with foreign currency, need to call this to get the same value that the quant will use.
diff --git a/addons/stock_landed_costs/models/stock_landed_cost.py b/addons/stock_landed_costs/models/stock_landed_cost.py
index 2427a8ca0ca..12134cc0f0f 100644
--- a/addons/stock_landed_costs/models/stock_landed_cost.py
+++ b/addons/stock_landed_costs/models/stock_landed_cost.py
@@ -129,6 +129,7 @@ class LandedCost(models.Model):
if quant.location_id.usage != 'internal':
qty_out += quant.qty
line._create_accounting_entries(move, qty_out)
+ move.assert_balanced()
cost.write({'state': 'done', 'account_move_id': move.id})
move.post()
return True
@@ -326,7 +327,7 @@ class AdjustmentLines(models.Model):
Generate the account.move.line values to track the landed cost.
Afterwards, for the goods that are already out of stock, we should create the out moves
"""
- AccountMoveLine = self.env['account.move.line'].with_context(check_move_validity=False)
+ AccountMoveLine = self.env['account.move.line'].with_context(check_move_validity=False, recompute=False)
base_line = {
'name': self.name,
@@ -389,5 +390,4 @@ class AdjustmentLines(models.Model):
AccountMoveLine.create(debit_line)
AccountMoveLine.create(credit_line)
- move.assert_balanced()
return True
diff --git a/addons/web/controllers/main.py b/addons/web/controllers/main.py
index cf6c8be5302..ed3e34762e5 100644
--- a/addons/web/controllers/main.py
+++ b/addons/web/controllers/main.py
@@ -651,6 +651,8 @@ class Database(http.Controller):
@http.route('/web/database/create', type='http', auth="none", methods=['POST'], csrf=False)
def create(self, master_pwd, name, lang, password, **post):
try:
+ if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', name):
+ raise Exception(_('Invalid database name. Only alphanumerical characters and underscore are allowed.'))
# country code could be = "False" which is actually True in python
country_code = post.get('country_code') or False
dispatch_rpc('db', 'create_database', [master_pwd, name, bool(post.get('demo')), lang, password, post['login'], country_code])
@@ -663,6 +665,8 @@ class Database(http.Controller):
@http.route('/web/database/duplicate', type='http', auth="none", methods=['POST'], csrf=False)
def duplicate(self, master_pwd, name, new_name):
try:
+ if not re.match('^[a-zA-Z][a-zA-Z0-9_]+$', new_name):
+ raise Exception(_('Invalid database name. Only alphanumerical characters and underscore are allowed.'))
dispatch_rpc('db', 'duplicate_database', [master_pwd, name, new_name])
return http.local_redirect('/web/database/manager')
except Exception, e:
diff --git a/addons/web/static/src/js/widgets/data_export.js b/addons/web/static/src/js/widgets/data_export.js
index d3135f86ba6..c74b89af11b 100644
--- a/addons/web/static/src/js/widgets/data_export.js
+++ b/addons/web/static/src/js/widgets/data_export.js
@@ -207,9 +207,15 @@ var DataExport = Dialog.extend({
this.records = {};
this.dataset = dataset;
this.exports = new data.DataSetSearch(this, 'ir.exports', this.dataset.get_context());
-
+
this.row_index = 0;
this.row_index_level = 0;
+
+ // The default for the ".modal_content" element is "max-height: 100%;"
+ // but we want it to always expand to "height: 100%;" for this modal.
+ // This can be achieved thanks to LESS modification without touching
+ // the ".modal-content" rules... but not with Internet explorer (11).
+ this.$modal.find(".modal-content").css("height", "100%");
},
start: function() {
var self = this;
@@ -261,7 +267,7 @@ var DataExport = Dialog.extend({
function do_setup_export_formats(formats) {
var $fmts = self.$('.o_export_format');
-
+
_.each(formats, function(format, i) {
var $radio = $('', {type: 'radio', value: format.tag, name: 'o_export_format_name'});
var $label = $('', {html: format.label});
@@ -362,7 +368,7 @@ var DataExport = Dialog.extend({
},
on_show_data: function(records, expansion) {
var self = this;
-
+
if(expansion) {
this.$('.o_export_tree_item[data-id="' + expansion + '"]')
.addClass('open')
diff --git a/addons/web/static/src/less/data_export.less b/addons/web/static/src/less/data_export.less
index 3f596bea27d..01a1da80134 100644
--- a/addons/web/static/src/less/data_export.less
+++ b/addons/web/static/src/less/data_export.less
@@ -1,8 +1,8 @@
.o_web_client .o_export {
-
- .o-flex(0, 1, 1000px);
+ .o-flex(1, 1, auto);
.o-flex-display();
.o-flex-flow(column, nowrap);
+ min-height: 0;
> p, > .row {
.o-flex(0, 0, auto);
@@ -28,8 +28,9 @@
}
.o_export_panel {
- .o-flex(1, 0, 0);
+ .o-flex(1, 1, auto);
position: relative;
+ min-height: 0;
> div {
height: 100%;
@@ -59,9 +60,10 @@
.o_left_field_panel, .o_fields_list {
border: 1px solid @odoo-brand-lightsecondary;
overflow: auto;
- .o-flex(1, 0, 0);
+ .o-flex(1, 1, auto);
+ min-height: 0;
}
-
+
.o_export_tree_item {
cursor: pointer;
position: relative;
@@ -72,7 +74,7 @@
background-color: @odoo-brand-primary;
color: white;
}
-
+
.o_expand_parent {
.o-position-absolute(@top: 4px, @left: 5px);
}
diff --git a/addons/web/views/database_manager.html b/addons/web/views/database_manager.html
index f0b3c570c2c..629d2a622ec 100644
--- a/addons/web/views/database_manager.html
+++ b/addons/web/views/database_manager.html
@@ -60,7 +60,7 @@
-
+
@@ -207,7 +207,7 @@
-
+
@@ -280,7 +280,7 @@
-
+
- -u web --stop-after-init
+ $ python /usr/bin/odoo.py -d -i web_enterprise --stop-after-init
* You should be able to connect to your Odoo Enterprise instance using your usual mean of identification.
You can then link your database with your Odoo Enterprise Subscription by entering the code you received
@@ -61,11 +61,11 @@ have your own favourite. You may need to adapt sections to your usual workflow.
* Shutdown your server
* Backup your community database
* Update the ``--addons-path`` parameter of your launch command (see :ref:`setup/install/source`)
-* Update the web module by using
+* Install the web_enterprise module by using
.. code-block:: console
- $ -d -u web --stop-after-init
+ $ -d -i web_enterprise --stop-after-init
Depending on the size of your database, this may take some time.
@@ -97,7 +97,7 @@ On Windows
.. code-block:: console
- $ odoo.exe -d -u web --stop-after-init
+ $ odoo.exe -d -i web_enterprise --stop-after-init
* No need to manually launch the server, the service is running.
You should be able to connect to your Odoo Enterprise instance using your usual
diff --git a/odoo/addons/base/ir/ir_filters.xml b/odoo/addons/base/ir/ir_filters.xml
index d21c6ad25d5..f46ec82c7f6 100644
--- a/odoo/addons/base/ir/ir_filters.xml
+++ b/odoo/addons/base/ir/ir_filters.xml
@@ -24,7 +24,7 @@
ir.filters.own.rule.delete
- [('create_uid', '=', user.id)]
+ [('user_id', '=', user.id)]
diff --git a/odoo/addons/base/ir/ir_model.py b/odoo/addons/base/ir/ir_model.py
index 98410ba110b..f67d9c1dc5f 100644
--- a/odoo/addons/base/ir/ir_model.py
+++ b/odoo/addons/base/ir/ir_model.py
@@ -9,6 +9,7 @@ from collections import defaultdict
from odoo import api, fields, models, SUPERUSER_ID, tools, _
from odoo.exceptions import AccessError, UserError, ValidationError
from odoo.modules.registry import Registry
+from odoo.osv import expression
from odoo.tools.safe_eval import safe_eval
_logger = logging.getLogger(__name__)
@@ -292,6 +293,25 @@ class IrModelFields(models.Model):
raise UserError(_("The Selection Options expression is not a valid Pythonic expression."
"Please provide an expression in the [('key','Label'), ...] format."))
+ @api.constrains('name', 'state')
+ def _check_name(self):
+ for field in self:
+ if field.state == 'manual' and not field.name.startswith('x_'):
+ raise ValidationError(_("Custom fields must have a name that starts with 'x_' !"))
+ try:
+ models.check_pg_name(field.name)
+ except ValidationError:
+ msg = _("Field names can only contain characters, digits and underscores (up to 63).")
+ raise ValidationError(msg)
+
+ @api.constrains('model', 'name')
+ def _unique_name(self):
+ # fix on stable branch (to be converted into an SQL constraint)
+ for field in self:
+ count = self.search_count([('model', '=', field.model), ('name', '=', field.name)])
+ if count > 1:
+ raise ValidationError(_("Field names must be unique per model."))
+
_sql_constraints = [
('size_gt_zero', 'CHECK (size>=0)', 'Size of the field cannot be negative.'),
]
@@ -449,6 +469,10 @@ class IrModelFields(models.Model):
This method prevents the modification/deletion of many2one fields
that have an inverse one2many, for instance.
"""
+ self = self.filtered(lambda record: record.state == 'manual')
+ if not self:
+ return
+
for record in self:
model = self.env[record.model]
field = model._fields[record.name]
@@ -462,6 +486,23 @@ class IrModelFields(models.Model):
msg = _("The field '%s' cannot be removed because the field '%s' depends on it.")
raise UserError(msg % (field, model._field_inverses[field][0]))
+ # remove fields from registry, and check that views are not broken
+ fields = [self.env[record.model]._pop_field(record.name) for record in self]
+ domain = expression.OR([('arch_db', 'like', record.name)] for record in self)
+ views = self.env['ir.ui.view'].search(domain)
+ try:
+ for view in views:
+ view._check_xml()
+ except Exception:
+ raise UserError("\n".join([
+ _("Cannot rename/delete fields that are still present in views:"),
+ _("Fields:") + " " + ", ".join(map(str, fields)),
+ _("View:") + " " + view.name,
+ ]))
+ finally:
+ # the registry has been modified, restore it
+ self.pool.setup_models(self._cr)
+
@api.multi
def unlink(self):
if not self:
@@ -504,9 +545,6 @@ class IrModelFields(models.Model):
res = super(IrModelFields, self).create(vals)
if vals.get('state', 'manual') == 'manual':
- if not vals['name'].startswith('x_'):
- raise UserError(_("Custom fields must have a name that starts with 'x_' !"))
-
if vals.get('relation') and not self.env['ir.model'].search([('model', '=', vals['relation'])]):
raise UserError(_("Model %s does not exist!") % vals['relation'])
@@ -569,12 +607,6 @@ class IrModelFields(models.Model):
item._prepare_update()
if column_rename:
raise UserError(_('Can only rename one field at a time!'))
- if vals['name'] in obj._fields:
- raise UserError(_('Cannot rename field to %s, because that field already exists!') % vals['name'])
- if vals.get('state', 'manual') == 'manual' and not vals['name'].startswith('x_'):
- raise UserError(_('New field name must still start with x_ , because it is a custom field!'))
- if '\'' in vals['name'] or '"' in vals['name'] or ';' in vals['name']:
- raise ValueError('Invalid character in column name')
column_rename = (obj._table, item.name, vals['name'], item.index)
# We don't check the 'state', because it might come from the context
diff --git a/odoo/addons/base/ir/ir_qweb/fields.py b/odoo/addons/base/ir/ir_qweb/fields.py
index f0c7d576672..4beec41db7c 100644
--- a/odoo/addons/base/ir/ir_qweb/fields.py
+++ b/odoo/addons/base/ir/ir_qweb/fields.py
@@ -340,7 +340,7 @@ class MonetaryConverter(models.AbstractModel):
if not options.get('display_currency') and field.type == 'monetary' and field.currency_field:
options['display_currency'] = record[field.currency_field]
- return self.value_to_html(record[field_name], options)
+ return super(MonetaryConverter, self).record_to_html(record, field_name, options)
TIMEDELTA_UNITS = (
('year', 3600 * 24 * 365),
diff --git a/odoo/addons/base/res/res_company_view.xml b/odoo/addons/base/res/res_company_view.xml
index 95bafe4f334..4746deeb1b1 100644
--- a/odoo/addons/base/res/res_company_view.xml
+++ b/odoo/addons/base/res/res_company_view.xml
@@ -1,7 +1,7 @@
-
+ res.company.form
diff --git a/odoo/addons/base/res/res_users.py b/odoo/addons/base/res/res_users.py
index e92aaf5315e..da025eeca78 100644
--- a/odoo/addons/base/res/res_users.py
+++ b/odoo/addons/base/res/res_users.py
@@ -744,7 +744,7 @@ class GroupsView(models.Model):
xml = E.field(E.group(*(xml1), col="2"), E.group(*(xml2), col="4"), name="groups_id", position="replace")
xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS"))
xml_content = etree.tostring(xml, pretty_print=True, xml_declaration=True, encoding="utf-8")
- view.with_context(lang=None).write({'arch': xml_content})
+ view.with_context(lang=None).write({'arch': xml_content, 'arch_fs': False})
def get_application_groups(self, domain):
""" Return the non-share groups that satisfy ``domain``. """
diff --git a/odoo/addons/base/tests/test_expression.py b/odoo/addons/base/tests/test_expression.py
index 932268d717c..104c4a6dfe1 100644
--- a/odoo/addons/base/tests/test_expression.py
+++ b/odoo/addons/base/tests/test_expression.py
@@ -683,8 +683,8 @@ class TestAutoJoin(TransactionCase):
self.assertEqual(partners, p_aa,
"_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..'): incorrect result")
# Test produced queries
- self.assertEqual(len(self.query_list), 3,
- "_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') should produce 3 queries (1 in res_partner_bank, 2 on res_partner)")
+ self.assertEqual(len(self.query_list), 2,
+ "_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') should produce 2 queries (1 in res_partner_bank, 1 on res_partner)")
sql_query = self.query_list[0].get_sql()
self.assertIn('res_partner_bank', sql_query[0],
"_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') first query incorrect main table")
@@ -692,16 +692,16 @@ class TestAutoJoin(TransactionCase):
expected = "%s::text like %s" % (unaccent('"res_partner_bank"."sanitized_acc_number"'), unaccent('%s'))
self.assertIn(expected, sql_query[1],
"_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') first query incorrect where condition")
-
+
self.assertEqual(['%' + name_test + '%'], sql_query[2],
"_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') first query incorrect parameter")
- sql_query = self.query_list[2].get_sql()
+ sql_query = self.query_list[1].get_sql()
self.assertIn('res_partner', sql_query[0],
- "_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') third query incorrect main table")
+ "_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') second query incorrect main table")
self.assertIn('"res_partner"."id" in (%s)', sql_query[1],
- "_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') third query incorrect where condition")
+ "_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') second query incorrect where condition")
self.assertIn(p_aa.id, sql_query[2],
- "_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') third query incorrect parameter")
+ "_auto_join off: ('bank_ids.sanitized_acc_number', 'like', '..') second query incorrect parameter")
# Do: cascaded one2many without _auto_join
self._reinit_mock()
@@ -710,8 +710,8 @@ class TestAutoJoin(TransactionCase):
self.assertEqual(partners, p_a + p_b,
"_auto_join off: ('child_ids.bank_ids.id', 'in', [..]): incorrect result")
# Test produced queries
- self.assertEqual(len(self.query_list), 5,
- "_auto_join off: ('child_ids.bank_ids.id', 'in', [..]) should produce 5 queries (1 in res_partner_bank, 4 on res_partner)")
+ self.assertEqual(len(self.query_list), 3,
+ "_auto_join off: ('child_ids.bank_ids.id', 'in', [..]) should produce 3 queries (1 in res_partner_bank, 2 on res_partner)")
# Do: one2many with _auto_join
patch_auto_join(partner_obj, 'bank_ids', True)
@@ -943,7 +943,7 @@ class TestAutoJoin(TransactionCase):
self.assertLessEqual(p_a + p_b, partners,
"_auto_join off: ('child_ids.state_id.country_id.code', 'like', '..') incorrect result")
# Test produced queries
- self.assertEqual(len(self.query_list), 5,
+ self.assertEqual(len(self.query_list), 4,
"_auto_join off: ('child_ids.state_id.country_id.code', 'like', '..') number of queries incorrect")
# Do: ('child_ids.state_id.country_id.code', 'like', '..') with _auto_join
diff --git a/odoo/addons/base/tests/test_ir_actions.py b/odoo/addons/base/tests/test_ir_actions.py
index 74d220b05bb..62711a8eab5 100644
--- a/odoo/addons/base/tests/test_ir_actions.py
+++ b/odoo/addons/base/tests/test_ir_actions.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo.exceptions import ValidationError
+from odoo.exceptions import UserError, ValidationError
from odoo.tools import mute_logger
import odoo.tests.common as common
@@ -181,3 +181,112 @@ class TestServerActions(TestServerActionsBase):
self.action.write({
'child_ids': [(6, 0, [self.action.id])]
})
+
+
+class TestCustomFields(common.TransactionCase):
+ MODEL = 'res.partner'
+
+ def setUp(self):
+ # use a test cursor instead of a real cursor
+ registry = odoo.registry()
+ registry.enter_test_mode()
+ fnames = set(registry[self.MODEL]._fields)
+
+ @self.addCleanup
+ def callback():
+ registry.leave_test_mode()
+ # the tests may have modified the registry, reset it
+ with registry.cursor() as cr:
+ registry.clear_manual_fields()
+ registry.setup_models(cr)
+ assert set(registry[self.MODEL]._fields) == fnames
+
+ super(TestCustomFields, self).setUp()
+
+ # do not reload the registry after removing a field
+ self.env = self.env(context={'_force_unlink': True})
+
+ def create_field(self, name):
+ """ create a custom field and return it """
+ model = self.env['ir.model'].search([('model', '=', self.MODEL)])
+ field = self.env['ir.model.fields'].create({
+ 'model_id': model.id,
+ 'name': name,
+ 'field_description': name,
+ 'ttype': 'char',
+ })
+ self.assertIn(name, self.env[self.MODEL]._fields)
+ return field
+
+ def create_view(self, name):
+ """ create a view with the given field name """
+ return self.env['ir.ui.view'].create({
+ 'name': 'yet another view',
+ 'model': self.MODEL,
+ 'arch': '' % name,
+ })
+
+ def test_create_custom(self):
+ """ custom field names must be start with 'x_' """
+ with self.assertRaises(ValidationError):
+ self.create_field('foo')
+
+ def test_rename_custom(self):
+ """ custom field names must be start with 'x_' """
+ field = self.create_field('x_foo')
+ with self.assertRaises(ValidationError):
+ field.name = 'foo'
+
+ def test_create_valid(self):
+ """ field names must be valid pg identifiers """
+ with self.assertRaises(ValidationError):
+ self.create_field('x_foo bar')
+
+ def test_rename_valid(self):
+ """ field names must be valid pg identifiers """
+ field = self.create_field('x_foo')
+ with self.assertRaises(ValidationError):
+ field.name = 'x_foo bar'
+
+ def test_create_unique(self):
+ """ one cannot create two fields with the same name on a given model """
+ self.create_field('x_foo')
+ with self.assertRaises(ValidationError):
+ self.create_field('x_foo')
+
+ def test_rename_unique(self):
+ """ one cannot create two fields with the same name on a given model """
+ field1 = self.create_field('x_foo')
+ field2 = self.create_field('x_bar')
+ with self.assertRaises(ValidationError):
+ field2.name = field1.name
+
+ def test_remove_without_view(self):
+ """ try removing a custom field that does not occur in views """
+ field = self.create_field('x_foo')
+ field.unlink()
+
+ def test_rename_without_view(self):
+ """ try renaming a custom field that does not occur in views """
+ field = self.create_field('x_foo')
+ field.name = 'x_bar'
+
+ def test_remove_with_view(self):
+ """ try removing a custom field that occurs in a view """
+ field = self.create_field('x_foo')
+ self.create_view('x_foo')
+
+ # try to delete the field, this should fail but not modify the registry
+ with self.assertRaises(UserError):
+ field.unlink()
+ self.assertIn('x_foo', self.env[self.MODEL]._fields)
+
+ def test_rename_with_view(self):
+ """ try renaming a custom field that occurs in a view """
+ field = self.create_field('x_foo')
+ self.create_view('x_foo')
+
+ # try to delete the field, this should fail but not modify the registry
+ with self.assertRaises(UserError):
+ field.name = 'x_bar'
+ self.assertIn('x_foo', self.env[self.MODEL]._fields)
diff --git a/odoo/addons/test_new_api/ir.model.access.csv b/odoo/addons/test_new_api/ir.model.access.csv
index 03ec40061ce..4e136481512 100644
--- a/odoo/addons/test_new_api/ir.model.access.csv
+++ b/odoo/addons/test_new_api/ir.model.access.csv
@@ -17,3 +17,4 @@ access_test_new_api_company,access_test_new_api_company,model_test_new_api_compa
access_test_new_api_company_attr,access_test_new_api_company_attr,model_test_new_api_company_attr,,1,1,1,1
access_test_new_api_sparse,access_test_new_api_sparse,model_test_new_api_sparse,,1,1,1,1
access_test_new_api_compute_inverse,access_test_new_api_compute_inverse,model_test_new_api_compute_inverse,,1,1,1,1
+access_test_new_api_recursive,access_test_new_api_recursive,model_test_new_api_recursive,,1,1,1,1
diff --git a/odoo/addons/test_new_api/models.py b/odoo/addons/test_new_api/models.py
index f719c696494..ad7092159a4 100644
--- a/odoo/addons/test_new_api/models.py
+++ b/odoo/addons/test_new_api/models.py
@@ -360,3 +360,19 @@ class Sparse(models.Model):
char = fields.Char(sparse='data')
selection = fields.Selection([('one', 'One'), ('two', 'Two')], sparse='data')
partner = fields.Many2one('res.partner', sparse='data')
+
+
+class ComputeRecursive(models.Model):
+ _name = 'test_new_api.recursive'
+
+ name = fields.Char(required=True)
+ parent = fields.Many2one('test_new_api.recursive')
+ display_name = fields.Char(compute='_compute_display_name', store=True)
+
+ @api.depends('name', 'parent.display_name')
+ def _compute_display_name(self):
+ for rec in self:
+ if rec.parent:
+ rec.display_name = rec.parent.display_name + " / " + rec.name
+ else:
+ rec.display_name = rec.name
diff --git a/odoo/addons/test_new_api/tests/test_new_fields.py b/odoo/addons/test_new_api/tests/test_new_fields.py
index 1326eb3da39..9deaee1a5f0 100644
--- a/odoo/addons/test_new_api/tests/test_new_fields.py
+++ b/odoo/addons/test_new_api/tests/test_new_fields.py
@@ -179,6 +179,29 @@ class TestFields(common.TransactionCase):
cath.parent = finn
self.assertEqual(ewan.display_name, "Gabriel / Finnley / Catherine / Ewan")
+ def test_12_recursive_recompute(self):
+ """ test recomputation on recursively dependent field """
+ a = self.env['test_new_api.recursive'].create({'name': 'A'})
+ b = self.env['test_new_api.recursive'].create({'name': 'B', 'parent': a.id})
+ c = self.env['test_new_api.recursive'].create({'name': 'C', 'parent': b.id})
+ d = self.env['test_new_api.recursive'].create({'name': 'D', 'parent': c.id})
+ self.assertEqual(a.display_name, 'A')
+ self.assertEqual(b.display_name, 'A / B')
+ self.assertEqual(c.display_name, 'A / B / C')
+ self.assertEqual(d.display_name, 'A / B / C / D')
+
+ b.parent = False
+ self.assertEqual(a.display_name, 'A')
+ self.assertEqual(b.display_name, 'B')
+ self.assertEqual(c.display_name, 'B / C')
+ self.assertEqual(d.display_name, 'B / C / D')
+
+ b.name = 'X'
+ self.assertEqual(a.display_name, 'A')
+ self.assertEqual(b.display_name, 'X')
+ self.assertEqual(c.display_name, 'X / C')
+ self.assertEqual(d.display_name, 'X / C / D')
+
def test_12_cascade(self):
""" test computed field depending on computed field """
message = self.env.ref('test_new_api.message_0_0')
diff --git a/odoo/fields.py b/odoo/fields.py
index a5404e3c367..b14be35ba54 100644
--- a/odoo/fields.py
+++ b/odoo/fields.py
@@ -708,6 +708,7 @@ class Field(object):
model._field_triggers.add(field, (self, path_str))
elif path:
self.recursive = True
+ model._field_triggers.add(field, (self, '.'.join(path)))
############################################################################
#
diff --git a/odoo/models.py b/odoo/models.py
index c6e265e4f86..56923cd1558 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -669,7 +669,7 @@ class BaseModel(object):
field = cls._fields.get(name)
if not field:
_logger.warning("method %s.%s: @constrains parameter %r is not a field name", cls._name, attr, name)
- if not (field.store or field.inverse):
+ elif not (field.store or field.inverse):
_logger.warning("method %s.%s: @constrains parameter %r is not writeable", cls._name, attr, name)
methods.append(func)
@@ -4342,7 +4342,8 @@ class BaseModel(object):
"""
self.ensure_one()
vals = self.copy_data(default)[0]
- new = self.create(vals)
+ # To avoid to create a translation in the lang of the user, copy_translation will do it
+ new = self.with_context(lang=None).create(vals)
self.copy_translations(new)
return new
diff --git a/odoo/modules/graph.py b/odoo/modules/graph.py
index aaf90ae8443..ff2f99188fe 100644
--- a/odoo/modules/graph.py
+++ b/odoo/modules/graph.py
@@ -80,7 +80,7 @@ class Graph(dict):
info = odoo.modules.module.load_information_from_description_file(module)
if info and info['installable']:
packages.append((module, info)) # TODO directly a dict, like in get_modules_with_version
- else:
+ elif module != 'studio_customization':
_logger.warning('module %s: not installable, skipped', module)
dependencies = dict([(p, info['depends']) for p, info in packages])
@@ -112,10 +112,7 @@ class Graph(dict):
unmet_deps = filter(lambda p: p not in self, dependencies[package])
_logger.error('module %s: Unmet dependencies: %s', package, ', '.join(unmet_deps))
- result = len(self) - len_graph
- if result != len(module_list):
- _logger.warning('Some modules were not loaded.')
- return result
+ return len(self) - len_graph
def __iter__(self):
diff --git a/odoo/modules/module.py b/odoo/modules/module.py
index ecf95be088b..d15df6dba59 100644
--- a/odoo/modules/module.py
+++ b/odoo/modules/module.py
@@ -302,7 +302,7 @@ def load_information_from_description_file(module, mod_path=None):
:param mod_path: Physical path of module, if not providedThe name of the module (sale, purchase, ...)
"""
if not mod_path:
- mod_path = get_module_path(module)
+ mod_path = get_module_path(module, downloaded=True)
manifest_file = module_manifest(mod_path)
if manifest_file:
# default values for descriptor
diff --git a/odoo/osv/expression.py b/odoo/osv/expression.py
index eea14988eb7..bef1cb1c523 100644
--- a/odoo/osv/expression.py
+++ b/odoo/osv/expression.py
@@ -876,8 +876,7 @@ class expression(object):
# Making search easier when there is a left operand as one2many or many2many
elif len(path) > 1 and field.store and field.type in ('many2many', 'one2many'):
right_ids = comodel.search([('.'.join(path[1:]), operator, right)]).ids
- table_ids = model.with_context(active_test=False).search([(path[0], 'in', right_ids)]).ids
- leaf.leaf = ('id', 'in', table_ids)
+ leaf.leaf = (path[0], 'in', right_ids)
push(leaf)
elif not field.store:
@@ -980,15 +979,16 @@ class expression(object):
rel_table, rel_id1, rel_id2 = field.relation, field.column1, field.column2
if operator in HIERARCHY_FUNCS:
- def _rec_convert(ids):
- if comodel == model:
- return ids
- return select_from_where(cr, rel_id1, rel_table, rel_id2, ids, operator)
-
ids2 = to_ids(right, comodel)
dom = HIERARCHY_FUNCS[operator]('id', ids2, comodel)
ids2 = comodel.search(dom).ids
- push(create_substitution_leaf(leaf, ('id', 'in', _rec_convert(ids2)), model))
+ if comodel == model:
+ push(create_substitution_leaf(leaf, ('id', 'in', ids2), model))
+ else:
+ subquery = 'SELECT "%s" FROM "%s" WHERE "%s" IN %%s' % (rel_id1, rel_table, rel_id2)
+ # avoid flattening of argument in to_sql()
+ subquery = cr.mogrify(subquery, [tuple(ids2)])
+ push(create_substitution_leaf(leaf, ('id', 'inselect', (subquery, [])), internal=True))
else:
call_null_m2m = True
if right is not False:
@@ -1014,8 +1014,11 @@ class expression(object):
operator = 'in' # operator changed because ids are directly related to main object
else:
call_null_m2m = False
- m2m_op = 'not in' if operator in NEGATIVE_TERM_OPERATORS else 'in'
- push(create_substitution_leaf(leaf, ('id', m2m_op, select_from_where(cr, rel_id1, rel_table, rel_id2, res_ids, operator) or [0]), model))
+ subop = 'not inselect' if operator in NEGATIVE_TERM_OPERATORS else 'inselect'
+ subquery = 'SELECT "%s" FROM "%s" WHERE "%s" IN %%s' % (rel_id1, rel_table, rel_id2)
+ # avoid flattening of argument in to_sql()
+ subquery = cr.mogrify(subquery, [tuple(filter(None, res_ids))])
+ push(create_substitution_leaf(leaf, ('id', subop, (subquery, [])), internal=True))
if call_null_m2m:
m2m_op = 'in' if operator in NEGATIVE_TERM_OPERATORS else 'not in'