[MERGE] forward port branch 11.0 up to bb6f6c57f9

This commit is contained in:
Christophe Simonis
2018-11-28 17:44:46 +01:00
34 changed files with 522 additions and 116 deletions
+1
View File
@@ -1471,6 +1471,7 @@ class AccountInvoiceLine(models.Model):
help="Gives the sequence of this line when displaying the invoice.")
invoice_id = fields.Many2one('account.invoice', string='Invoice Reference',
ondelete='cascade', index=True)
invoice_type = fields.Selection(related='invoice_id.type', readonly=True)
uom_id = fields.Many2one('uom.uom', string='Unit of Measure',
ondelete='set null', index=True, oldname='uos_id')
product_id = fields.Many2one('product.product', string='Product',
@@ -339,7 +339,11 @@ class AccountReconciliation(models.AbstractModel):
rec_prop = self._get_move_line_reconciliation_proposition(account.id, partner_id)
row['reconciliation_proposition'] = self._prepare_move_lines(rec_prop, target_currency=currency)
row['company_id'] = account.company_id.id
return rows
# Return the partners with a reconciliation proposition first, since they are most likely to
# be reconciled.
return [r for r in rows if r['reconciliation_proposition']] + [r for r in rows if not r['reconciliation_proposition']]
@api.model
def process_move_lines(self, data):
@@ -60,8 +60,11 @@
<form string="Invoice Line">
<group>
<group>
<field name="partner_id" invisible="1"/>
<field name="invoice_type" invisible="1"/>
<field name="company_id" invisible="1"/>
<field name="product_id"
context="parent and {'partner_id': parent.partner_id}"/>
context="{'partner_id': partner_id}"/>
<label for="quantity"/>
<div>
<field name="quantity" class="oe_inline"/>
@@ -73,9 +76,9 @@
</group>
<group>
<field name="analytic_tag_ids" groups="analytic.group_analytic_accounting" widget="many2many_tags" options="{'color_field': 'color'}"/>
<field domain="[('company_id', '=', parent.company_id)]" name="account_id" groups="account.group_account_user"/>
<field name="invoice_line_tax_ids" context="{'type':parent.type}" domain="[('type_tax_use','!=','none'),('company_id', '=', parent.company_id)]" widget="many2many_tags" options="{'no_create': True}"/>
<field domain="[('company_id', '=', parent.company_id)]" name="account_analytic_id" groups="analytic.group_analytic_accounting"/>
<field domain="[('company_id', '=', company_id)]" name="account_id" groups="account.group_account_user"/>
<field name="invoice_line_tax_ids" context="{'type': invoice_type}" domain="[('type_tax_use','!=','none'),('company_id', '=', company_id)]" widget="many2many_tags" options="{'no_create': True}"/>
<field domain="[('company_id', '=', company_id)]" name="account_analytic_id" groups="analytic.group_analytic_accounting"/>
<field name="analytic_tag_ids" widget="many2many_tags" groups="analytic.group_analytic_tags"/>
<field name="company_id" groups="base.group_multi_company" readonly="1"/>
</group>
+3 -1
View File
@@ -49,7 +49,9 @@ class HrEmployee(models.Model):
@api.depends('attendance_ids')
def _compute_last_attendance_id(self):
for employee in self:
employee.last_attendance_id = employee.attendance_ids and employee.attendance_ids[0] or False
employee.last_attendance_id = self.env['hr.attendance'].search([
('employee_id', '=', employee.id),
], limit=1)
@api.depends('last_attendance_id.check_in', 'last_attendance_id.check_out', 'last_attendance_id')
def _compute_attendance_state(self):
+1
View File
@@ -441,6 +441,7 @@ class HrExpense(models.Model):
'employee_id': employee.id,
'product_id': product.id,
'product_uom_id': product.uom_id.id,
'tax_ids': [(4, tax.id, False) for tax in product.supplier_taxes_id],
'quantity': 1,
'unit_amount': price,
'company_id': employee.company_id.id,
+20
View File
@@ -72,3 +72,23 @@ class TestAccountEntry(TestExpenseCommon):
self.assertEquals(len(self.analytic_account.line_ids), 1, "Analytic Account should have only one line")
self.assertAlmostEquals(self.analytic_account.line_ids[0].amount, -636.36, "Amount on the only AAL is wrong")
self.assertEquals(self.analytic_account.line_ids[0].product_id, self.product_expense, "Product of AAL should be the one from the expense")
def test_expense_from_email(self):
user_demo = self.env.ref('base.user_demo')
self.tax.price_include = False
message_parsed = {
'message_id': 'the-world-is-a-ghetto',
'subject': '[AT] 9876',
'email_from': 'demo@yourcompany.example.com',
'to': 'catchall@yourcompany.com',
'body': "Don't you know, that for me, and for you",
'attachments': [],
}
expense = self.env['hr.expense'].message_new(message_parsed)
self.assertEquals(expense.product_id, self.product)
self.assertEquals(expense.tax_ids.ids, [self.tax.id])
self.assertEquals(expense.total_amount, 10863.60)
self.assertTrue(expense.employee_id in user_demo.employee_ids)
+1 -1
View File
@@ -194,7 +194,7 @@ class HrPayslip(models.Model):
current_leave_struct['number_of_days'] += leave_time / work_hours
# compute worked days
work_data = contract.employee_id.get_work_days_data(day_from, day_to, calendar=contract.resource_calendar_id)
work_data = contract.employee_id.with_context(no_tz_convert=True).get_work_days_data(day_from, day_to, calendar=contract.resource_calendar_id)
attendances = {
'name': _("Normal Working Days paid at 100%"),
'sequence': 1,
+12 -1
View File
@@ -61,7 +61,18 @@ class MailMail(models.Model):
# notification field: if not set, set if mail comes from an existing mail.message
if 'notification' not in values and values.get('mail_message_id'):
values['notification'] = True
return super(MailMail, self).create(values)
new_mail = super(MailMail, self).create(values)
if values.get('attachment_ids'):
new_mail.attachment_ids.check(mode='read')
return new_mail
@api.multi
def write(self, vals):
res = super(MailMail, self).write(vals)
if vals.get('attachment_ids'):
for mail in self:
mail.attachment_ids.check(mode='read')
return res
@api.multi
def unlink(self):
+11 -3
View File
@@ -12,7 +12,7 @@ from odoo.exceptions import UserError, AccessError
from odoo.osv import expression
_logger = logging.getLogger(__name__)
_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])', re.I)
_image_dataurl = re.compile(r'(data:image/[a-z]+?);base64,([a-z0-9+/\n]{3,}=*)\n*([\'"])(?: data-filename="([^"]*)")?', re.I)
class Message(models.Model):
@@ -764,12 +764,13 @@ class Message(models.Model):
def base64_to_boundary(match):
key = match.group(2)
if not data_to_url.get(key):
name = 'image%s' % len(data_to_url)
name = match.group(4) if match.group(4) else 'image%s' % len(data_to_url)
attachment = Attachments.create({
'name': name,
'datas': match.group(2),
'datas_fname': name,
'res_model': 'mail.message',
'res_model': values.get('model'),
'res_id': values.get('res_id'),
})
attachment.generate_access_token()
values['attachment_ids'].append((4, attachment.id))
@@ -780,6 +781,10 @@ class Message(models.Model):
# delegate creation of tracking after the create as sudo to avoid access rights issues
tracking_values_cmd = values.pop('tracking_value_ids', False)
message = super(Message, self).create(values)
if values.get('attachment_ids'):
message.attachment_ids.check(mode='read')
if tracking_values_cmd:
message.sudo().write({'tracking_value_ids': tracking_values_cmd})
@@ -800,6 +805,9 @@ class Message(models.Model):
if 'model' in vals or 'res_id' in vals:
self._invalidate_documents()
res = super(Message, self).write(vals)
if vals.get('attachment_ids'):
for mail in self:
mail.attachment_ids.check(mode='read')
if 'notification_ids' in vals or 'model' in vals or 'res_id' in vals:
self._invalidate_documents()
return res
+2
View File
@@ -191,6 +191,8 @@ class MailThread(models.AbstractModel):
@api.multi
def _get_message_needaction(self):
res = dict((res_id, 0) for res_id in self.ids)
if not res:
return
# search for unread messages, directly in SQL to improve performances
self._cr.execute(""" SELECT msg.res_id FROM mail_message msg
+1 -1
View File
@@ -78,7 +78,7 @@ class Partner(models.Model):
company = user.company_id
if record and hasattr(record, 'company_id'):
company = record.company_id
company = record.company_id.sudo()
return {
'message': message,
+21 -9
View File
@@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml import etree
from lxml.html import builder as html
from odoo import _, api, fields, models
@@ -12,18 +15,27 @@ class Invite(models.TransientModel):
@api.model
def default_get(self, fields):
result = super(Invite, self).default_get(fields)
if self._context.get('mail_invite_follower_channel_only'):
result['send_mail'] = False
if 'message' not in fields:
return result
user_name = self.env.user.name_get()[0][1]
model = result.get('res_model')
res_id = result.get('res_id')
if self._context.get('mail_invite_follower_channel_only'):
result['send_mail'] = False
if 'message' in fields and model and res_id:
model_name = self.env['ir.model']._get(model).display_name
document_name = self.env[model].browse(res_id).name_get()[0][1]
message = _('<div><p>Hello,</p><p>%s invited you to follow %s document: %s.</p></div>') % (user_name, model_name, document_name)
result['message'] = message
elif 'message' in fields:
result['message'] = _('<div><p>Hello,</p><p>%s invited you to follow a new document.</p></div>') % user_name
if model and res_id:
document = self.env['ir.model']._get(model).display_name
title = self.env[model].browse(res_id).display_name
msg_fmt = _('%(user_name)s invited you to follow %(document)s document: %(title)s')
else:
msg_fmt = _('%(user_name)s invited you to follow a new document.')
text = msg_fmt % locals()
message = html.DIV(
html.P(_('Hello,')),
html.P(text)
)
result['message'] = etree.tostring(message)
return result
res_model = fields.Char('Related Document Model', required=True, index=True, help='Model of the followed resource')
@@ -363,8 +363,8 @@ class PurchaseOrder(models.Model):
self.order_line = order_lines
@api.multi
def button_confirm(self):
res = super(PurchaseOrder, self).button_confirm()
def button_approve(self, force=False):
res = super(PurchaseOrder, self).button_approve(force=force)
for po in self:
if not po.requisition_id:
continue
+3 -2
View File
@@ -437,8 +437,9 @@ class ResourceCalendar(models.Model):
if not end_dt:
end_dt = datetime.datetime.combine(start_dt.date(), datetime.time.max)
start_dt = to_naive_user_tz(start_dt, self.env.user)
end_dt = to_naive_user_tz(end_dt, self.env.user)
if not self.env.context.get('no_tz_convert', False):
start_dt = to_naive_user_tz(start_dt, self.env.user)
end_dt = to_naive_user_tz(end_dt, self.env.user)
for day in rrule.rrule(rrule.DAILY,
dtstart=start_dt,
+1
View File
@@ -57,6 +57,7 @@ class ResourceMixin(models.AbstractModel):
days_count = 0.0
total_work_time = timedelta()
calendar = calendar or self.resource_calendar_id
calendar = calendar.with_context(no_tz_convert=self.env.context.get('no_tz_convert', False))
for day_intervals in calendar._iter_work_intervals(
from_datetime, to_datetime, self.resource_id.id,
compute_leaves=True, domain=domain):
+24 -15
View File
@@ -19,30 +19,39 @@ KanbanRecord.include({
*/
_onSalesTeamTargetClick: function (ev) {
ev.preventDefault();
var self = this;
this.$target_input = $('<input>');
this.$('.o_kanban_primary_bottom:last').html(this.$target_input);
this.$('.o_kanban_primary_bottom:last').prepend(_t("Set an invoicing target: "));
this.$target_input.focus();
this.$target_input.on({
blur: this._onSalesTeamTargetSet.bind(this),
keydown: function (ev) {
if (ev.keyCode === $.ui.keyCode.ENTER) {
self._onSalesTeamTargetSet();
}
},
});
},
/**
* Mostly a handler for what happens to the input "this.$target_input"
*
* @private
* @param {JqueryEvent} ev
*
*/
_onSalesTeamTargetSet: function () {
var self = this;
this.$target_input.blur(function() {
var value = Number(self.$target_input.val());
if (isNaN(value)) {
self.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid."));
} else {
self._rpc({
model: 'crm.team',
method: 'write',
args: [[self.id], { 'invoiced_target': value }],
})
.done(function() {
self.trigger_up('kanban_record_update', {id: self.id});
});
var value = Number(this.$target_input.val());
if (isNaN(value)) {
this.do_warn(_t("Wrong value entered!"), _t("Only Integer Value should be valid."));
} else {
this.trigger_up('kanban_record_update', {invoiced_target: value});
// TODO: previous lines can be refactored as follows (in master)
// self.trigger_up('kanban_record_update', {invoiced_target: value});
}
});
}
},
});
@@ -23,7 +23,7 @@ QUnit.module('Sales Team Dashboard', {
});
QUnit.test('edit target with several o_kanban_primary_bottom divs', function (assert) {
assert.expect(4);
assert.expect(6);
var kanban = createView({
View: KanbanView,
@@ -33,6 +33,7 @@ QUnit.test('edit target with several o_kanban_primary_bottom divs', function (as
'<templates>' +
'<t t-name="kanban-box">' +
'<div class="container o_kanban_card_content">' +
'<field name="invoiced_target" />' +
'<a href="#" class="sales_team_target_definition o_inline_link">' +
'Click to define a target</a>' +
'<div class="col-12 o_kanban_primary_bottom"/>' +
@@ -46,9 +47,12 @@ QUnit.test('edit target with several o_kanban_primary_bottom divs', function (as
assert.strictEqual(args.args[1].invoiced_target, 123,
"new value is correctly saved");
}
if (args.method === 'read') { // Read happens after the write
assert.deepEqual(args.args[1], ['invoiced_target', 'display_name'],
'the read (after write) should ask for invoiced_target');
}
return this._super.apply(this, arguments);
},
});
assert.strictEqual(kanban.$('.o_kanban_view .sales_team_target_definition').length, 1,
@@ -64,6 +68,54 @@ QUnit.test('edit target with several o_kanban_primary_bottom divs', function (as
kanban.$('.o_kanban_primary_bottom:last input').val('123');
kanban.$('.o_kanban_primary_bottom:last input').blur();
assert.strictEqual(kanban.$('.o_kanban_record').text(), "123Click to define a target",
'The kanban record should display the updated target value');
kanban.destroy();
});
QUnit.test('edit target supports push Enter', function (assert) {
assert.expect(3);
var kanban = createView({
View: KanbanView,
model: 'crm.team',
data: this.data,
arch: '<kanban>' +
'<templates>' +
'<t t-name="kanban-box">' +
'<div class="container o_kanban_card_content">' +
'<field name="invoiced_target" />' +
'<a href="#" class="sales_team_target_definition o_inline_link">' +
'Click to define a target</a>' +
'<div class="col-12 o_kanban_primary_bottom"/>' +
'<div class="col-12 o_kanban_primary_bottom bottom_block"/>' +
'</div>' +
'</t>' +
'</templates>' +
'</kanban>',
mockRPC: function (route, args) {
if (args.method === 'write') {
assert.strictEqual(args.args[1].invoiced_target, 123,
"new value is correctly saved");
}
if (args.method === 'read') { // Read happens after the write
assert.deepEqual(args.args[1], ['invoiced_target', 'display_name'],
'the read (after write) should ask for invoiced_target');
}
return this._super.apply(this, arguments);
},
});
kanban.$('a.sales_team_target_definition').click();
kanban.$('.o_kanban_primary_bottom:last input').focus();
kanban.$('.o_kanban_primary_bottom:last input').val('123');
kanban.$('.o_kanban_primary_bottom:last input').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER, keyCode: $.ui.keyCode.ENTER}));
assert.strictEqual(kanban.$('.o_kanban_record').text(), "123Click to define a target",
'The kanban record should display the updated target value');
kanban.destroy();
});
+2 -1
View File
@@ -268,7 +268,8 @@ class StockMove(models.Model):
# Update the standard price with the price of the last used candidate, if any.
if new_standard_price and move.product_id.cost_method == 'fifo':
move.product_id.sudo().standard_price = new_standard_price
move.product_id.sudo().with_context(force_company=move.company_id.id) \
.standard_price = new_standard_price
# If there's still quantity to value but we're out of candidates, we fall in the
# negative stock use case. We chose to value the out move at the price of the
@@ -2096,6 +2096,18 @@ var FieldProgressBar = AbstractField.extend({
this.$('.o_progressbar_value').focus().select();
}
},
/**
* The progress bar has more than one field/value to deal with
* i.e. max_value
*
* @override
* @private
*/
_reset: function () {
this._super.apply(this, arguments);
var new_max_value = this.recordData[this.nodeOptions.max_value];
this.max_value = new_max_value !== undefined ? new_max_value : this.max_value;
},
isSet: function () {
return true;
},
@@ -468,6 +468,7 @@ var FieldMany2One = AbstractField.extend({
initial_ids: ids ? _.map(ids, function (x) { return x[0]; }) : undefined,
initial_view: view,
disable_multiple_selection: true,
no_create: !self.can_create,
on_selected: function (records) {
self.reinitialize(records[0]);
self.activate();
@@ -141,6 +141,7 @@ var AbstractView = Class.extend({
modelName: params.modelName,
res_id: params.currentId,
res_ids: params.ids,
orderedBy: params.context ? params.context.orderedBy : [],
};
if (params.modelName) {
this.loadParams.modelName = params.modelName;
@@ -3365,7 +3365,7 @@ var BasicModel = AbstractModel.extend({
model: params.modelName,
offset: params.offset || (type === 'record' ? _.indexOf(res_ids, res_id) : 0),
openGroupByDefault: params.openGroupByDefault,
orderedBy: params.orderedBy || (params.context && params.context.orderedBy) || [],
orderedBy: params.orderedBy || [],
orderedResIDs: params.orderedResIDs,
parentID: params.parentID,
rawContext: params.rawContext,
@@ -4279,7 +4279,7 @@ var BasicModel = AbstractModel.extend({
if (orderData1 > orderData2) {
return order.asc ? 1 : -1;
}
return compareRecords(record1ID, record2ID, level + 1);
return compareRecords(resId1, resId2, level + 1);
};
utils.stableSort(list.res_ids, compareRecords);
}
@@ -4888,6 +4888,48 @@ QUnit.module('basic_fields', {
form.destroy();
});
QUnit.module('FieldProgressBar');
QUnit.test('Field ProgressBar: max_value should update', function (assert) {
assert.expect(2);
this.data.partner.records = this.data.partner.records.slice(0,1);
this.data.partner.records[0].qux = 2;
this.data.partner.onchanges = {
display_name: function (obj) {
obj.int_field = 999;
obj.qux = 5;
}
};
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<field name="display_name" />' +
'<field name="qux" invisible="1" />' +
'<field name="int_field" widget="progressbar" options="{\'current_value\': \'int_field\', \'max_value\': \'qux\'}" />' +
'</form>',
res_id: 1,
viewOptions: {
mode: 'edit',
},
});
assert.strictEqual(form.$('.o_progressbar_value').text(), '10 / 2',
'The initial value of the progress bar should be correct');
// trigger the onchange
form.$('.o_input[name=display_name]').val('new name').trigger('input');
assert.strictEqual(form.$('.o_progressbar_value').text(), '999 / 5',
'The value of the progress bar should be correct after the update');
form.destroy();
});
});
});
});
@@ -2733,6 +2733,48 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('x2many default_order multiple fields', function (assert) {
assert.expect(7);
this.data.partner.records = [
{int_field: 10, id: 1, display_name: "record1"},
{int_field: 12, id: 2, display_name: "record2"},
{int_field: 11, id: 3, display_name: "record3"},
{int_field: 12, id: 4, display_name: "record4"},
{int_field: 10, id: 5, display_name: "record5"},
{int_field: 10, id: 6, display_name: "record6"},
{int_field: 11, id: 7, display_name: "record7"},
];
this.data.partner.records[0].p = [1, 7, 4, 5, 2, 6, 3];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<field name="p" >' +
'<tree default_order="int_field,id">' +
'<field name="id"/>' +
'<field name="int_field"/>' +
'</tree>' +
'</field>' +
'</form>',
res_id: 1,
});
var $recordList = form.$('.o_field_x2many_list .o_data_row');
var expectedOrderId = ['1', '5', '6', '3', '7', '2', '4'];
_.each($recordList, function(record, index) {
var $record = $(record);
assert.strictEqual($record.find('.o_data_cell').eq(0).text(), expectedOrderId[index],
'The record should be the right place. Index: ' + index);
});
form.destroy();
});
QUnit.test('many2many list add *many* records, remove, re-add', function (assert) {
assert.expect(5);
@@ -2873,6 +2915,41 @@ QUnit.module('relational_fields', {
form.destroy();
});
QUnit.test('one2many from a model that has been sorted', function (assert) {
assert.expect(1);
/* On a standard list view, sort your records by a field
* Click on a record which contains a x2m with multiple records in it
* The x2m shouldn't take the orderedBy of the parent record (the one on the form)
*/
this.data.partner.records[0].turtles = [3, 2];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<field name="turtles">' +
'<tree>' +
'<field name="turtle_foo"/>' +
'</tree>' +
'</field>' +
'</form>',
res_id: 1,
context: {
orderedBy: [{
name: 'foo',
asc: false,
}]
},
});
assert.strictEqual(form.$('.o_field_one2many[name=turtles] tbody').text().trim(), "kawablip",
'The o2m should not have been sorted.');
form.destroy();
});
QUnit.test('O2M List with pager, decoration and default_order: add and cancel adding', function (assert) {
assert.expect(3);
@@ -476,6 +476,66 @@ QUnit.module('Views', {
testUtils.unpatch(ListController);
parent.destroy();
});
QUnit.test('propagate can_create onto the search popup o2m', function (assert) {
assert.expect(3);
this.data.instrument.records = [
{id: 1, name: 'Tromblon1'},
{id: 2, name: 'Tromblon2'},
{id: 3, name: 'Tromblon3'},
{id: 4, name: 'Tromblon4'},
{id: 5, name: 'Tromblon5'},
{id: 6, name: 'Tromblon6'},
{id: 7, name: 'Tromblon7'},
{id: 8, name: 'Tromblon8'},
];
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form>' +
'<field name="name"/>' +
'<field name="instrument" can_create="false"/>' +
'</form>',
res_id: 1,
archs: {
'instrument,false,list': '<tree>'+
'<field name="name"/>'+
'</tree>',
'instrument,false,search': '<search>'+
'<field name="name"/>'+
'</search>',
},
viewOptions: {
mode: 'edit',
},
mockRPC: function(route, args) {
if (args.method === 'get_formview_id') {
return $.when(false);
}
return this._super(route, args);
},
});
form.$('.o_field_widget[name="instrument"] .o_input').click();
assert.notOk($('.ui-autocomplete a:contains(Create and Edit)').length,
'Create and edit not present in dropdown');
$('.ui-autocomplete a:contains(Search More)').trigger('mouseenter').trigger('click');
var $modal = $('.modal-dialog.modal-lg');
assert.strictEqual($modal.length, 1, 'Modal present');
assert.strictEqual($modal.find('.modal-footer button').text(), "Cancel",
'Only the cancel button is present in modal');
form.destroy();
});
});
});
+1 -1
View File
@@ -54,7 +54,7 @@ class SaleOrder(models.Model):
abandoned_delay = float(self.env['ir.config_parameter'].sudo().get_param('website_sale.cart_abandoned_delay', '1.0'))
abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay))
for order in self:
domain = order.date_order <= abandoned_datetime and order.team_id.team_type == 'website' and order.state == 'draft' and order.partner_id.id != self.env.ref('base.public_partner').id and order.order_line
domain = order.date_order and order.date_order <= abandoned_datetime and order.team_id.team_type == 'website' and order.state == 'draft' and order.partner_id.id != self.env.ref('base.public_partner').id and order.order_line
order.is_abandoned_cart = bool(domain)
def _search_abandoned_cart(self, operator, value):
+1
View File
@@ -34,3 +34,4 @@ Damien Crier damien.crier@camptocamp.com https://github.com/damdam-s
Simone Orsi simahawk@gmail.com https://github.com/simahawk
Patrick Tombez patrick.tombez@camptocamp.com https://github.com/p-tombez
Julien Coux julien.coux@camptocamp.com https://github.com/jcoux
Alexandre Saunier alexandre.saunier@camptocamp.com https://github.com/asaunier
+8 -5
View File
@@ -217,10 +217,13 @@ command is available and configured properly, run the following commands :
$ sudo systemctl start postgresql
In order to print PDF reports, you must install wkhtmltopdf_ yourself:
the version of wkhtmltopdf_ available in debian repositories does not support
headers and footers so it can not be installed automatically.
The recommended version is 0.12.1 and is available on `the wkhtmltopdf download page`_,
in the archive section.
the version of wkhtmltopdf_ available in Debian repositories does
not support headers and footers so it is not used as a direct dependency.
The recommended version is 0.12.5 and is available on
`the wkhtmltopdf download page`_, in the archive section. Previously
recommended version 0.12.1 is a good alternative.
More details on the various versions and their respective quirks can be
found in our `wiki <https://github.com/odoo/odoo/wiki/Wkhtmltopdf>`_.
Repository
^^^^^^^^^^
@@ -606,7 +609,7 @@ official Odoo `docker image <https://registry.hub.docker.com/_/odoo/>`_ page.
http://www.enterprisedb.com/products-services-training/pgdownload
.. _Quilt: http://en.wikipedia.org/wiki/Quilt_(software)
.. _saas: https://www.odoo.com/page/start
.. _the wkhtmltopdf download page: https://github.com/wkhtmltopdf/wkhtmltopdf/releases/tag/0.12.1
.. _the wkhtmltopdf download page: https://github.com/wkhtmltopdf/wkhtmltopdf/releases/tag/0.12.5
.. _UAC: http://en.wikipedia.org/wiki/User_Account_Control
.. _wkhtmltopdf: http://wkhtmltopdf.org
.. _pip: https://pip.pypa.io
+28 -21
View File
@@ -438,15 +438,15 @@ class IrTranslation(models.Model):
if not callable(field.translate):
return
trans = self.env['ir.translation']
outdated = trans
discarded = trans
Translation = self.env['ir.translation']
outdated = Translation
discarded = Translation
for record in records:
# get field value and terms to translate
value = record[field.name]
terms = set(field.get_trans_terms(value))
record_trans = trans.search([
translations = Translation.search([
('type', '=', 'model'),
('name', '=', "%s,%s" % (field.model_name, field.name)),
('res_id', '=', record.id),
@@ -454,25 +454,32 @@ class IrTranslation(models.Model):
if not terms:
# discard all translations for that field
discarded += record_trans
discarded += translations
continue
# remap existing translations on terms when possible
trans_src = record_trans.mapped('src')
for trans in record_trans:
if trans.src == trans.value:
discarded += trans
elif trans.src not in terms:
matches = get_close_matches(trans.src, terms, 1, 0.9)
if matches:
if matches[0] in trans_src:
# there is already a translation for this term; discard this one
discarded += trans
else:
trans.write({'src': matches[0], 'state': trans.state})
trans_src.append(matches[0]) # avoid reuse of term
else:
outdated += trans
# remap existing translations on terms when possible; each term
# should be translated at most once per language
done = set() # {(src, lang), ...}
translations_to_match = []
for translation in translations:
if translation.src == translation.value:
discarded += translation
elif translation.src in terms:
done.add((translation.src, translation.lang))
else:
translations_to_match.append(translation)
for translation in translations_to_match:
matches = get_close_matches(translation.src, terms, 1, 0.9)
src = matches[0] if matches else None
if not src:
outdated += translation
elif (src, translation.lang) in done:
discarded += translation
else:
translation.write({'src': src, 'state': translation.state})
done.add((src, translation.lang))
# process outdated and discarded translations
outdated.write({'state': 'to_translate'})
+2
View File
@@ -448,6 +448,8 @@ class Users(models.Model):
@api.model
def check_credentials(self, password):
""" Override this method to plug additional authentication methods"""
if not password:
raise AccessDenied()
user = self.sudo().search([('id', '=', self._uid), ('password', '=', password)])
if not user:
raise AccessDenied()
+50 -32
View File
@@ -310,30 +310,36 @@ class TestTranslation(TransactionCase):
class TestXMLTranslation(TransactionCase):
def setUp(self):
super(TestXMLTranslation, self).setUp()
self.env['ir.translation'].load_module_terms(['base'], ['fr_FR'])
self.env['ir.translation'].load_module_terms(['base'], ['fr_FR', 'nl_NL'])
def create_view(self, archf, terms, **kwargs):
view = self.env['ir.ui.view'].create({
'name': 'test',
'model': 'res.partner',
'arch': archf % terms,
})
for lang, trans_terms in kwargs.items():
for src, val in pycompat.izip(terms, trans_terms):
self.env['ir.translation'].create({
'type': 'model',
'name': 'ir.ui.view,arch_db',
'lang': lang,
'res_id': view.id,
'src': src,
'value': val,
'state': 'translated',
})
return view
def test_copy(self):
""" Create a simple view, fill in translations, and copy it. """
env_en = self.env(context={})
env_fr = self.env(context={'lang': 'fr_FR'})
archf = '<form string="%s"><div>%s</div><div>%s</div></form>'
terms_en = ('Knife', 'Fork', 'Spoon')
terms_fr = ('Couteau', 'Fourchette', 'Cuiller')
view0 = self.env['ir.ui.view'].create({
'name': 'test',
'model': 'res.partner',
'arch': archf % terms_en,
})
for src, value in list(pycompat.izip(terms_en, terms_fr)):
self.env['ir.translation'].create({
'type': 'model',
'name': 'ir.ui.view,arch_db',
'lang': 'fr_FR',
'res_id': view0.id,
'src': src,
'value': value,
})
view0 = self.create_view(archf, terms_en, fr_FR=terms_fr)
env_en = self.env(context={})
env_fr = self.env(context={'lang': 'fr_FR'})
# check translated field
self.assertEqual(view0.with_env(env_en).arch_db, archf % terms_en)
@@ -360,17 +366,29 @@ class TestXMLTranslation(TransactionCase):
archf = '<form string="%s"><div>%s</div><div>%s</div></form>'
terms_en = ('Knife', 'Fork', 'Spoon')
terms_fr = (' Couteau', 'Fourchette ', ' Cuiller ')
view0 = self.env['ir.ui.view'].create({
'name': 'test',
'model': 'res.partner',
'arch': archf % terms_en,
})
for src, value in list(pycompat.izip(terms_en, terms_fr)):
self.env['ir.translation'].create({
'type': 'model',
'name': 'ir.ui.view,arch_db',
'lang': 'fr_FR',
'res_id': view0.id,
'src': src,
'value': value,
})
self.create_view(archf, terms_en, fr_FR=terms_fr)
def test_sync(self):
""" Check translations after minor change in source terms. """
archf = '<form string="X">%s</form>'
terms_en = ('Bread and cheeze',)
terms_fr = ('Pain et fromage',)
terms_nl = ('Brood and kaas',)
view = self.create_view(archf, terms_en, fr_FR=terms_fr, nl_NL=terms_nl)
env_en = self.env(context={})
env_fr = self.env(context={'lang': 'fr_FR'})
env_nl = self.env(context={'lang': 'nl_NL'})
self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr)
self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl)
# modify source term in view (fixed type in 'cheeze')
terms_en = ('Bread and cheese',)
view.write({'arch_db': archf % terms_en})
# check whether translations have been synchronized
self.assertEqual(view.with_env(env_en).arch_db, archf % terms_en)
self.assertEqual(view.with_env(env_fr).arch_db, archf % terms_fr)
self.assertEqual(view.with_env(env_nl).arch_db, archf % terms_nl)
@@ -1,4 +1,8 @@
# -*- coding: utf-8 -*-
try:
from unittest.mock import patch
except ImportError:
from mock import patch
from odoo.tests import common
@@ -465,3 +469,32 @@ class TestOnChange(common.TransactionCase):
result = Message.onchange(value, ['message', 'message_name', 'message_currency'], field_onchange)
self.assertEqual(result['value'], onchange_result)
def test_onchange_many2one_one2many(self):
""" Setting a many2one field should not read the inverse one2many. """
discussion = self.env.ref('test_new_api.discussion_0')
field_onchange = self.Message._onchange_spec()
self.assertEqual(field_onchange.get('discussion'), '1')
values = {
'discussion': discussion.id,
'name': "[%s] %s" % ('', self.env.user.name),
'body': False,
'author': self.env.uid,
'size': 0,
}
called = [False]
orig_read = type(discussion).read
def mock_read(self, fields=None, load='_classic_read'):
if discussion in self and 'messages' in (fields or ()):
called[0] = True
return orig_read(self, fields, load)
# changing 'discussion' on message should not read 'messages' on discussion
with patch.object(type(discussion), 'read', mock_read, create=True):
self.env.cache.invalidate()
self.Message.onchange(values, 'discussion', field_onchange)
self.assertFalse(called[0], "discussion.messages has been read")
+6
View File
@@ -983,6 +983,12 @@ class Cache(object):
value = self._data[field][record.id].get(key, SpecialValue(None))
return default if isinstance(value, SpecialValue) else value
def get_special(self, record, field, default=None):
""" Return the special value of ``field`` for ``record``. """
key = field.cache_key(record)
value = self._data[field][record.id].get(key)
return value.get if isinstance(value, SpecialValue) else default
def set_special(self, record, field, getter):
""" Set the value of ``field`` for ``record`` to return ``getter()``. """
key = field.cache_key(record)
+27 -12
View File
@@ -2054,6 +2054,26 @@ class Many2one(_Relational):
return super(Many2one, self).convert_to_onchange(value, record, names)
class _RelationalMultiUpdate(object):
""" A getter to update the value of an x2many field, without reading its
value until necessary.
"""
__slots__ = ['record', 'field', 'value']
def __init__(self, record, field, value):
self.record = record
self.field = field
self.value = value
def __call__(self):
# determine the current field's value, and update it in cache only
record, field, value = self.record, self.field, self.value
cache = record.env.cache
cache.remove(record, field)
val = field.convert_to_cache(record[field.name] | value, record, validate=False)
cache.set(record, field, val)
return val
class _RelationalMulti(_Relational):
""" Abstract class for relational fields *2many. """
@@ -2065,21 +2085,16 @@ class _RelationalMulti(_Relational):
""" Update the cached value of ``self`` for ``records`` with ``value``. """
cache = records.env.cache
for record in records:
if cache.contains(record, self):
special = cache.get_special(record, self)
if isinstance(special, _RelationalMultiUpdate):
# include 'value' in the existing _RelationalMultiUpdate; this
# avoids reading the field's value (which may be large)
special.value |= value
elif cache.contains(record, self):
val = self.convert_to_cache(record[self.name] | value, record, validate=False)
cache.set(record, self, val)
else:
cache.set_special(record, self, self._update_getter(record, value))
def _update_getter(self, record, value):
def getter():
# determine the current field's value, and update it in cache only
cache = record.env.cache
cache.remove(record, self)
val = self.convert_to_cache(record[self.name] | value, record, validate=False)
cache.set(record, self, val)
return val
return getter
cache.set_special(record, self, _RelationalMultiUpdate(record, self, value))
def convert_to_cache(self, value, record, validate=True):
# cache format: tuple(ids)