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