diff --git a/addons/l10n_fr/migrations/9.0.1.1/post-migrate_tags_on_taxes.py b/addons/l10n_fr/migrations/9.0.1.1/post-migrate_tags_on_taxes.py
new file mode 100644
index 00000000000..6f933404f1d
--- /dev/null
+++ b/addons/l10n_fr/migrations/9.0.1.1/post-migrate_tags_on_taxes.py
@@ -0,0 +1,7 @@
+from openerp.modules.registry import RegistryManager
+
+def migrate(cr, version):
+ registry = RegistryManager.get(cr.dbname)
+ from openerp.addons.account.models.chart_template import migrate_tags_on_taxes
+ migrate_tags_on_taxes(cr, registry)
+
diff --git a/addons/l10n_fr/migrations/9.0.1.1/pre-set_tags_and_taxes_updatable.py b/addons/l10n_fr/migrations/9.0.1.1/pre-set_tags_and_taxes_updatable.py
new file mode 100644
index 00000000000..586ae984b8c
--- /dev/null
+++ b/addons/l10n_fr/migrations/9.0.1.1/pre-set_tags_and_taxes_updatable.py
@@ -0,0 +1,7 @@
+from openerp.modules.registry import RegistryManager
+
+def migrate(cr, version):
+ registry = RegistryManager.get(cr.dbname)
+ from openerp.addons.account.models.chart_template import migrate_set_tags_and_taxes_updatable
+ migrate_set_tags_and_taxes_updatable(cr, registry, 'l10n_fr')
+
diff --git a/addons/mail/controllers/main.py b/addons/mail/controllers/main.py
index c1b4433e74a..25ea7b008ce 100644
--- a/addons/mail/controllers/main.py
+++ b/addons/mail/controllers/main.py
@@ -77,7 +77,10 @@ class MailController(http.Controller):
record_action = record_sudo.get_access_action(access_uid=uid)
else:
record_action = record_sudo.get_access_action()
+ if record_action['type'] == 'ir.actions.act_url' and record_action.get('target_type') != 'public':
+ return cls._redirect_to_messaging()
+ record_action.pop('target_type', None)
# the record has an URL redirection: use it directly
if record_action['type'] == 'ir.actions.act_url':
return werkzeug.utils.redirect(record_action['url'])
diff --git a/addons/mail/static/src/js/many2many_tags_email.js b/addons/mail/static/src/js/many2many_tags_email.js
index 382efabbd35..c566d2a4706 100644
--- a/addons/mail/static/src/js/many2many_tags_email.js
+++ b/addons/mail/static/src/js/many2many_tags_email.js
@@ -28,7 +28,7 @@ BasicModel.include({
var localID = (record._changes && fieldName in record._changes) ?
record._changes[fieldName] :
record.data[fieldName];
- var list = this.localData[localID];
+ var list = this._applyX2ManyOperations(this.localData[localID]);
var invalidPartnerIds = [];
_.each(list.data, function (id) {
var record = self.localData[id];
diff --git a/addons/mail/static/tests/chatter_tests.js b/addons/mail/static/tests/chatter_tests.js
index b5d7feab56e..c650f8c10c9 100644
--- a/addons/mail/static/tests/chatter_tests.js
+++ b/addons/mail/static/tests/chatter_tests.js
@@ -1064,5 +1064,65 @@ QUnit.test('fieldmany2many tags email', function (assert) {
$('.modal-footer .btn-primary').click();
});
+QUnit.test('fieldmany2many tags email (edition)', function (assert) {
+ assert.expect(15);
+
+ this.data.partner.records[0].timmy = [12];
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ res_id: 1,
+ arch:'
',
+ viewOptions: {
+ mode: 'edit',
+ },
+ mockRPC: function (route, args) {
+ if (args.method ==='read' && args.model === 'partner_type') {
+ assert.step(args.args[0]);
+ assert.deepEqual(args.args[1] , ['display_name', 'email'], "should read the email");
+ }
+ return this._super.apply(this, arguments);
+ },
+ archs: {
+ 'partner_type,false,form': '
',
+ },
+ });
+
+ assert.verifySteps([[12]]);
+ assert.strictEqual(form.$('.o_field_many2manytags[name="timmy"] span.o_tag_color_10').length, 1,
+ "should contain one tag");
+
+ // add an other existing tag
+ var $input = form.$('.o_field_many2manytags input');
+ $input.click(); // opens the dropdown
+ $input.autocomplete('widget').find('li:first').click(); // add 'silver'
+
+ assert.strictEqual($('.modal-body.o_act_window').length, 1,
+ "there should be one modal opened to edit the empty email");
+ assert.strictEqual($('.modal-body.o_act_window input[name="display_name"]').val(), "silver",
+ "the opened modal should be a form view dialog with the partner_type 14");
+ assert.strictEqual($('.modal-body.o_act_window input[name="email"]').length, 1,
+ "there should be an email field in the modal");
+
+ // set the email and save the modal (will rerender the form view)
+ $('.modal-body.o_act_window input[name="email"]').val('coucou@petite.perruche').trigger('input');
+ $('.modal-footer .btn-primary').click();
+
+ assert.strictEqual(form.$('.o_field_many2manytags[name="timmy"] span.o_tag_color_10').length, 2,
+ "should contain the second tag");
+ // should have read [14] three times: when opening the dropdown, when opening the modal, and
+ // after the save
+ assert.verifySteps([[12], [14], [14], [14]]);
+
+ form.destroy();
+});
+
});
});
diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py
index 69ad489da17..1125ed663af 100644
--- a/addons/mrp/models/mrp_production.py
+++ b/addons/mrp/models/mrp_production.py
@@ -338,6 +338,7 @@ class MrpProduction(models.Model):
source_location = self.location_src_id
original_quantity = self.product_qty - self.qty_produced
data = {
+ 'sequence': bom_line.sequence,
'name': self.name,
'date': self.date_planned_start,
'date_expected': self.date_planned_start,
diff --git a/addons/point_of_sale/static/src/js/db.js b/addons/point_of_sale/static/src/js/db.js
index c2b3fad52f0..ccdd0fb6616 100644
--- a/addons/point_of_sale/static/src/js/db.js
+++ b/addons/point_of_sale/static/src/js/db.js
@@ -477,6 +477,13 @@ var PosDB = core.Class.extend({
}
return orders;
},
+ set_cashier: function(cashier) {
+ // Always update if the user is the same as before
+ this.save('cashier', cashier);
+ },
+ get_cashier: function() {
+ return this.load('cashier');
+ }
});
return PosDB;
diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js
index c7071344333..f6d1c1c5e41 100644
--- a/addons/point_of_sale/static/src/js/models.js
+++ b/addons/point_of_sale/static/src/js/models.js
@@ -243,6 +243,7 @@ exports.PosModel = Backbone.Model.extend({
}
self.db.set_uuid(self.config.uuid);
+ self.set_cachier(self.get_cashier());
var orders = self.db.get_orders();
for (var i = 0; i < orders.length; i++) {
@@ -584,11 +585,12 @@ exports.PosModel = Backbone.Model.extend({
// returns the user who is currently the cashier for this point of sale
get_cashier: function(){
- return this.get('cashier') || this.user;
+ return this.db.get_cashier() || this.get('cashier') || this.user;
},
// changes the current cashier
set_cashier: function(user){
this.set('cashier', user);
+ this.db.set_cashier(this.cashier);
},
//creates a new empty order and sets it as the current order
add_new_order: function(){
diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py
index 07c70419b89..0f30f231aa1 100644
--- a/addons/sale_timesheet/models/account.py
+++ b/addons/sale_timesheet/models/account.py
@@ -26,8 +26,8 @@ class AccountAnalyticLine(models.Model):
@api.multi
def write(self, values):
- # prevent to update invoiced timesheets if product_id is not of type order
- if self.so_line.product_id.invoice_policy != "order" and self.filtered(lambda timesheet: timesheet.timesheet_invoice_id):
+ # prevent to update invoiced timesheets if one line is of type delivery
+ if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and self.filtered(lambda timesheet: timesheet.timesheet_invoice_id):
if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'task_id', 'timesheet_revenue', 'so_line', 'amount', 'date']]):
raise UserError(_('You can not modify already invoiced timesheets.'))
@@ -123,7 +123,9 @@ class AccountAnalyticLine(models.Model):
revenue = analytic_account.currency_id.round(unit_amount * sale_price * (1-(so_line.discount/100)))
billable_type = 'billable_time'
elif so_line.product_id.invoice_policy == 'order' and so_line.product_id.track_service == 'task':
- quantity_hour = so_line.product_uom._compute_quantity(so_line.product_uom_qty, timesheet_uom)
+ quantity_hour = unit_amount
+ if so_line.product_uom.category_id == timesheet_uom.category_id:
+ quantity_hour = so_line.product_uom._compute_quantity(so_line.product_uom_qty, timesheet_uom)
# compute the total revenue the SO since we are in fixed price
total_revenue_so = analytic_account.currency_id.round(quantity_hour * sale_price * (1-(so_line.discount/100)))
# compute the total revenue already existing (without the current timesheet line)
diff --git a/addons/sale_timesheet/tests/test_timesheet_revenue.py b/addons/sale_timesheet/tests/test_timesheet_revenue.py
index 1fa0b40fcd2..6f1533d3ca1 100644
--- a/addons/sale_timesheet/tests/test_timesheet_revenue.py
+++ b/addons/sale_timesheet/tests/test_timesheet_revenue.py
@@ -186,15 +186,20 @@ class TestSaleTimesheet(TestSale):
self.assertEquals(timesheet2.timesheet_invoice_id, invoice)
self.assertEquals(timesheet3.timesheet_invoice_id, invoice)
- # check that analytic line for product 'ordered' can be altered
+ # check that analytic line for product 'delivery' cannot be altered
with self.assertRaises(UserError):
timesheet1.write(dict(unit_amount=10))
self.assertNotEquals(timesheet1.unit_amount, 10)
- # check that analytic line for product 'delivered' cannot be altered
+ # check that analytic line for product 'ordered' can be altered
timesheet3.write(dict(unit_amount=10))
self.assertEquals(timesheet3.unit_amount, 10)
+ # check that if at least 1 analytic line is for product 'delivery', it cannot be altered
+ with self.assertRaises(UserError):
+ (timesheet1 + timesheet3).write(dict(unit_amount=15))
+ self.assertNotEquals(timesheet1.unit_amount, 15)
+ self.assertNotEquals(timesheet3.unit_amount, 15)
def test_revenue_multi_currency(self):
""" Create a SO with 2 lines : one for a delivered service, one for a ordered service. Confirm
diff --git a/addons/web/static/src/js/chrome/search_inputs.js b/addons/web/static/src/js/chrome/search_inputs.js
index 2dba4c0dd99..d734bb0ebf2 100644
--- a/addons/web/static/src/js/chrome/search_inputs.js
+++ b/addons/web/static/src/js/chrome/search_inputs.js
@@ -356,8 +356,7 @@ var DateField = Field.extend(/** @lends instance.web.search.DateField# */{
var m = moment(v, t === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD');
if (!m.isValid()) { return $.when(null); }
- var d = m.toDate();
- var date_string = field_utils.format[this.attrs.type](d, {type: t.attrs.type});
+ var date_string = field_utils.format[t](m, {type: t});
var label = _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s at: %(value)s")), {
field: '
' + _.escape(this.attrs.string) + '',
@@ -367,7 +366,7 @@ var DateField = Field.extend(/** @lends instance.web.search.DateField# */{
facet: {
category: this.attrs.string,
field: this,
- values: [{label: date_string, value: d}]
+ values: [{label: date_string, value: m.toDate()}]
}
}]);
}
diff --git a/addons/web/static/src/js/core/dialog.js b/addons/web/static/src/js/core/dialog.js
index 6214c99483e..11ce34770de 100644
--- a/addons/web/static/src/js/core/dialog.js
+++ b/addons/web/static/src/js/core/dialog.js
@@ -151,13 +151,12 @@ var Dialog = Widget.extend({
this.$modal.modal('hide');
this.$modal.remove();
- setTimeout(function () { // Keep class modal-open (deleted by bootstrap hide fnct) on body to allow scrolling inside the modal
- var modals = $('body > .modal').filter(':visible');
- if(modals.length) {
- modals.last().focus();
- $('body').addClass('modal-open');
- }
- }, 0);
+ var modals = $('body > .modal').filter(':visible');
+ if (modals.length) {
+ modals.last().focus();
+ // Keep class modal-open (deleted by bootstrap hide fnct) on body to allow scrolling inside the modal
+ $('body').addClass('modal-open');
+ }
}
});
diff --git a/addons/web/static/src/js/services/data_manager.js b/addons/web/static/src/js/services/data_manager.js
index 77857ef2d31..105d7f30d69 100644
--- a/addons/web/static/src/js/services/data_manager.js
+++ b/addons/web/static/src/js/services/data_manager.js
@@ -305,18 +305,6 @@ return core.Class.extend({
viewType = viewType === 'tree' ? 'list' : viewType;
innerFieldsView.type = viewType;
attrs.views[viewType] = self._processFieldsView(_.extend({}, innerFieldsView));
-
- // default_order is like:
- // 'name,id desc'
- // but we need it like:
- // [{name: 'id', asc: false}, {name: 'name', asc: true}]
- var defaultOrder = innerFieldsView.arch.attrs.default_order;
- if (defaultOrder) {
- attrs.orderedBy = _.map(defaultOrder.split(','), function (order) {
- order = order.trim().split(' ');
- return {name: order[0], asc: order[1] !== 'desc'};
- });
- }
});
delete field.views;
}
@@ -346,6 +334,23 @@ return core.Class.extend({
}
}
attrs.mode = mode;
+ if (mode in attrs.views) {
+ var view = attrs.views[mode];
+ // process the default_order, which is like 'name,id desc'
+ // but we need it like [{name: 'name', asc: true}, {name: 'id', asc: false}]
+ var defaultOrder = view.arch.attrs.default_order;
+ if (defaultOrder) {
+ attrs.orderedBy = _.map(defaultOrder.split(','), function (order) {
+ order = order.trim().split(' ');
+ return {name: order[0], asc: order[1] !== 'desc'};
+ });
+ }
+ // detect editables list has they behave differently with respect
+ // to the sorting (changes are not sorted directly)
+ if (mode === 'list' && view.arch.attrs.editable) {
+ attrs.keepChangesUnsorted = true;
+ }
+ }
}
if (attrs.Widget.prototype.fieldsToFetch) {
attrs.viewType = 'default';
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 4e3e5823223..9c4599f1683 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -337,7 +337,6 @@ var BasicModel = AbstractModel.extend({
}
if (element.type === 'record') {
-
var data = _.extend({}, element.data, element._changes);
var relDataPoint;
for (var fieldName in data) {
@@ -411,16 +410,46 @@ var BasicModel = AbstractModel.extend({
}
// here, type === 'list'
- // apply potential changes (only for x2many lists)
- element = this._applyX2ManyOperations(element);
- var listData = _.map(element.data, function (elemID) {
- return self.get(elemID, options);
- });
+ // apply potential changes (only for x2many lists):
+ // for list having the flag _keepChangesUnsorted set to true (typically,
+ // editable lists), we sort before apply changes by default (to keep the
+ // changes unsorted, i.e. the edited line at the same place). However, if
+ // the user forces a sort, all changes done so far are sorted (this is the
+ // purpose of the _keepChangesUnsortedCount key), and changes done later
+ // on won't be sorted.
+ var count;
+ if (element._keepChangesUnsorted) {
+ // only apply a subset (by default 0) of changes before sorting
+ count = element._keepChangesUnsortedCount || 0;
+ element = this._applyX2ManyOperations(element, {to: count, position: 'bottom'});
+ } else {
+ // apply all changes before sorting
+ element = this._applyX2ManyOperations(element, {position: 'bottom'});
+ }
+ this._sortList(element);
+ if (element._keepChangesUnsorted) {
+ // apply the remaining changes after the sort
+ element = this._applyX2ManyOperations(element, {from: count, position: 'bottom'});
+ }
+ if (element._changes) {
+ _.each(element._changes, function (change) {
+ if (change.operation === 'ADD' && change.isNew) {
+ element.data = _.without(element.data, change.id);
+ if (change.position === 'top') {
+ element.data.unshift(change.id);
+ } else {
+ element.data.push(change.id);
+ }
+ }
+ });
+ }
var list = {
aggregateValues: _.extend({}, element.aggregateValues),
context: _.extend({}, element.context),
count: element.count,
- data: listData,
+ data: _.map(element.data, function (elemID) {
+ return self.get(elemID, options);
+ }),
domain: element.domain.slice(0),
fields: element.fields,
getContext: element.getContext,
@@ -434,7 +463,7 @@ var BasicModel = AbstractModel.extend({
offset: element.offset,
orderedBy: element.orderedBy,
res_id: element.res_id,
- res_ids: element.res_ids,
+ res_ids: element.res_ids.slice(0),
type: 'list',
value: element.value,
viewType: element.viewType,
@@ -442,7 +471,6 @@ var BasicModel = AbstractModel.extend({
if (element.fieldsInfo) {
list.fieldsInfo = element.fieldsInfo;
}
- this._sortList(list);
return list;
},
/**
@@ -684,6 +712,10 @@ var BasicModel = AbstractModel.extend({
if (!options.keepChanges) {
this.discardChanges(id, {rollback: false});
}
+ } else if (element._changes) {
+ _.each(element._changes, function (change) {
+ delete change.isNew;
+ });
}
if (options.context !== undefined) {
@@ -876,8 +908,15 @@ var BasicModel = AbstractModel.extend({
var list = this.localData[list_id];
if (list.type === 'record') {
return;
+ } else if (list._changes) {
+ _.each(list._changes, function (change) {
+ delete change.isNew;
+ });
}
- list.offset = 0;
+ // the user manually selected a sort order, so we sort the list with all
+ // the changes he made so far, even if it is editable, however, changes
+ // he'll made after won't be sorted if the list is editable
+ list._keepChangesUnsortedCount = list._changes ? list._changes.length : 0;
if (list.orderedBy.length === 0) {
list.orderedBy.push({name: fieldName, asc: true});
} else if (list.orderedBy[0].name === fieldName){
@@ -972,7 +1011,7 @@ var BasicModel = AbstractModel.extend({
};
return this._makeDefaultRecord(list.model, params).then(function (id) {
var position = options && options.position || 'top';
- list._changes.push({operation: 'ADD', id: id, position: position});
+ list._changes.push({operation: 'ADD', id: id, position: position, isNew: true});
var record = self.localData[id];
list._cache[record.res_id] = id;
return id;
@@ -1366,11 +1405,17 @@ var BasicModel = AbstractModel.extend({
// no 'ADD' operation for that dataPoint, as it would mean
// that the record wasn't in the relation yet
var idsToRemove = command.ids;
- list._changes = _.reject(list._changes, function (change) {
+ list._changes = _.reject(list._changes, function (change, index) {
var idInCommands = _.contains(command.ids, change.id);
if (idInCommands && change.operation === 'ADD') {
idsToRemove = _.without(idsToRemove, change.id);
}
+ // decrement _keepChangesUnsortedCount if we filter out an
+ // operation whose index is smaller than the count (as there
+ // will be one operation less to apply before sorting)
+ if (idInCommands && index < list._keepChangesUnsortedCount) {
+ list._keepChangesUnsortedCount--;
+ }
return idInCommands;
});
_.each(idsToRemove, function (id) {
@@ -1423,19 +1468,28 @@ var BasicModel = AbstractModel.extend({
*
* @private
* @param {Object} dataPoint of type list
+ * @param {Object} [options] mostly contains the range of operations to apply
+ * @param {Object} [options.from=0] the index of the first operation to apply
+ * @param {Object} [options.to=length] the index of the last operation to apply
+ * @param {Object} [options.position] if set, each new operation will be set
+ * accordingly at the top or the bottom of the list
* @returns {Object} element of type list in which the commands have been
* applied
*/
- _applyX2ManyOperations: function (list) {
+ _applyX2ManyOperations: function (list, options) {
if (!list.static) {
// this function only applies on x2many lists
return list;
}
var self = this;
list = _.extend({}, list);
- list.data = list.data.slice(0);
list.res_ids = list.res_ids.slice(0);
- _.each(list._changes, function (change) {
+ var changes = list._changes || [];
+ if (options) {
+ var to = options.to === 0 ? 0 : (options.to || changes.length);
+ changes = changes.slice(options.from || 0, to);
+ }
+ _.each(changes, function (change) {
var relRecord;
if (change.id) {
relRecord = self.localData[change.id];
@@ -1444,7 +1498,7 @@ var BasicModel = AbstractModel.extend({
case 'ADD':
list.count++;
var resID = relRecord ? relRecord.res_id : change.resID;
- if (change.position === 'top') {
+ if (change.position === 'top' && (options ? options.position !== 'bottom' : true)) {
list.res_ids.unshift(resID);
} else {
list.res_ids.push(resID);
@@ -2148,6 +2202,7 @@ var BasicModel = AbstractModel.extend({
rawContext: rawContext,
relationField: field.relation_field,
viewType: view ? view.type : fieldInfo.viewType,
+ _keepChangesUnsorted: fieldInfo.keepChangesUnsorted,
});
record.data[fieldName] = list.id;
if (!fieldInfo.__no_fetch) {
@@ -2834,6 +2889,7 @@ var BasicModel = AbstractModel.extend({
offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0),
openGroupByDefault: params.openGroupByDefault,
orderedBy: params.orderedBy || [],
+ _keepChangesUnsorted: params._keepChangesUnsorted,
parentID: params.parentID,
rawContext: params.rawContext,
relationField: params.relationField,
@@ -3367,7 +3423,9 @@ var BasicModel = AbstractModel.extend({
change.id = dataPoint.id;
}
});
- list.data.push(dataPoint.id);
+ if (_.contains(list.res_ids, id)) {
+ list.data.push(dataPoint.id);
+ }
});
return list;
});
@@ -3458,20 +3516,32 @@ var BasicModel = AbstractModel.extend({
},
/**
* Do a in-memory sort of a list resource data points. This method assumes
- * that the list data has already been fetched, and that the changes have
- * been applied. Its intended use is for static datasets, such as a one2many
- * in a form view.
+ * that the list data has already been fetched, and that the changes that
+ * need to be sorted have already been applied. Its intended use is for
+ * static datasets, such as a one2many in a form view.
*
- * @param {Object} list list dataPoint on which changes have been applied
+ * @param {Object} list list dataPoint on which (some) changes might have
+ * been applied; it is a copy of an internal dataPoint, not the result of
+ * get
*/
_sortList: function (list) {
+ if (!list.static) {
+ // only sort x2many lists
+ return;
+ }
+
if (list.orderedBy.length) {
+ var self = this;
+
// sort records according to ordered_by[0]
var order = list.orderedBy[0];
var data = list.data;
- data.sort(function (r1, r2) {
- var data1 = r1.data;
- var data2 = r2.data;
+ var res_ids = list.res_ids;
+ data.sort(function (record1ID, record2ID) {
+ var r1 = self.localData[record1ID];
+ var r2 = self.localData[record2ID];
+ var data1 = _.extend({}, r1.data, r1._changes);
+ var data2 = _.extend({}, r2.data, r2._changes);
if (data1[order.name] < data2[order.name]) {
return order.asc ? -1 : 1;
}
@@ -3480,6 +3550,15 @@ var BasicModel = AbstractModel.extend({
}
return 0;
});
+
+ // sort res_ids accordingly (only the current range of ids, the one
+ // mapping the data, needs to be sorted)
+ var preRangeIDs = res_ids.slice(0, list.offset); // resIDs before the range
+ var postRangeIDs = res_ids.slice(list.offset + list.limit); // resIDs after the range
+ var rangeIDs = _.map(data, function (dataPointID) {
+ return self.localData[dataPointID].res_id;
+ });
+ list.res_ids = preRangeIDs.concat(rangeIDs).concat(postRangeIDs);
}
},
/**
diff --git a/addons/web/static/src/js/views/calendar/calendar_renderer.js b/addons/web/static/src/js/views/calendar/calendar_renderer.js
index 422258b4035..9ec70551413 100644
--- a/addons/web/static/src/js/views/calendar/calendar_renderer.js
+++ b/addons/web/static/src/js/views/calendar/calendar_renderer.js
@@ -252,6 +252,24 @@ return AbstractRenderer.extend({
this.color_map[key] = index;
return index;
},
+ /**
+ * @override
+ */
+ getLocalState: function () {
+ var $fcScroller = this.$calendar.find('.fc-scroller');
+ return {
+ scrollPosition: $fcScroller.scrollTop(),
+ };
+ },
+ /**
+ * @override
+ */
+ setLocalState: function (localState) {
+ if (localState.scrollPosition) {
+ var $fcScroller = this.$calendar.find('.fc-scroller');
+ $fcScroller.scrollTop(localState.scrollPosition);
+ }
+ },
//--------------------------------------------------------------------------
// Private
@@ -376,6 +394,7 @@ return AbstractRenderer.extend({
var $calendar = this.$calendar;
var $fc_view = $calendar.find('.fc-view');
var scrollPosition = $fc_view.scrollLeft();
+ var scrollTop = this.$calendar.find('.fc-scroller').scrollTop();
$fc_view.scrollLeft(0);
$calendar.fullCalendar('unselect');
@@ -420,7 +439,11 @@ return AbstractRenderer.extend({
this._renderFilters();
this.$calendar.appendTo('body');
- this.$calendar.fullCalendar('render');
+ if (scrollTop) {
+ this.$calendar.fullCalendar('reinitView');
+ } else {
+ this.$calendar.fullCalendar('render');
+ }
this._renderEvents();
this.$calendar.prependTo(this.$('.o_calendar_view'));
diff --git a/addons/web/static/tests/fields/relational_fields_tests.js b/addons/web/static/tests/fields/relational_fields_tests.js
index 6337d74326e..5b356ac25c3 100644
--- a/addons/web/static/tests/fields/relational_fields_tests.js
+++ b/addons/web/static/tests/fields/relational_fields_tests.js
@@ -1474,6 +1474,7 @@ QUnit.module('relational_fields', {
assert.strictEqual($('.modal').length, 1,
"should have one modal in body");
form.destroy();
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = M2O_DELAY;
done();
});
});
@@ -5406,6 +5407,118 @@ QUnit.module('relational_fields', {
});
+ QUnit.test('editing tabbed one2many (editable=bottom)', function (assert) {
+ assert.expect(11);
+
+ this.data.partner.records[0].turtles = [];
+ for (var i = 0; i < 42; i++) {
+ var id = 100 + i;
+ this.data.turtle.records.push({id: id, turtle_foo: 'turtle' + (id-99)});
+ this.data.partner.records[0].turtles.push(id);
+ }
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'
',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].turtles[42], [0, false, {turtle_foo: 'rainbow dash'}]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 41,
+ "should have 41 data rows on the current page");
+ assert.ok(form.$('tr.o_data_row').last().hasClass('o_selected_row'),
+ "last row should be selected");
+
+ form.$('.o_data_row input[name="turtle_foo"]').val('rainbow dash').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 40,
+ "should have 40 data rows on the current page");
+
+ assert.verifySteps(['read', 'read', 'default_get', 'write', 'read', 'read']);
+ form.destroy();
+ });
+
+ QUnit.test('editing tabbed one2many (editable=top)', function (assert) {
+ assert.expect(14);
+
+ this.data.partner.records[0].turtles = [];
+ this.data.turtle.fields.turtle_foo.default = "default foo";
+ for (var i = 0; i < 42; i++) {
+ var id = 100 + i;
+ this.data.turtle.records.push({id: id, turtle_foo: 'turtle' + (id-99)});
+ this.data.partner.records[0].turtles.push(id);
+ }
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch:'
',
+ res_id: 1,
+ mockRPC: function (route, args) {
+ assert.step(args.method);
+ if (args.method === 'write') {
+ assert.deepEqual(args.args[1].turtles[0], [0, false, {turtle_foo: 'rainbow dash'}])
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+
+ form.$buttons.find('.o_form_button_edit').click();
+ form.$('.o_pager_next').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 2,
+ "should have 2 data rows on the current page");
+
+ form.$('.o_field_x2many_list_row_add a').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 3,
+ "should have 3 data rows on the current page");
+ assert.ok(form.$('tr.o_data_row').first().hasClass('o_selected_row'),
+ "first row should be selected");
+
+ assert.strictEqual(form.$('tr.o_data_row input').val(), 'default foo',
+ "selected input should have correct string");
+
+ form.$('.o_data_row input[name="turtle_foo"]').val('rainbow dash').trigger('input');
+ form.$buttons.find('.o_form_button_save').click();
+
+ assert.strictEqual(form.$('tr.o_data_row').length, 40,
+ "should have 40 data rows on the current page");
+
+ assert.verifySteps(['read', 'read', 'read', 'default_get', 'write', 'read', 'read']);
+ form.destroy();
+ });
QUnit.module('FieldMany2Many');
diff --git a/addons/web/static/tests/views/basic_model_tests.js b/addons/web/static/tests/views/basic_model_tests.js
index 318b715a504..b2a00f1ca09 100644
--- a/addons/web/static/tests/views/basic_model_tests.js
+++ b/addons/web/static/tests/views/basic_model_tests.js
@@ -2086,4 +2086,38 @@ QUnit.module('Views', {
model.destroy();
});
+ QUnit.test('only x2many lists (static) should be sorted client-side', function (assert) {
+ assert.expect(1);
+
+ this.params.modelName = 'partner_type';
+ this.params.res_id = undefined;
+ this.params.orderedBy = [{name: 'display_name', asc: true}];
+
+ var model = createModel({
+ Model: BasicModel,
+ data: this.data,
+ mockRPC: function (route) {
+ if (route === '/web/dataset/search_read') {
+ // simulate randomn sort form the server
+ return $.when({
+ length: 3,
+ records: [
+ {id: 12, display_name: "gold", date: "2017-01-25"},
+ {id: 15, display_name: "bronze"},
+ {id: 14, display_name: "silver"},
+ ],
+ });
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ model.load(this.params).then(function (resultID) {
+ var list = model.get(resultID);
+ assert.deepEqual(_.map(list.data, 'res_id'), [12, 15, 14],
+ "should have kept the order from the server");
+ });
+ model.destroy();
+ });
+
});});
diff --git a/addons/web/static/tests/views/list_tests.js b/addons/web/static/tests/views/list_tests.js
index 2ac373dbcd0..ce246ef17d6 100644
--- a/addons/web/static/tests/views/list_tests.js
+++ b/addons/web/static/tests/views/list_tests.js
@@ -773,6 +773,153 @@ QUnit.module('Views', {
list.destroy();
});
+ QUnit.test('use default_order on editable tree: sort on save', function (assert) {
+ assert.expect(8);
+
+ this.data.foo.records[0].o2m = [1, 3];
+
+ var form = createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '
',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+
+ var $o2m = form.$('.o_field_widget[name=o2m]');
+ form.$('.o_field_x2many_list_row_add a').click();
+ $o2m.find('.o_field_widget').val("Value 2").trigger('input');
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+ assert.ok(form.$('tbody tr:eq(2) td input').val(),
+ "Value 2 should be third (shouldn't be sorted)");
+
+ form.$buttons.find('.o_form_button_save').click();
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 2)').length,
+ "Value 2 should be second (should be sorted after saving)");
+ assert.ok(form.$('tbody tr:eq(2) td:contains(Value 3)').length,
+ "Value 3 should be third");
+
+ form.destroy();
+ });
+
+ QUnit.test('use default_order on editable tree: sort on demand', function (assert) {
+ assert.expect(8);
+
+ this.data.foo.records[0].o2m = [1, 3];
+ this.data.bar.fields = {name: {string: "Name", type: "char", sortable: true}};
+ this.data.bar.records[0].name = "Value 1";
+ this.data.bar.records[2].name = "Value 3";
+
+ var form = createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '
',
+ res_id: 1,
+ });
+
+ form.$buttons.find('.o_form_button_edit').click();
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+
+ var $o2m = form.$('.o_field_widget[name=o2m]');
+ form.$('.o_field_x2many_list_row_add a').click();
+ $o2m.find('.o_field_widget').val("Value 2").trigger('input');
+ assert.ok(form.$('tbody tr:first td:contains(Value 1)').length,
+ "Value 1 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 3)').length,
+ "Value 3 should be second");
+ assert.ok(form.$('tbody tr:eq(2) td input').val(),
+ "Value 2 should be third (shouldn't be sorted)");
+
+ $o2m.find('.o_column_sortable').click();
+ assert.ok(form.$('tbody tr:first td:contains(Value 3)').length,
+ "Value 3 should be first");
+ assert.ok(form.$('tbody tr:eq(1) td:contains(Value 2)').length,
+ "Value 2 should be second (should be sorted after saving)");
+ assert.ok(form.$('tbody tr:eq(2) td:contains(Value 1)').length,
+ "Value 1 should be third");
+
+ form.destroy();
+ });
+
+ QUnit.test('use default_order on editable tree: sort on demand in page', function (assert) {
+ assert.expect(4);
+
+ this.data.bar.fields = {name: {string: "Name", type: "char", sortable: true}};
+
+ var ids = [];
+ for (var i=0; i<45; i++) {
+ var id = 4 + i;
+ ids.push(id);
+ this.data.bar.records.push({
+ id: id,
+ name: "Value " + id,
+ });
+ }
+ this.data.foo.records[0].o2m = ids;
+
+ var form = createView({
+ View: FormView,
+ model: 'foo',
+ data: this.data,
+ arch: '
',
+ res_id: 1,
+ });
+
+ // Change page
+ form.$('.o_pager_next').click();
+ assert.ok(form.$('tbody tr:first td:contains(Value 44)').length,
+ "record 44 should be first");
+ assert.ok(form.$('tbody tr:eq(4) td:contains(Value 48)').length,
+ "record 48 should be last");
+
+ form.$('.o_column_sortable').click();
+ assert.ok(form.$('tbody tr:first td:contains(Value 48)').length,
+ "record 48 should be first");
+ assert.ok(form.$('tbody tr:eq(4) td:contains(Value 44)').length,
+ "record 44 should be first");
+
+ form.destroy();
+ });
+
QUnit.test('can display button in edit mode', function (assert) {
assert.expect(1);
diff --git a/addons/web/tests/test_js.py b/addons/web/tests/test_js.py
index c2f29ba0d0d..1614be64c43 100644
--- a/addons/web/tests/test_js.py
+++ b/addons/web/tests/test_js.py
@@ -10,4 +10,4 @@ class WebSuite(odoo.tests.HttpCase):
at_install = False
def test_01_js(self):
- self.phantom_js('/web/tests?mod=web', "", "", login='admin', timeout=240)
+ self.phantom_js('/web/tests?mod=web', "", "", login='admin', timeout=300)
diff --git a/addons/website_blog/models/website_blog.py b/addons/website_blog/models/website_blog.py
index 2bef6f61b5f..c5d17bd7cec 100644
--- a/addons/website_blog/models/website_blog.py
+++ b/addons/website_blog/models/website_blog.py
@@ -227,6 +227,7 @@ class BlogPost(models.Model):
'type': 'ir.actions.act_url',
'url': self.url,
'target': 'self',
+ 'target_type': 'public',
'res_id': self.id,
}
diff --git a/addons/website_forum/models/forum.py b/addons/website_forum/models/forum.py
index b9d6ea0e865..e8349e0cb9e 100644
--- a/addons/website_forum/models/forum.py
+++ b/addons/website_forum/models/forum.py
@@ -783,6 +783,7 @@ class Post(models.Model):
'type': 'ir.actions.act_url',
'url': '/forum/%s/question/%s' % (self.forum_id.id, self.id),
'target': 'self',
+ 'target_type': 'public',
'res_id': self.id,
}
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index 5ea87fdbe92..f56c1b93aa1 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -205,7 +205,7 @@
-
+
No product defined.
Click 'New' in the top-right corner to create your first product.
@@ -417,8 +417,8 @@
-
-
+
+
@@ -438,10 +438,10 @@
-
-
+
-
-
+
-
diff --git a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js
index ff48ebd9dfd..f6f8386a515 100644
--- a/addons/website_sale_delivery/static/src/js/website_sale_delivery.js
+++ b/addons/website_sale_delivery/static/src/js/website_sale_delivery.js
@@ -1,9 +1,15 @@
'use strict';
$(document).ready(function () {
- // When choosing an delivery carrier, update the quotation and the acquirers
+ var $pay_button = $('.oe_sale_acquirer_button button');
+ $pay_button.prop('disabled', false);
+
+ // When choosing an delivery carrier, update the quotation and the acquirers. Disable the 'Pay
+ // Now' button to avoid being redirected to payment acquier if the delivery carrier update is
+ // not over.
var $carrier = $("#delivery_carrier");
$carrier.find("input[name='delivery_type']").click(function (ev) {
+ $pay_button.prop('disabled', true);
var carrier_id = $(ev.currentTarget).val();
window.location.href = '/shop/payment?carrier_id=' + carrier_id;
});
diff --git a/addons/website_slides/models/slides.py b/addons/website_slides/models/slides.py
index ad5df26cadd..9e7ec732e24 100644
--- a/addons/website_slides/models/slides.py
+++ b/addons/website_slides/models/slides.py
@@ -446,6 +446,7 @@ class Slide(models.Model):
'type': 'ir.actions.act_url',
'url': '%s' % self.website_url,
'target': 'self',
+ 'target_type': 'public',
'res_id': self.id,
}
return super(Slide, self).get_access_action(access_uid)
diff --git a/doc/setup/deploy.rst b/doc/setup/deploy.rst
index 34a06a01be3..983a2318117 100644
--- a/doc/setup/deploy.rst
+++ b/doc/setup/deploy.rst
@@ -213,6 +213,10 @@ Instead you must have a proxy redirecting requests whose URL starts with
``/longpolling/`` to the longpolling port. Other request should be proxied to
the :option:`normal HTTP port `
+.. warning:: The livechat worker requires the ``psycogreen`` Python module,
+ which is not always included with all installation packages.
+ It can be manually installed with ``pip install psycogreen``.
+
Configuration sample
--------------------
diff --git a/odoo/addons/base/base.sql b/odoo/addons/base/base.sql
index 9e3efde69a6..57d57a1389b 100644
--- a/odoo/addons/base/base.sql
+++ b/odoo/addons/base/base.sql
@@ -50,7 +50,7 @@ CREATE TABLE ir_module_module (
website character varying(256),
summary character varying(256),
name character varying(128) NOT NULL,
- author character varying(128),
+ author character varying,
icon varchar,
state character varying(16),
latest_version character varying(64),
diff --git a/odoo/addons/base/ir/ir_model.py b/odoo/addons/base/ir/ir_model.py
index 9f3ef47faa0..73f1aa10ec2 100644
--- a/odoo/addons/base/ir/ir_model.py
+++ b/odoo/addons/base/ir/ir_model.py
@@ -1538,7 +1538,9 @@ class IrModelData(models.Model):
if model == 'ir.model.fields':
# Don't remove the LOG_ACCESS_COLUMNS unless _log_access
# has been turned off on the model.
- field = self.env[model].browse(res_id)
+ field = self.env[model].browse(res_id).with_context(
+ prefetch_fields=False,
+ )
if not field.exists():
_logger.info('Deleting orphan external_ids %s', external_ids)
external_ids.unlink()
diff --git a/odoo/tools/misc.py b/odoo/tools/misc.py
index 9116e08cec8..9eabfd13bd9 100644
--- a/odoo/tools/misc.py
+++ b/odoo/tools/misc.py
@@ -371,11 +371,12 @@ try:
# TODO when xlsxwriter bump to 0.9.8, add worksheet_class=None parameter instead of kw
def add_worksheet(self, name=None, **kw):
- # invalid Excel character: []:*?/\
- name = re.sub(r'[\[\]:*?/\\]', '', name)
+ if name:
+ # invalid Excel character: []:*?/\
+ name = re.sub(r'[\[\]:*?/\\]', '', name)
- # maximum size is 31 characters
- name = name[:31]
+ # maximum size is 31 characters
+ name = name[:31]
return super(PatchedXlsxWorkbook, self).add_worksheet(name, **kw)
xlsxwriter.Workbook = PatchedXlsxWorkbook
diff --git a/odoo/tools/sql.py b/odoo/tools/sql.py
index 6c783fe27e7..d15bd027176 100644
--- a/odoo/tools/sql.py
+++ b/odoo/tools/sql.py
@@ -23,14 +23,18 @@ _CONFDELTYPES = {
def existing_tables(cr, tablenames):
""" Return the names of existing tables among ``tablenames``. """
- query = """ SELECT table_name FROM information_schema.tables
- WHERE table_name IN %s AND table_schema != 'information_schema' """
+ query = """
+ SELECT relname
+ FROM pg_class
+ WHERE relname IN %s
+ AND relkind IN ('r', 'v', 'm')
+ """
cr.execute(query, [tuple(tablenames)])
return [row[0] for row in cr.fetchall()]
def table_exists(cr, tablename):
""" Return whether the given table exists. """
- query = "SELECT 1 FROM information_schema.tables WHERE table_name=%s AND table_schema != 'information_schema'"
+ query = "SELECT 1 FROM pg_class WHERE relkind IN ('r', 'v', 'm') AND relname=%s"
cr.execute(query, (tablename,))
return cr.rowcount