[MERGE] forward port branch 11.0 up to 950d9b2369

This commit is contained in:
Christophe Simonis
2018-01-22 17:47:32 +01:00
86 changed files with 1190 additions and 435 deletions
+1 -18
View File
@@ -350,7 +350,7 @@ class AccountInvoice(models.Model):
payment_move_line_ids = fields.Many2many('account.move.line', string='Payment Move Lines', compute='_compute_payments', store=True) payment_move_line_ids = fields.Many2many('account.move.line', string='Payment Move Lines', compute='_compute_payments', store=True)
user_id = fields.Many2one('res.users', string='Salesperson', track_visibility='onchange', user_id = fields.Many2one('res.users', string='Salesperson', track_visibility='onchange',
readonly=True, states={'draft': [('readonly', False)]}, readonly=True, states={'draft': [('readonly', False)]},
default=lambda self: self.env.user) default=lambda self: self.env.user, copy=False)
fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', oldname='fiscal_position', fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', oldname='fiscal_position',
readonly=True, states={'draft': [('readonly', False)]}) readonly=True, states={'draft': [('readonly', False)]})
commercial_partner_id = fields.Many2one('res.partner', string='Commercial Entity', compute_sudo=True, commercial_partner_id = fields.Many2one('res.partner', string='Commercial Entity', compute_sudo=True,
@@ -998,8 +998,6 @@ class AccountInvoice(models.Model):
'invoice_id': self.id, 'invoice_id': self.id,
'analytic_tag_ids': analytic_tag_ids 'analytic_tag_ids': analytic_tag_ids
} }
if line['account_analytic_id']:
move_line_dict['analytic_line_ids'] = [(0, 0, line._get_analytic_line())]
res.append(move_line_dict) res.append(move_line_dict)
return res return res
@@ -1445,21 +1443,6 @@ class AccountInvoiceLine(models.Model):
_description = "Invoice Line" _description = "Invoice Line"
_order = "invoice_id,sequence,id" _order = "invoice_id,sequence,id"
@api.multi
def _get_analytic_line(self):
ref = self.invoice_id.number
return {
'name': self.name,
'date': self.invoice_id.date_invoice,
'account_id': self.account_analytic_id.id,
'unit_amount': self.quantity,
'amount': self.price_subtotal_signed,
'product_id': self.product_id.id,
'product_uom_id': self.uom_id.id,
'general_account_id': self.account_id.id,
'ref': ref,
}
@api.one @api.one
@api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity', @api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity',
'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id', 'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id',
+1 -2
View File
@@ -174,7 +174,6 @@ Best Regards,'''))
def setting_init_fiscal_year_action(self): def setting_init_fiscal_year_action(self):
""" Called by the 'Fiscal Year Opening' button of the setup bar.""" """ Called by the 'Fiscal Year Opening' button of the setup bar."""
company = self.env.user.company_id company = self.env.user.company_id
company.create_op_move_if_non_existant()
new_wizard = self.env['account.financial.year.op'].create({'company_id': company.id}) new_wizard = self.env['account.financial.year.op'].create({'company_id': company.id})
view_id = self.env.ref('account.setup_financial_year_opening_form').id view_id = self.env.ref('account.setup_financial_year_opening_form').id
@@ -265,7 +264,7 @@ Best Regards,'''))
default_journal = self.env['account.journal'].search([('type', '=', 'general'), ('company_id', '=', self.id)], limit=1) default_journal = self.env['account.journal'].search([('type', '=', 'general'), ('company_id', '=', self.id)], limit=1)
if not default_journal: if not default_journal:
raise UserError(_("No miscellaneous journal could be found. Please create one before proceeding.")) raise UserError(_("Please install a chart of accounts or create a miscellaneous journal before proceeding."))
self.account_opening_move_id = self.env['account.move'].create({ self.account_opening_move_id = self.env['account.move'].create({
'name': _('Opening Journal Entry'), 'name': _('Opening Journal Entry'),
@@ -179,7 +179,7 @@
</td> </td>
</tr> </tr>
<tr t-foreach="range(max(5-len(invoice.invoice_line_ids),0))" t-as="l"> <tr t-foreach="range(max(5-len(invoice.invoice_line_ids),0))" t-as="l">
<td>&amp;nbsp;</td> <td t-translation="off">&amp;nbsp;</td>
<td class="hidden"></td> <td class="hidden"></td>
<td></td> <td></td>
<td></td> <td></td>
+7 -7
View File
@@ -88,7 +88,7 @@
</td> </td>
</tr> </tr>
<tr t-foreach="range(max(5-len(o.invoice_line_ids),0))" t-as="l"> <tr t-foreach="range(max(5-len(o.invoice_line_ids),0))" t-as="l">
<td>&amp;nbsp;</td> <td t-translation="off">&amp;nbsp;</td>
<td class="hidden"></td> <td class="hidden"></td>
<td></td> <td></td>
<td></td> <td></td>
@@ -112,18 +112,18 @@
<t t-foreach="o._get_tax_amount_by_group()" t-as="amount_by_group"> <t t-foreach="o._get_tax_amount_by_group()" t-as="amount_by_group">
<tr style="border-bottom:1px solid #dddddd;"> <tr style="border-bottom:1px solid #dddddd;">
<t t-if="len(o.tax_line_ids) == 1 and o.amount_untaxed == amount_by_group[2]"> <t t-if="len(o.tax_line_ids) == 1 and o.amount_untaxed == amount_by_group[2]">
<td><span t-esc="amount_by_group[0]"/></td>
<td class="text-right">
<span t-esc="amount_by_group[3]"/>
</td>
</t>
<t t-else="">
<td> <td>
<span t-esc="amount_by_group[0]"/> <span t-esc="amount_by_group[0]"/>
<span>&amp;nbsp;<span>on</span> <span>&amp;nbsp;<span>on</span>
<t t-esc="amount_by_group[4]"/> <t t-esc="amount_by_group[4]"/>
</span> </span>
</td> </td>
<td class="text-right">
<span t-esc="amount_by_group[3]"/>
</td>
</t>
<t t-else="">
<td><span t-esc="amount_by_group[0]"/></td>
<td class="text-right"> <td class="text-right">
<span t-esc="amount_by_group[3]" /> <span t-esc="amount_by_group[3]" />
</td> </td>
@@ -168,6 +168,10 @@
<label for="currency_exchange_journal_id" class="col-md-3 o_light_label"/> <label for="currency_exchange_journal_id" class="col-md-3 o_light_label"/>
<field name="currency_exchange_journal_id"/> <field name="currency_exchange_journal_id"/>
</div> </div>
<div class="row mt16">
<label for="currency_exchange_journal_id" class="col-md-3 o_light_label"/>
<field name="currency_exchange_journal_id"/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -402,5 +402,57 @@ QUnit.test('non-existing action in a dashboard', function (assert) {
form.destroy(); form.destroy();
}); });
QUnit.test('clicking on a kanban\'s button should trigger the action', function (assert) {
assert.expect(2);
var form = createView({
View: FormView,
model: 'board',
data: this.data,
arch: '<form string="My Dashboard">' +
'<board style="2-1">' +
'<column>' +
'<action name="149" string="Partner" view_mode="kanban" id="action_0_1"></action>' +
'</column>' +
'</board>' +
'</form>',
archs: {
'partner,false,kanban':
'<kanban class="o_kanban_test"><templates><t t-name="kanban-box">' +
'<div>' +
'<field name="foo"/>' +
'</div>' +
'<div><button name="sitting_on_a_park_bench" type="object">Eying little girls with bad intent</button>' +
'</div>' +
'</t></templates></kanban>',
},
intercepts: {
execute_action: function (event) {
var data = event.data;
assert.strictEqual(data.env.model, 'partner', "should have correct model");
assert.strictEqual(data.action_data.name, 'sitting_on_a_park_bench',
"should call correct method");
}
},
mockRPC: function (route) {
if (route === '/board/static/src/img/layout_1-1-1.png') {
return $.when();
}
if (route === '/web/action/load') {
return $.when({res_model: 'partner', view_mode: 'kanban', views: [[false, 'kanban']]});
}
if (route === '/web/dataset/search_read') {
return $.when({records: [{foo: 'aqualung'}]});
}
return this._super.apply(this, arguments);
}
});
form.$('.o_kanban_test').find('button:first').click();
form.destroy();
});
}); });
+3 -3
View File
@@ -122,10 +122,10 @@
<field name="stop" attrs="{'invisible': True}"/> <field name="stop" attrs="{'invisible': True}"/>
<field name="id" attrs="{'invisible': True}"/> <field name="id" attrs="{'invisible': True}"/>
<field name="start_date" string="Starting at" attrs="{'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> <field name="start_date" string="Starting at" attrs="{'required': [('allday','=',True)], 'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/>
<field name="stop_date" string="Ending at" attrs="{'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> <field name="stop_date" string="Ending at" attrs="{'required': [('allday','=',True)],'invisible': [('allday','=',False)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/>
<field name="start_datetime" string="Starting at" attrs="{'invisible': [('allday','=',True)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/> <field name="start_datetime" string="Starting at" attrs="{'required': [('allday','=',False)], 'invisible': [('allday','=',True)], 'readonly': [('id', '!=', False), ('recurrency','=',True)]}"/>
<field name="stop_datetime" invisible="1"/> <field name="stop_datetime" invisible="1"/>
<label for="duration" attrs="{'invisible': [('allday','=',True)]}"/> <label for="duration" attrs="{'invisible': [('allday','=',True)]}"/>
<div attrs="{'invisible': [('allday','=',True)]}"> <div attrs="{'invisible': [('allday','=',True)]}">
@@ -9,6 +9,7 @@ import werkzeug.urls
from odoo import api, fields, models from odoo import api, fields, models
from odoo.exceptions import RedirectWarning, UserError from odoo.exceptions import RedirectWarning, UserError
from odoo.tools import pycompat
from odoo.tools.safe_eval import safe_eval from odoo.tools.safe_eval import safe_eval
from odoo.tools.translate import _ from odoo.tools.translate import _
@@ -153,6 +154,9 @@ class GoogleDrive(models.Model):
a length of 1 element only (batch processing is not supported in the code, though nothing really prevent it) a length of 1 element only (batch processing is not supported in the code, though nothing really prevent it)
:return: the config id and config name :return: the config id and config name
''' '''
# TO DO in master: fix my signature and my model
if isinstance(res_model, pycompat.string_types):
res_model = self.env['ir.model'].search([('model', '=', res_model)]).id
if not res_id: if not res_id:
raise UserError(_("Creating google drive may only be done by one at a time.")) raise UserError(_("Creating google drive may only be done by one at a time."))
# check if a model is configured with a template # check if a model is configured with a template
+86 -55
View File
@@ -1,78 +1,109 @@
odoo.define('google_drive.google_drive', function (require) { odoo.define('google_drive.sidebar', function (require) {
"use strict"; "use strict";
var data = require('web.data'); /**
* The purpose of this file is to include the Sidebar widget to add Google
* Drive related items.
*/
var Sidebar = require('web.Sidebar'); var Sidebar = require('web.Sidebar');
Sidebar.include({ Sidebar.include({
init: function () { // TO DO: clean me in master
var self = this; /**
var ids; * @override
this._super.apply(this, arguments); */
var view = self.getParent(); start: function () {
var result; var def;
if (view.fields_view && view.fields_view.type === "form") { if (this.options.viewType === "form") {
ids = []; def = this._addGoogleDocItems(this.env.model, this.env.activeIds[0]);
view.on("load_record", self, function (r) {
ids = [r.id];
self.add_gdoc_items(view, r.id);
});
} }
return $.when(def).then(this._super.bind(this));
}, },
add_gdoc_items: function (view, res_id) {
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {string} model
* @param {integer} resID
* @returns {Deferred}
*/
_addGoogleDocItems: function (model, resID) {
var self = this; var self = this;
if (!resID) {
return $.when();
}
var gdoc_item = _.indexOf(_.pluck(self.items.other, 'classname'), 'oe_share_gdoc'); var gdoc_item = _.indexOf(_.pluck(self.items.other, 'classname'), 'oe_share_gdoc');
if (gdoc_item !== -1) { if (gdoc_item !== -1) {
self.items.other.splice(gdoc_item, 1); self.items.other.splice(gdoc_item, 1);
} }
if (res_id) { return this._rpc({
view.sidebar_eval_context().done(function (context) { args: [this.env.model, resID],
var ds = new data.DataSet(this, 'google.drive.config', context); context: this.env.context,
ds.call('get_google_drive_config', [view.dataset.model, res_id, context]).done(function (r) { method: 'get_google_drive_config',
if (!_.isEmpty(r)) { model: 'google.drive.config',
_.each(r, function (res) { }).then(function (r) {
var already_there = false; if (!_.isEmpty(r)) {
for (var i = 0;i < self.items.other.length;i++){ _.each(r, function (res) {
if (self.items.other[i].classname === "oe_share_gdoc" && self.items.other[i].label.indexOf(res.name) > -1){ var already_there = false;
already_there = true; for (var i = 0; i < self.items.other.length; i++) {
break; var item = self.items.other[i];
} if (item.classname === 'oe_share_gdoc' && item.label.indexOf(res.name) > -1) {
} already_there = true;
if (!already_there){ break;
self.add_items('other', [{ }
label: res.name, }
config_id: res.id, if (!already_there) {
res_id: res_id, self._addItems('other', [{
res_model: view.dataset.model, callback: self._onGoogleDocItemClicked.bind(self, res.id, resID),
callback: self.on_google_doc, classname: 'oe_share_gdoc',
classname: 'oe_share_gdoc' config_id: res.id,
}, label: res.name,
]); res_id: resID,
} res_model: model,
}); }]);
} }
}); });
}); }
} });
}, },
on_google_doc: function (doc_item) {
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {integer} configID
* @param {integer} resID
*/
_onGoogleDocItemClicked: function (configID, resID) {
var self = this; var self = this;
var domain = [['id', '=', doc_item.config_id]]; var domain = [['id', '=', configID]];
var fields = ['google_drive_resource_id', 'google_drive_client_id']; var fields = ['google_drive_resource_id', 'google_drive_client_id'];
this._rpc({ this._rpc({
args: [domain, fields],
method: 'search_read',
model: 'google.drive.config',
}).then(function (configs) {
self._rpc({
args: [configID, resID, configs[0].google_drive_resource_id],
context: self.env.context,
method: 'get_google_drive_url',
model: 'google.drive.config', model: 'google.drive.config',
method: 'search_read', }).then(function (url) {
args: [domain, fields], if (url){
}) window.open(url, '_blank');
.then(function (configs) { }
var ds = new data.DataSet(self, 'google.drive.config');
ds.call('get_google_drive_url', [doc_item.config_id, doc_item.res_id,configs[0].google_drive_resource_id, self.dataset.context]).done(function (url) {
if (url){
window.open(url, '_blank');
}
});
}); });
});
}, },
});
}); });
return Sidebar;
});
@@ -0,0 +1,112 @@
odoo.define('google_drive.gdrive_integration', function (require) {
"use strict";
//rebuild
var FormView = require('web.FormView');
var testUtils = require('web.test_utils');
var GoogleDriveSideBar = require('google_drive.sidebar');
var createView = testUtils.createView;
/*
* @override
* Avoid breaking other tests because of the new route
* that the module introduces
*/
var _addGoogleDocItemsOriginal = GoogleDriveSideBar.prototype._addGoogleDocItems;
var _addGoogleDocItemsMocked = function (model, resID) {
return $.when();
};
GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsMocked;
QUnit.module('gdrive_integration', {
beforeEach: function () {
// For our test to work, the _addGoogleDocItems function needs to be the original
GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsOriginal;
this.data = {
partner: {
fields: {
display_name: {string: "Displayed name", type: "char", searchable: true},
},
records: [{
id: 1,
display_name: "Locomotive Breath",
}],
},
'google.drive.config': {
fields: {
model_id: {string: 'Model', type: 'int'},
name: {string: 'Name', type: 'char'},
google_drive_resource_id: {string: 'Resource ID', type: 'char'},
},
records: [{
id: 27,
name: 'Cyberdyne Systems',
model_id: 1,
google_drive_resource_id: 'T1000',
}],
},
'ir.attachment': {
fields: {
name: {string: 'Name', type:'char'}
},
records: [],
}
};
},
afterEach: function() {
GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsMocked;
}
}, function () {
QUnit.module('Google Drive Sidebar');
QUnit.test('rendering of the google drive attachments in Sidebar', function (assert) {
assert.expect(3);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch: '<form string="Partners">' +
'<field name="display_name"/>' +
'</form>',
res_id: 1,
viewOptions: {sidebar: true},
mockRPC: function (route, args) {
if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_config') {
assert.deepEqual(args.args, ['partner', 1],
'The route to get google drive config should have been called');
return $.when([{id: 27, name: 'Cyberdyne Systems'}]);
}
if (route === '/web/dataset/call_kw/google.drive.config/search_read'){
return $.when([{google_drive_resource_id: "T1000",
google_drive_client_id: "cyberdyne.org",
id: 1}]);
}
if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_url') {
assert.deepEqual(args.args, [27, 1, 'T1000'],
'The route to get the Google url should have been called');
// We don't return anything useful, otherwise it will open a new tab
return $.when();
}
return this._super.apply(this, arguments);
}
});
var google_action = form.sidebar.$('.oe_share_gdoc');
assert.strictEqual(google_action.length, 1,
'The button to the google action should be present');
// Trigger opening of the dynamic link
google_action.find('a:first').click();
form.destroy();
});
});
});
@@ -8,4 +8,9 @@
</xpath> </xpath>
</template> </template>
<template id="qunit_suite" name="google_drive tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/google_drive/static/tests/gdrive_test.js"></script>
</xpath>
</template>
</odoo> </odoo>
+3 -3
View File
@@ -27,13 +27,13 @@
<field eval="25" name="priority"/> <field eval="25" name="priority"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="Expenses" class="o_expense_form"> <form string="Expenses" class="o_expense_form">
<header> <header invisible="context.get('expense_adding_line')">
<button name="submit_expenses" states="draft" string="Submit to Manager" type="object" class="oe_highlight o_expense_submit"/> <button name="submit_expenses" states="draft" string="Submit to Manager" type="object" class="oe_highlight o_expense_submit"/>
<field name="state" widget="statusbar" statusbar_visible="draft,reported,done"/> <field name="state" widget="statusbar" statusbar_visible="draft,reported,done"/>
<button name="view_sheet" type="object" string="View Report" class="oe_highlight" attrs="{'invisible': [('state', '=','draft')]}"/> <button name="view_sheet" type="object" string="View Report" class="oe_highlight" attrs="{'invisible': [('state', '=','draft')]}"/>
</header> </header>
<sheet> <sheet>
<div class="oe_button_box"> <div class="oe_button_box" invisible="context.get('expense_adding_line')">
<button name="action_get_attachment_view" <button name="action_get_attachment_view"
class="oe_stat_button" class="oe_stat_button"
icon="fa-book" icon="fa-book"
@@ -419,7 +419,7 @@
</group> </group>
<notebook> <notebook>
<page string="Expense Lines"> <page string="Expense Lines">
<field name="expense_line_ids" nolabel="1" widget="many2many" domain="[('state', '=', 'draft')]" options="{'not_delete': True, 'reload_on_button': True, 'no_create': True}"> <field name="expense_line_ids" nolabel="1" widget="many2many" domain="[('state', '=', 'draft')]" options="{'not_delete': True, 'reload_on_button': True, 'no_create': True}" context="{'expense_adding_line': True}">
<tree decoration-danger="is_refused"> <tree decoration-danger="is_refused">
<field name="date"/> <field name="date"/>
<field name="name"/> <field name="name"/>
@@ -31,4 +31,4 @@ class HolidaysSummaryDept(models.TransientModel):
'model': 'hr.department', 'model': 'hr.department',
'form': data 'form': data
} }
return self.env.ref('hr_holidays.action_report_holidayssummary').report_action(departments, data=datas) return self.env.ref('hr_holidays.action_report_holidayssummary').with_context(from_transient_model=True).report_action(departments, data=datas)
+1
View File
@@ -102,6 +102,7 @@ class HrPayslip(models.Model):
def refund_sheet(self): def refund_sheet(self):
for payslip in self: for payslip in self:
copied_payslip = payslip.copy({'credit_note': True, 'name': _('Refund: ') + payslip.name}) copied_payslip = payslip.copy({'credit_note': True, 'name': _('Refund: ') + payslip.name})
copied_payslip.compute_sheet()
copied_payslip.action_payslip_done() copied_payslip.action_payslip_done()
formview_ref = self.env.ref('hr_payroll.view_hr_payslip_form', False) formview_ref = self.env.ref('hr_payroll.view_hr_payslip_form', False)
treeview_ref = self.env.ref('hr_payroll.view_hr_payslip_tree', False) treeview_ref = self.env.ref('hr_payroll.view_hr_payslip_tree', False)
+15
View File
@@ -59,3 +59,18 @@ class Task(models.Model):
vals['parent_id'] = context.pop('default_parent_id', None) vals['parent_id'] = context.pop('default_parent_id', None)
task = super(Task, self.with_context(context)).create(vals) task = super(Task, self.with_context(context)).create(vals)
return task return task
@api.multi
def write(self, values):
result = super(Task, self).write(values)
# reassign project_id on related timesheet lines
if 'project_id' in values:
project_id = values.get('project_id')
# a timesheet must have an analytic account (and a project)
if not project_id:
raise UserError(_('This task must have a project since they are linked to timesheets.'))
self.sudo().mapped('timesheet_ids').write({
'project_id': project_id,
'account_id': self.env['project.project'].browse(project_id).sudo().analytic_account_id.id
})
return result
@@ -48,7 +48,7 @@
<field name="perm_create" eval="1"/> <field name="perm_create" eval="1"/>
<field name="perm_write" eval="1"/> <field name="perm_write" eval="1"/>
<field name="perm_unlink" eval="1"/> <field name="perm_unlink" eval="1"/>
<field name="perm_read" eval="1"/> <field name="perm_read" eval="0"/>
</record> </record>
<record id="timesheet_rule_portal" model="ir.rule"> <record id="timesheet_rule_portal" model="ir.rule">
+45 -3
View File
@@ -2,13 +2,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details. # Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests.common import TransactionCase from odoo.tests.common import TransactionCase
from odoo.exceptions import AccessError from odoo.exceptions import AccessError, UserError
class TestTimesheet(TransactionCase): class TestCommonTimesheet(TransactionCase):
def setUp(self): def setUp(self):
super(TestTimesheet, self).setUp() super(TestCommonTimesheet, self).setUp()
# customer partner # customer partner
self.partner = self.env['res.partner'].create({ self.partner = self.env['res.partner'].create({
@@ -69,6 +69,9 @@ class TestTimesheet(TransactionCase):
'user_id': self.user_manager.id, 'user_id': self.user_manager.id,
}) })
class TestTimesheet(TestCommonTimesheet):
def test_log_timesheet(self): def test_log_timesheet(self):
""" Test when log timesheet : check analytic account, user and employee are correctly set. """ """ Test when log timesheet : check analytic account, user and employee are correctly set. """
Timesheet = self.env['account.analytic.line'] Timesheet = self.env['account.analytic.line']
@@ -134,3 +137,42 @@ class TestTimesheet(TransactionCase):
'employee_id': self.empl_employee2.id, 'employee_id': self.empl_employee2.id,
}) })
self.assertEquals(timesheet1.user_id, self.user_employee2, 'Changing timesheet employee should change the related user') self.assertEquals(timesheet1.user_id, self.user_employee2, 'Changing timesheet employee should change the related user')
def test_transfert_project(self):
""" Test transfert task with timesheet to another project """
Timesheet = self.env['account.analytic.line']
# create a second project
self.project_customer2 = self.env['project.project'].create({
'name': 'Project NUMBER DEUX',
'allow_timesheets': True,
})
# employee 1 log some timesheet on task 1
Timesheet.create({
'project_id': self.project_customer.id,
'task_id': self.task1.id,
'name': 'my first timesheet',
'unit_amount': 4,
})
timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_customer.id)])
timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_customer2.id)])
self.assertEquals(timesheet_count1, 1, "One timesheet in project 1")
self.assertEquals(timesheet_count2, 0, "No timesheet in project 2")
self.assertEquals(len(self.task1.timesheet_ids), 1, "The timesheet should be linked to task 1")
# change project of task 1
self.task1.write({
'project_id': self.project_customer2.id
})
timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_customer.id)])
timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_customer2.id)])
self.assertEquals(timesheet_count1, 0, "No timesheet in project 1")
self.assertEquals(timesheet_count2, 1, "One timesheet in project 2")
self.assertEquals(len(self.task1.timesheet_ids), 1, "The timesheet should be linked to task 1")
# it is forbidden to set a task with timesheet without project
with self.assertRaises(UserError):
self.task1.write({
'project_id': False
})
+9 -1
View File
@@ -72,6 +72,11 @@ def jsonrpc(url, method='call', params=None):
#---------------------------------------------------------- #----------------------------------------------------------
# Helpers for proxy # Helpers for proxy
#---------------------------------------------------------- #----------------------------------------------------------
class IapTransaction(object):
def __init__(self):
self.credit = None
@contextlib.contextmanager @contextlib.contextmanager
def charge(env, key, account_token, credit, description=None, credit_template=None): def charge(env, key, account_token, credit, description=None, credit_template=None):
""" """
@@ -107,7 +112,9 @@ def charge(env, key, account_token, credit, description=None, credit_template=No
e.args = (json.dumps(arguments),) e.args = (json.dumps(arguments),)
raise e raise e
try: try:
yield transaction = IapTransaction()
transaction.credit = credit
yield transaction
except Exception as e: except Exception as e:
params = { params = {
'token': transaction_token, 'token': transaction_token,
@@ -119,6 +126,7 @@ def charge(env, key, account_token, credit, description=None, credit_template=No
params = { params = {
'token': transaction_token, 'token': transaction_token,
'key': key, 'key': key,
'credit_to_capture': transaction.credit,
} }
r = jsonrpc(endpoint + '/iap/1/capture', params=params) # noqa r = jsonrpc(endpoint + '/iap/1/capture', params=params) # noqa
@@ -67,7 +67,7 @@ class HrContract(models.Model):
help="Number of days of paid leaves the employee gets per year.") help="Number of days of paid leaves the employee gets per year.")
holidays_editable = fields.Boolean(string="Editable Leaves", default=True) holidays_editable = fields.Boolean(string="Editable Leaves", default=True)
holidays_compensation = fields.Monetary(compute='_compute_holidays_compensation', string="Holidays Compensation") holidays_compensation = fields.Monetary(compute='_compute_holidays_compensation', string="Holidays Compensation")
wage_with_holidays = fields.Monetary(compute='_compute_wage_with_holidays', string="Wage update with holidays retenues") wage_with_holidays = fields.Monetary(compute='_compute_wage_with_holidays', inverse='_inverse_wage_with_holidays', string="Wage update with holidays retenues")
additional_net_amount = fields.Monetary(string="Net Supplements", additional_net_amount = fields.Monetary(string="Net Supplements",
help="Monthly net amount the employee receives.") help="Monthly net amount the employee receives.")
retained_net_amount = fields.Monetary(sting="Net Retained", retained_net_amount = fields.Monetary(sting="Net Retained",
@@ -76,7 +76,7 @@ class HrContract(models.Model):
default=lambda self: self.get_attribute('eco_checks', 'default_value'), default=lambda self: self.get_attribute('eco_checks', 'default_value'),
help="Yearly amount the employee receives in the form of eco vouchers.") help="Yearly amount the employee receives in the form of eco vouchers.")
@api.depends('holidays', 'wage') @api.depends('holidays', 'wage', 'final_yearly_costs')
def _compute_wage_with_holidays(self): def _compute_wage_with_holidays(self):
for contract in self: for contract in self:
if contract.holidays > 20.0: if contract.holidays > 20.0:
@@ -85,6 +85,23 @@ class HrContract(models.Model):
else: else:
contract.wage_with_holidays = contract.wage contract.wage_with_holidays = contract.wage
def _inverse_wage_with_holidays(self):
for contract in self:
if contract.holidays > 20.0:
remaining_for_gross = contract.wage_with_holidays * (13.0 + 13.0 * 0.3507 + 0.92)
yearly_cost = remaining_for_gross \
+ 12.0 * contract.representation_fees \
+ 12.0 * contract.fuel_card \
+ 12.0 * contract.internet \
+ 12.0 * (contract.mobile + contract.mobile_plus) \
+ 12.0 * contract.transport_employer_cost \
+ (1.326 / 1.05 * 12.0) * contract.commission_on_target \
+ 220.0 * contract.meal_voucher_paid_by_employer
contract.final_yearly_costs = yearly_cost / (1.0 - (contract.holidays - 20.0) / 231.0)
contract.wage = contract._get_gross_from_employer_costs(contract.final_yearly_costs)
else:
contract.wage = contract.wage_with_holidays
@api.depends('transport_mode', 'company_car_total_depreciated_cost', @api.depends('transport_mode', 'company_car_total_depreciated_cost',
'public_transport_reimbursed_amount', 'others_reimbursed_amount') 'public_transport_reimbursed_amount', 'others_reimbursed_amount')
def _compute_transport_employer_cost(self): def _compute_transport_employer_cost(self):
@@ -36,13 +36,13 @@ class AccountMove(models.Model):
_('An error occured when computing the inalterability. Impossible to get the unique previous posted journal entry.')) _('An error occured when computing the inalterability. Impossible to get the unique previous posted journal entry.'))
#build and return the hash #build and return the hash
return self._compute_hash(prev_move.l10n_fr_hash if prev_move else '') return self._compute_hash(prev_move.l10n_fr_hash if prev_move else u'')
def _compute_hash(self, previous_hash): def _compute_hash(self, previous_hash):
""" Computes the hash of the browse_record given as self, based on the hash """ Computes the hash of the browse_record given as self, based on the hash
of the previous record in the company's securisation sequence given as parameter""" of the previous record in the company's securisation sequence given as parameter"""
self.ensure_one() self.ensure_one()
hash_string = sha256(previous_hash + self.l10n_fr_string_to_hash) hash_string = sha256((previous_hash + self.l10n_fr_string_to_hash).encode('utf-8'))
return hash_string.hexdigest() return hash_string.hexdigest()
def _compute_string_to_hash(self): def _compute_string_to_hash(self):
@@ -63,7 +63,7 @@ class AccountMove(models.Model):
values[k] = _getattrstring(line, field) values[k] = _getattrstring(line, field)
#make the json serialization canonical #make the json serialization canonical
# (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00) # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00)
move.l10n_fr_string_to_hash = dumps(values, sort_keys=True, encoding="utf-8", move.l10n_fr_string_to_hash = dumps(values, sort_keys=True,
ensure_ascii=True, indent=None, ensure_ascii=True, indent=None,
separators=(',',':')) separators=(',',':'))
@@ -118,7 +118,7 @@ class AccountMove(models.Model):
if not moves: if not moves:
raise UserError(_('There isn\'t any journal entry flagged for data inalterability yet for the company %s. This mechanism only runs for journal entries generated after the installation of the module France - Certification CGI 286 I-3 bis.') % self.env.user.company_id.name) raise UserError(_('There isn\'t any journal entry flagged for data inalterability yet for the company %s. This mechanism only runs for journal entries generated after the installation of the module France - Certification CGI 286 I-3 bis.') % self.env.user.company_id.name)
previous_hash = '' previous_hash = u''
start_move_info = [] start_move_info = []
for move in moves: for move in moves:
if move.l10n_fr_hash != move._compute_hash(previous_hash=previous_hash): if move.l10n_fr_hash != move._compute_hash(previous_hash=previous_hash):
+4 -4
View File
@@ -79,13 +79,13 @@ class pos_order(models.Model):
_('An error occured when computing the inalterability. Impossible to get the unique previous posted point of sale order.')) _('An error occured when computing the inalterability. Impossible to get the unique previous posted point of sale order.'))
#build and return the hash #build and return the hash
return self._compute_hash(prev_order.l10n_fr_hash if prev_order else '') return self._compute_hash(prev_order.l10n_fr_hash if prev_order else u'')
def _compute_hash(self, previous_hash): def _compute_hash(self, previous_hash):
""" Computes the hash of the browse_record given as self, based on the hash """ Computes the hash of the browse_record given as self, based on the hash
of the previous record in the company's securisation sequence given as parameter""" of the previous record in the company's securisation sequence given as parameter"""
self.ensure_one() self.ensure_one()
hash_string = sha256(previous_hash + self.l10n_fr_string_to_hash) hash_string = sha256((previous_hash + self.l10n_fr_string_to_hash).encode('utf-8'))
return hash_string.hexdigest() return hash_string.hexdigest()
def _compute_string_to_hash(self): def _compute_string_to_hash(self):
@@ -108,7 +108,7 @@ class pos_order(models.Model):
values[k] = _getattrstring(line, field) values[k] = _getattrstring(line, field)
#make the json serialization canonical #make the json serialization canonical
# (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00) # (https://tools.ietf.org/html/draft-staykov-hu-json-canonical-form-00)
order.l10n_fr_string_to_hash = dumps(values, sort_keys=True, encoding="utf-8", order.l10n_fr_string_to_hash = dumps(values, sort_keys=True,
ensure_ascii=True, indent=None, ensure_ascii=True, indent=None,
separators=(',',':')) separators=(',',':'))
@@ -155,7 +155,7 @@ class pos_order(models.Model):
if not orders: if not orders:
raise UserError(_('There isn\'t any order flagged for data inalterability yet for the company %s. This mechanism only runs for point of sale orders generated after the installation of the module France - Certification CGI 286 I-3 bis. - POS') % self.env.user.company_id.name) raise UserError(_('There isn\'t any order flagged for data inalterability yet for the company %s. This mechanism only runs for point of sale orders generated after the installation of the module France - Certification CGI 286 I-3 bis. - POS') % self.env.user.company_id.name)
previous_hash = '' previous_hash = u''
start_order_info = [] start_order_info = []
for order in orders: for order in orders:
if order.l10n_fr_hash != order._compute_hash(previous_hash=previous_hash): if order.l10n_fr_hash != order._compute_hash(previous_hash=previous_hash):
+21 -9
View File
@@ -148,7 +148,7 @@ class Message(models.Model):
given, restrict to messages written in one of those channels. """ given, restrict to messages written in one of those channels. """
partner_id = self.env.user.partner_id.id partner_id = self.env.user.partner_id.id
delete_mode = not self.env.user.share # delete employee notifs, keep customer ones delete_mode = not self.env.user.share # delete employee notifs, keep customer ones
if domain is None and delete_mode: if not domain and delete_mode:
query = "DELETE FROM mail_message_res_partner_needaction_rel WHERE res_partner_id IN %s" query = "DELETE FROM mail_message_res_partner_needaction_rel WHERE res_partner_id IN %s"
args = [(partner_id,)] args = [(partner_id,)]
if channel_ids: if channel_ids:
@@ -468,6 +468,7 @@ class Message(models.Model):
- if author_id == pid, uid is the author, OR - if author_id == pid, uid is the author, OR
- uid belongs to a notified channel, OR - uid belongs to a notified channel, OR
- uid is in the specified recipients, OR - uid is in the specified recipients, OR
- uid has a notification on the message, OR
- uid have read access to the related document is model, res_id - uid have read access to the related document is model, res_id
- otherwise: remove the id - otherwise: remove the id
""" """
@@ -495,17 +496,22 @@ class Message(models.Model):
# check read access rights before checking the actual rules on the given ids # check read access rights before checking the actual rules on the given ids
super(Message, self.sudo(access_rights_uid or self._uid)).check_access_rights('read') super(Message, self.sudo(access_rights_uid or self._uid)).check_access_rights('read')
self._cr.execute("""SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, partner_rel.res_partner_id, channel_partner.channel_id as channel_id self._cr.execute("""
SELECT DISTINCT m.id, m.model, m.res_id, m.author_id,
COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id),
channel_partner.channel_id as channel_id
FROM "%s" m FROM "%s" m
LEFT JOIN "mail_message_res_partner_rel" partner_rel LEFT JOIN "mail_message_res_partner_rel" partner_rel
ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = (%%s) ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s
LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel
ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s
LEFT JOIN "mail_message_mail_channel_rel" channel_rel LEFT JOIN "mail_message_mail_channel_rel" channel_rel
ON channel_rel.mail_message_id = m.id ON channel_rel.mail_message_id = m.id
LEFT JOIN "mail_channel" channel LEFT JOIN "mail_channel" channel
ON channel.id = channel_rel.mail_channel_id ON channel.id = channel_rel.mail_channel_id
LEFT JOIN "mail_channel_partner" channel_partner LEFT JOIN "mail_channel_partner" channel_partner
ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s) ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s
WHERE m.id = ANY (%%s)""" % self._table, (pid, pid, ids,)) WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=pid, ids=ids))
for id, rmod, rid, author_id, partner_id, channel_id in self._cr.fetchall(): for id, rmod, rid, author_id, partner_id, channel_id in self._cr.fetchall():
if author_id == pid: if author_id == pid:
author_ids.add(id) author_ids.add(id)
@@ -533,6 +539,7 @@ class Message(models.Model):
- read: if - read: if
- author_id == pid, uid is the author OR - author_id == pid, uid is the author OR
- uid is in the recipients (partner_ids) OR - uid is in the recipients (partner_ids) OR
- uid has been notified (needaction) OR
- uid is member of a listern channel (channel_ids.partner_ids) OR - uid is member of a listern channel (channel_ids.partner_ids) OR
- uid have read access to the related document if model, res_id - uid have read access to the related document if model, res_id
- otherwise: raise - otherwise: raise
@@ -583,17 +590,22 @@ class Message(models.Model):
message_values = dict((res_id, {}) for res_id in self.ids) message_values = dict((res_id, {}) for res_id in self.ids)
if operation in ['read', 'write']: if operation in ['read', 'write']:
self._cr.execute("""SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, partner_rel.res_partner_id, channel_partner.channel_id as channel_id self._cr.execute("""
SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id,
COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id),
channel_partner.channel_id as channel_id
FROM "%s" m FROM "%s" m
LEFT JOIN "mail_message_res_partner_rel" partner_rel LEFT JOIN "mail_message_res_partner_rel" partner_rel
ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = (%%s) ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s
LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel
ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s
LEFT JOIN "mail_message_mail_channel_rel" channel_rel LEFT JOIN "mail_message_mail_channel_rel" channel_rel
ON channel_rel.mail_message_id = m.id ON channel_rel.mail_message_id = m.id
LEFT JOIN "mail_channel" channel LEFT JOIN "mail_channel" channel
ON channel.id = channel_rel.mail_channel_id ON channel.id = channel_rel.mail_channel_id
LEFT JOIN "mail_channel_partner" channel_partner LEFT JOIN "mail_channel_partner" channel_partner
ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s) ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s
WHERE m.id = ANY (%%s)""" % self._table, (self.env.user.partner_id.id, self.env.user.partner_id.id, self.ids,)) WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=self.env.user.partner_id.id, ids=self.ids))
for mid, rmod, rid, author_id, parent_id, partner_id, channel_id in self._cr.fetchall(): for mid, rmod, rid, author_id, parent_id, partner_id, channel_id in self._cr.fetchall():
message_values[mid] = { message_values[mid] = {
'model': rmod, 'model': rmod,
+5 -3
View File
@@ -125,7 +125,7 @@ class Users(models.Model):
@api.model @api.model
def activity_user_count(self): def activity_user_count(self):
query = """SELECT m.name, count(*), act.res_model as model, query = """SELECT m.id, count(*), act.res_model as model,
CASE CASE
WHEN now()::date - act.date_deadline::date = 0 Then 'today' WHEN now()::date - act.date_deadline::date = 0 Then 'today'
WHEN now()::date - act.date_deadline::date > 0 Then 'overdue' WHEN now()::date - act.date_deadline::date > 0 Then 'overdue'
@@ -134,16 +134,18 @@ class Users(models.Model):
FROM mail_activity AS act FROM mail_activity AS act
JOIN ir_model AS m ON act.res_model_id = m.id JOIN ir_model AS m ON act.res_model_id = m.id
WHERE user_id = %s WHERE user_id = %s
GROUP BY m.name, states, act.res_model; GROUP BY m.id, states, act.res_model;
""" """
self.env.cr.execute(query, [self.env.uid]) self.env.cr.execute(query, [self.env.uid])
activity_data = self.env.cr.dictfetchall() activity_data = self.env.cr.dictfetchall()
model_ids = [a['id'] for a in activity_data]
model_names = {n[0]:n[1] for n in self.env['ir.model'].browse(model_ids).name_get()}
user_activities = {} user_activities = {}
for activity in activity_data: for activity in activity_data:
if not user_activities.get(activity['model']): if not user_activities.get(activity['model']):
user_activities[activity['model']] = { user_activities[activity['model']] = {
'name': activity['name'], 'name': model_names[activity['id']],
'model': activity['model'], 'model': activity['model'],
'icon': modules.module.get_module_icon(self.env[activity['model']]._original_module), 'icon': modules.module.get_module_icon(self.env[activity['model']]._original_module),
'total_count': 0, 'today_count': 0, 'overdue_count': 0, 'planned_count': 0, 'total_count': 0, 'today_count': 0, 'overdue_count': 0, 'planned_count': 0,
+1 -1
View File
@@ -258,7 +258,7 @@ var Chatter = Widget.extend(chat_mixin, {
var suggested_partners = []; var suggested_partners = [];
var thread_recipients = result[self.context.default_res_id]; var thread_recipients = result[self.context.default_res_id];
_.each(thread_recipients, function (recipient) { _.each(thread_recipients, function (recipient) {
var parsed_email = utils.parse_email(recipient[1]); var parsed_email = recipient[1] && utils.parse_email(recipient[1]);
suggested_partners.push({ suggested_partners.push({
checked: true, checked: true,
partner_id: recipient[0], partner_id: recipient[0],
+3 -1
View File
@@ -3,7 +3,6 @@ odoo.define('mail.systray', function (require) {
var config = require('web.config'); var config = require('web.config');
var core = require('web.core'); var core = require('web.core');
var framework = require('web.framework');
var session = require('web.session'); var session = require('web.session');
var SystrayMenu = require('web.SystrayMenu'); var SystrayMenu = require('web.SystrayMenu');
var Widget = require('web.Widget'); var Widget = require('web.Widget');
@@ -175,6 +174,9 @@ var ActivityMenu = Widget.extend({
return self._rpc({ return self._rpc({
model: 'res.users', model: 'res.users',
method: 'activity_user_count', method: 'activity_user_count',
kwargs: {
context: session.user_context,
},
}).then(function (data) { }).then(function (data) {
self.activities = data; self.activities = data;
self.activityCounter = _.reduce(data, function(total_count, p_data){ return total_count + p_data.total_count; }, 0); self.activityCounter = _.reduce(data, function(total_count, p_data){ return total_count + p_data.total_count; }, 0);
+10 -8
View File
@@ -100,15 +100,17 @@ function inline (node, transform_children) {
// Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False // Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False
function parse_email (text) { function parse_email (text) {
var result = text.match(/(.*)<(.*@.*)>/); if (text){
if (result) { var result = text.match(/(.*)<(.*@.*)>/);
return [_.str.trim(result[1]), _.str.trim(result[2])]; if (result) {
return [_.str.trim(result[1]), _.str.trim(result[2])];
}
result = text.match(/(.*@.*)/);
if (result) {
return [_.str.trim(result[1]), _.str.trim(result[1])];
}
return [text, false];
} }
result = text.match(/(.*@.*)/);
if (result) {
return [_.str.trim(result[1]), _.str.trim(result[1])];
}
return [text, false];
} }
// Replaces textarea text into html text (add <p>, <a>) // Replaces textarea text into html text (add <p>, <a>)
@@ -0,0 +1,105 @@
odoo.define('mail.client_action_mobile_tests', function (require) {
"use strict";
var session = require('web.session');
var testUtils = require('web.test_utils');
var Widget = require('web.Widget');
var ChatAction = require('mail.chat_client_action');
var chat_manager = require('mail.chat_manager');
QUnit.module('mail', {}, function () {
QUnit.module('Discuss client action in mobile', {
beforeEach: function () {
this.data = {
'mail.message': {
fields: {},
},
};
this.createChatAction = function (params) {
var Parent = Widget.extend({
do_push_state: function () {},
});
var parent = new Parent();
testUtils.addMockEnvironment(parent, {
data: this.data,
archs: {
'mail.message,false,search': '<search/>',
},
session: params.session || {},
intercepts: params.intercepts || {},
});
var chatAction = new ChatAction(parent, params);
chatAction.set_cp_bus(new Widget());
chatAction.appendTo($('#qunit-fixture'));
return chatAction;
};
},
});
QUnit.test('mobile basic rendering', function (assert) {
// This is a very basic first test for the client action. However, with
// the chat_manager, it is hard to override RPCs (for instance, the
// /mail/client_action route is always called when the test suite is
// launched), and we must wait for this RPC to be done before starting to
// test the interface. This should be refactored to facilitate the testing.
assert.expect(9);
var done = assert.async();
var rpc = session.rpc;
var chatAction = this.createChatAction({
id: 1,
context: {},
params: {},
intercepts: {
get_session: function (ev) {
ev.data.callback({});
},
},
session: {
rpc: function (route, args) {
if (args.method === 'message_fetch') {
return $.when([]);
}
return rpc.apply(this, arguments);
},
},
});
chat_manager.is_ready.then(function () {
// test basic rendering in mobile
assert.strictEqual(chatAction.$('.o_mail_chat_mobile_control_panel').length, 1,
"should have rendered a control panel");
assert.strictEqual(chatAction.$('.o_mail_chat_content .o_mail_no_content').length, 1,
"should display the no content message");
assert.strictEqual(chatAction.$('.o_mail_mobile_tabs').length, 1,
"should have rendered the tabs");
assert.ok(chatAction.$('.o_mail_mobile_tab[data-type=channel_inbox]').hasClass('active'),
"should be in inbox tab");
assert.strictEqual(chatAction.$('.o_mail_chat_mobile_inbox_buttons:visible').length, 1,
"inbox/starred buttons should be visible");
assert.ok(chatAction.$('.o_mail_chat_mobile_inbox_buttons .o_channel_inbox_item[data-type=channel_inbox]').hasClass('btn-primary'),
"should be in inbox");
// move to DMs tab
chatAction.$('.o_mail_mobile_tab[data-type=dm]').click();
assert.ok(chatAction.$('.o_mail_mobile_tab[data-type=dm]').hasClass('active'),
"should be in DMs tab");
assert.strictEqual(chatAction.$('.o_mail_chat_content .o_mail_no_content').length, 0,
"should display the no content message");
chatAction.$('.o_mail_chat_button_dm').click(); // click to add a channel
assert.strictEqual(chatAction.$('.o_mail_add_channel input:visible').length, 1,
"should display the input to add a channel");
chatAction.destroy();
done();
});
});
});
});
@@ -1,88 +0,0 @@
odoo.define('mail.client_action_test', function (require) {
"use strict";
var testUtils = require('web.test_utils');
var Widget = require('web.Widget');
var ChatAction = require("mail.chat_client_action");
QUnit.module('mail', {}, function () {
QUnit.module('Discuss client action', {
beforeEach: function () {
this.data = {
'mail.message': {
fields: {},
},
};
this.createChatAction = function (params) {
var parent = new Widget();
testUtils.addMockEnvironment(parent, {
data: this.data,
archs: {
'mail.message,false,search': '<search/>',
},
config: {device: {isMobile: true}},
});
var chatAction = new ChatAction(parent, params);
chatAction.set_cp_bus(new Widget());
chatAction.appendTo($('#qunit-fixture'));
return chatAction;
};
},
});
QUnit.skip('mobile basic rendering', function (assert) {
// Unfortunately, this test is skipped for now because there is no way to
// execute the whole test suite in mobile (it only works test by test), so
// as the client action include for mobile is rejected when we are not in
// mobile, it isn't possible to test it
// Moreover, RPCs done by the chat_manager (e.g. message_fetch) should be
// properly mocked.
assert.expect(11);
var parent = new Widget();
testUtils.addMockEnvironment(parent, {
data: this.data,
archs: {
'mail.message,false,search': '<search/>',
},
config: {device: {isMobile: true}},
});
var params = {
id: 1,
context: {},
params: {},
};
var chatAction = this.createChatAction(params);
// test basic rendering in mobile
assert.equal(chatAction.$(".o_mail_chat_mobile_control_panel").length, 1, "Mobile control panel created");
assert.equal(chatAction.$(".o_mail_mobile_tab").length, 4, "Four mobile tabs created");
assert.equal(chatAction.$('.o_mail_chat_content').length, 1, "One default chat content pane created");
assert.equal(chatAction.$(".o_mail_chat_tab_pane").length, 3, "Three mobile tab panes created");
// Inbox
assert.equal(chatAction.activeMobileTab, "channel_inbox", "'channel_inbox' is default active tab");
assert.ok(chatAction.$(".o_channel_inbox_item:nth(0)").hasClass("btn-primary"), "Showing 'Inbox'");
// Starred
chatAction.$(".o_channel_inbox_item[data-type='channel_starred']").click();
assert.ok(chatAction.$(".o_channel_inbox_item:nth(1)").hasClass("btn-primary"), "Clicked on 'Starred'");
assert.ok(chatAction.$(".o_mail_chat_content").is(":visible"), "Default main content pane visible");
chatAction.$(".o_mail_mobile_tab[data-type='dm']").click();
assert.equal(chatAction.activeMobileTab, "dm", "After click on 'Conversation', is now active tab");
assert.ok(!chatAction.$(".o_mail_chat_content").is(":visible"), "none", "'Main' content pane is invisible");
assert.ok(chatAction.$(".o_mail_chat_tab_pane:nth(0)").is(":visible"), "'Conversation' pane is visible");
chatAction.destroy();
});
});
});
+6 -1
View File
@@ -43,13 +43,18 @@
<template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite"> <template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite">
<xpath expr="//t[@t-set='head']" position="inside"> <xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/mail/static/tests/client_action_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/chatter_tests.js"></script> <script type="text/javascript" src="/mail/static/tests/chatter_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script> <script type="text/javascript" src="/mail/static/tests/systray_tests.js"></script>
<script type="text/javascript" src="/mail/static/tests/chat_window_test.js"></script> <script type="text/javascript" src="/mail/static/tests/chat_window_test.js"></script>
</xpath> </xpath>
</template> </template>
<template id="qunit_mobile_suite" name="mail_mobile_tests" inherit_id="web.qunit_mobile_suite">
<xpath expr="//t[@t-set='head']" position="inside">
<script type="text/javascript" src="/mail/static/tests/client_action_mobile_tests.js"></script>
</xpath>
</template>
<template id="message_origin_link"> <template id="message_origin_link">
<p>This <t t-esc="self._description.lower()"/> has been <span t-if="edit">modified</span><span t-if="not edit">created</span> from: <p>This <t t-esc="self._description.lower()"/> has been <span t-if="edit">modified</span><span t-if="not edit">created</span> from:
<t t-foreach="origin" t-as="o"> <t t-foreach="origin" t-as="o">
+9
View File
@@ -213,6 +213,15 @@ class MaintenanceEquipment(models.Model):
self.message_subscribe_users(user_ids=[vals['owner_user_id']]) self.message_subscribe_users(user_ids=[vals['owner_user_id']])
return super(MaintenanceEquipment, self).write(vals) return super(MaintenanceEquipment, self).write(vals)
@api.model
def _message_get_auto_subscribe_fields(self, updated_fields, auto_follow_fields=None):
""" mail.thread override so user_id which has no special access allowance is not
automatically subscribed.
"""
if auto_follow_fields is None:
auto_follow_fields = []
return super(MaintenanceEquipment, self)._message_get_auto_subscribe_fields(updated_fields, auto_follow_fields)
@api.model @api.model
def _read_group_category_ids(self, categories, domain, order): def _read_group_category_ids(self, categories, domain, order):
""" Read group customization in order to display all the categories in """ Read group customization in order to display all the categories in
+3 -2
View File
@@ -585,14 +585,15 @@ class MassMailing(models.Model):
self.ensure_one() self.ensure_one()
mass_mailing_copy = self.copy() mass_mailing_copy = self.copy()
if mass_mailing_copy: if mass_mailing_copy:
context = dict(self.env.context)
context['form_view_initial_mode'] = 'edit'
return { return {
'type': 'ir.actions.act_window', 'type': 'ir.actions.act_window',
'view_type': 'form', 'view_type': 'form',
'view_mode': 'form', 'view_mode': 'form',
'res_model': 'mail.mass_mailing', 'res_model': 'mail.mass_mailing',
'res_id': mass_mailing_copy.id, 'res_id': mass_mailing_copy.id,
'context': self.env.context, 'context': context,
'flags': {'initial_mode': 'edit'},
} }
return False return False
+27 -4
View File
@@ -88,6 +88,10 @@ class MrpUnbuild(models.Model):
if self.product_id.tracking != 'none' and not self.lot_id.id: if self.product_id.tracking != 'none' and not self.lot_id.id:
raise UserError(_('Should have a lot for the finished product')) raise UserError(_('Should have a lot for the finished product'))
if self.mo_id:
if self.mo_id.state != 'done':
raise UserError(_('You cannot unbuild a undone manufacturing order.'))
consume_move = self._generate_consume_moves()[0] consume_move = self._generate_consume_moves()[0]
produce_moves = self._generate_produce_moves() produce_moves = self._generate_produce_moves()
@@ -156,12 +160,31 @@ class MrpUnbuild(models.Model):
def _generate_produce_moves(self): def _generate_produce_moves(self):
moves = self.env['stock.move'] moves = self.env['stock.move']
for unbuild in self: for unbuild in self:
factor = unbuild.product_uom_id._compute_quantity(unbuild.product_qty, unbuild.bom_id.product_uom_id) / unbuild.bom_id.product_qty if unbuild.mo_id:
boms, lines = unbuild.bom_id.explode(unbuild.product_id, factor, picking_type=unbuild.bom_id.picking_type_id) raw_moves = unbuild.mo_id.move_raw_ids.filtered(lambda move: move.state == 'done')
for line, line_data in lines: factor = unbuild.product_qty / unbuild.mo_id.product_uom_id._compute_quantity(unbuild.mo_id.product_qty, unbuild.product_uom_id)
moves += unbuild._generate_move_from_bom_line(line, line_data['qty']) for raw_move in raw_moves:
moves += unbuild._generate_move_from_raw_moves(raw_move, factor)
else:
factor = unbuild.product_uom_id._compute_quantity(unbuild.product_qty, unbuild.bom_id.product_uom_id) / unbuild.bom_id.product_qty
boms, lines = unbuild.bom_id.explode(unbuild.product_id, factor, picking_type=unbuild.bom_id.picking_type_id)
for line, line_data in lines:
moves += unbuild._generate_move_from_bom_line(line, line_data['qty'])
return moves return moves
def _generate_move_from_raw_moves(self, raw_move, factor):
return self.env['stock.move'].create({
'name': self.name,
'date': self.create_date,
'product_id': raw_move.product_id.id,
'product_uom_qty': raw_move.product_uom_qty * factor,
'product_uom': raw_move.product_uom.id,
'procure_method': 'make_to_stock',
'location_dest_id': self.location_dest_id.id,
'location_id': raw_move.location_dest_id.id,
'unbuild_id': self.id,
})
def _generate_move_from_bom_line(self, bom_line, quantity): def _generate_move_from_bom_line(self, bom_line, quantity):
return self.env['stock.move'].create({ return self.env['stock.move'].create({
'name': self.name, 'name': self.name,
+6
View File
@@ -195,6 +195,12 @@ class StockMove(models.Model):
move_lines = self.move_line_ids.filtered(lambda ml: ml.lot_id == lot and not ml.lot_produced_id) move_lines = self.move_line_ids.filtered(lambda ml: ml.lot_id == lot and not ml.lot_produced_id)
else: else:
move_lines = self.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_produced_id) move_lines = self.move_line_ids.filtered(lambda ml: not ml.lot_id and not ml.lot_produced_id)
# Sanity check: if the product is a serial number and `lot` is already present in the other
# consumed move lines, raise.
if lot and self.product_id.tracking == 'serial' and lot in self.move_line_ids.filtered(lambda ml: ml.qty_done).mapped('lot_id'):
raise UserError(_('You cannot consume the same serial number twice. Please correct the serial numbers encoded.'))
for ml in move_lines: for ml in move_lines:
rounding = ml.product_uom_id.rounding rounding = ml.product_uom_id.rounding
if float_compare(qty_to_add, 0, precision_rounding=rounding) <= 0: if float_compare(qty_to_add, 0, precision_rounding=rounding) <= 0:
+1 -1
View File
@@ -122,7 +122,7 @@
</div> </div>
</group> </group>
<group> <group>
<field name="mo_id" required="True"/> <field name="mo_id" domain="[('state','=','done')]"/>
<field name="location_id" groups="stock.group_stock_multi_locations"/> <field name="location_id" groups="stock.group_stock_multi_locations"/>
<field name="location_dest_id" groups="stock.group_stock_multi_locations"/> <field name="location_dest_id" groups="stock.group_stock_multi_locations"/>
<field name="has_tracking" invisible="1"/> <field name="has_tracking" invisible="1"/>
+16 -16
View File
@@ -125,6 +125,8 @@ class MrpProductProduce(models.TransientModel):
raise UserError(_('You need to provide a lot for the finished product')) raise UserError(_('You need to provide a lot for the finished product'))
existing_move_line = produce_move.move_line_ids.filtered(lambda x: x.lot_id == self.lot_id) existing_move_line = produce_move.move_line_ids.filtered(lambda x: x.lot_id == self.lot_id)
if existing_move_line: if existing_move_line:
if self.product_id.tracking == 'serial':
raise UserError(_('You cannot produce the same serial number twice.'))
existing_move_line.product_uom_qty += self.product_qty existing_move_line.product_uom_qty += self.product_qty
existing_move_line.qty_done += self.product_qty existing_move_line.qty_done += self.product_qty
else: else:
@@ -181,27 +183,25 @@ class MrpProductProduceLine(models.TransientModel):
@api.onchange('lot_id') @api.onchange('lot_id')
def _onchange_lot_id(self): def _onchange_lot_id(self):
""" When the user is encoding a produce line for a tracked product, we apply some logic to
help him. This onchange will automatically switch `qty_done` to 1.0.
"""
res = {} res = {}
if self.product_id.tracking == 'serial': if self.product_id.tracking == 'serial':
self.qty_done = 1 self.qty_done = 1
return res return res
@api.constrains('lot_id') @api.onchange('qty_done')
def _check_lot_id(self): def _onchange_qty_done(self):
for ml in self: """ When the user is encoding a produce line for a tracked product, we apply some logic to
if ml.product_id.tracking == 'serial': help him. This onchange will warn him if he set `qty_done` to a non-supported value.
produce_lines_to_check = ml.product_produce_id.produce_line_ids.filtered(lambda l: l.product_id == ml.product_id and l.lot_id) """
message = produce_lines_to_check._check_for_duplicated_serial_numbers() res = {}
if message: if self.product_id.tracking == 'serial':
raise ValidationError(message) if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0:
message = _('You can only process 1.0 %s for products with unique serial number.') % self.product_id.uom_id.name
def _check_for_duplicated_serial_numbers(self): res['warning'] = {'title': _('Warning'), 'message': message}
if self.mapped('lot_id'): return res
lots_map = [(ml.product_id.id, ml.lot_id.name) for ml in self]
recorded_serials_counter = Counter(lots_map)
for (product_id, lot_id), occurrences in recorded_serials_counter.items():
if occurrences > 1 and lot_id is not False:
return _('You cannot consume the same serial number twice. Please correct the serial numbers encoded.')
@api.onchange('product_id') @api.onchange('product_id')
def _onchange_product_id(self): def _onchange_product_id(self):
+4 -1
View File
@@ -51,7 +51,10 @@ class WebsitePayment(http.Controller):
'amount': float(amount), 'amount': float(amount),
'payment_form': payment_form, 'payment_form': payment_form,
} }
return request.render('payment.pay', values) # TODO: remove return when payment is implemented with refactored payments, i.e. use of
# payment.payment_tokens_list in the template.
# return request.render('payment.pay', values)
return request.render('website.404')
@http.route(['/website_payment/transaction'], type='json', auth="public", website=True) @http.route(['/website_payment/transaction'], type='json', auth="public", website=True)
def transaction(self, reference, amount, currency_id, acquirer_id): def transaction(self, reference, amount, currency_id, acquirer_id):
+1 -1
View File
@@ -127,7 +127,7 @@ class PaymentAcquirerAuthorize(models.Model):
for field_name in mandatory_fields: for field_name in mandatory_fields:
if not data.get(field_name): if not data.get(field_name):
error[field_name] = 'missing' error[field_name] = 'missing'
if data['cc_expiry'] and datetime.now().strftime('%y%M') > datetime.strptime(data['cc_expiry'], '%M / %y').strftime('%y%M'): if data['cc_expiry'] and datetime.now().strftime('%y%m') > datetime.strptime(data['cc_expiry'], '%m / %y').strftime('%y%m'):
return False return False
return False if error else True return False if error else True
@@ -92,7 +92,7 @@ odoo.define('payment_stripe.stripe', function(require) {
return false; return false;
} }
var access_token = $("input[name='access_token']").val() || $("input[name='token']").val(); var access_token = $("input[name='access_token']").val() || $("input[name='token']").val() || '';
var so_id = $("input[name='return_url']").val().match(/quote\/([0-9]+)/) || undefined; var so_id = $("input[name='return_url']").val().match(/quote\/([0-9]+)/) || undefined;
if (so_id) { if (so_id) {
so_id = parseInt(so_id[1]); so_id = parseInt(so_id[1]);
@@ -43,11 +43,10 @@ try:
else: else:
_logger.warning(_('Unable to format %s:\n%s') % number, e) _logger.warning(_('Unable to format %s:\n%s') % number, e)
return number return number
if always_international and phone_nbr.country_code != country_phone_code: if always_international or phone_nbr.country_code != country_phone_code:
phone_fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL phone_fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
else: else:
phone_fmt = phonenumbers.PhoneNumberFormat.NATIONAL phone_fmt = phonenumbers.PhoneNumberFormat.NATIONAL
return phonenumbers.format_number(phone_nbr, phone_fmt) return phonenumbers.format_number(phone_nbr, phone_fmt)
except ImportError: except ImportError:
@@ -1,8 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
The purpose of this view is to move the VAT field at the top of the
company form. That way, the user will fill it first
-->
<odoo> <odoo>
<record id="res_company_view_form_phone_validation" model="ir.ui.view"> <record id="res_company_view_form_phone_validation" model="ir.ui.view">
<field name="name">res.company.view.form.phone_validation</field> <field name="name">res.company.view.form.phone_validation</field>
+9
View File
@@ -128,6 +128,15 @@
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/> <filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
<field string="Attributes" name="attribute_line_ids" groups="product.group_product_variant"/> <field string="Attributes" name="attribute_line_ids" groups="product.group_product_variant"/>
<field name="pricelist_id" widget="selection" context="{'pricelist': self}" filter_domain="[]" groups="product.group_sale_pricelist"/> <!-- Keep widget=selection on this field to pass numeric `self` value, which is not the case for regular m2o widgets! --> <field name="pricelist_id" widget="selection" context="{'pricelist': self}" filter_domain="[]" groups="product.group_sale_pricelist"/> <!-- Keep widget=selection on this field to pass numeric `self` value, which is not the case for regular m2o widgets! -->
<separator/>
<filter string="Late Activities" name="activities_overdue"
domain="[('activity_ids.date_deadline', '&lt;', context_today().strftime('%Y-%m-%d'))]"
help="Show all records which has next action date is before today"/>
<filter string="Today Activities" name="activities_today"
domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Future Activities" name="activities_upcoming_all"
domain="[('activity_ids.date_deadline', '&gt;', context_today().strftime('%Y-%m-%d'))
]"/>
</search> </search>
</field> </field>
</record> </record>
@@ -44,7 +44,7 @@ class StockProductionLot(models.Model):
# Assign dates according to products data # Assign dates according to products data
@api.model @api.model
def create(self, vals): def create(self, vals):
dates = self._get_dates(vals.get('product_id')) dates = self._get_dates(vals.get('product_id') or self.env.context.get('default_product_id'))
for d in dates: for d in dates:
if not vals.get(d): if not vals.get(d):
vals[d] = dates[d] vals[d] = dates[d]
+5 -5
View File
@@ -512,19 +512,19 @@ class Task(models.Model):
partner_id = fields.Many2one('res.partner', partner_id = fields.Many2one('res.partner',
string='Customer', string='Customer',
default=_get_default_partner) default=_get_default_partner)
manager_id = fields.Many2one('res.users', string='Project Manager', related='project_id.user_id', readonly=True) manager_id = fields.Many2one('res.users', string='Project Manager', related='project_id.user_id', readonly=True, related_sudo=False)
company_id = fields.Many2one('res.company', company_id = fields.Many2one('res.company',
string='Company', string='Company',
default=lambda self: self.env['res.company']._company_default_get()) default=lambda self: self.env['res.company']._company_default_get())
color = fields.Integer(string='Color Index') color = fields.Integer(string='Color Index')
user_email = fields.Char(related='user_id.email', string='User Email', readonly=True) user_email = fields.Char(related='user_id.email', string='User Email', readonly=True, related_sudo=False)
attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment_ids', string="Main Attachments", attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment_ids', string="Main Attachments",
help="Attachment that don't come from message.") help="Attachment that don't come from message.")
# In the domain of displayed_image_id, we couln't use attachment_ids because a one2many is represented as a list of commands so we used res_model & res_id # In the domain of displayed_image_id, we couln't use attachment_ids because a one2many is represented as a list of commands so we used res_model & res_id
displayed_image_id = fields.Many2one('ir.attachment', domain="[('res_model', '=', 'project.task'), ('res_id', '=', id), ('mimetype', 'ilike', 'image')]", string='Cover Image') displayed_image_id = fields.Many2one('ir.attachment', domain="[('res_model', '=', 'project.task'), ('res_id', '=', id), ('mimetype', 'ilike', 'image')]", string='Cover Image')
legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True) legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True, related_sudo=False)
legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True) legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True, related_sudo=False)
legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True) legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True, related_sudo=False)
parent_id = fields.Many2one('project.task', string='Parent Task') parent_id = fields.Many2one('project.task', string='Parent Task')
child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks") child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks")
subtask_project_id = fields.Many2one('project.project', related="project_id.subtask_project_id", string='Sub-task Project', readonly=True) subtask_project_id = fields.Many2one('project.project', related="project_id.subtask_project_id", string='Sub-task Project', readonly=True)
@@ -60,7 +60,7 @@ class Holidays(models.Model):
work_hours_data = [item for item in holiday.employee_id.iter_work_hours_count(fields.Datetime.from_string(holiday.date_from), fields.Datetime.from_string(holiday.date_to))] work_hours_data = [item for item in holiday.employee_id.iter_work_hours_count(fields.Datetime.from_string(holiday.date_from), fields.Datetime.from_string(holiday.date_to))]
for index, (day_date, work_hours_count) in enumerate(work_hours_data): for index, (day_date, work_hours_count) in enumerate(work_hours_data):
self.env['account.analytic.line'].create({ self.env['account.analytic.line'].create({
'name': "%s (%s/%s)" % (holiday.name, index + 1, len(work_hours_data)), 'name': "%s (%s/%s)" % (holiday.name or '', index + 1, len(work_hours_data)),
'project_id': holiday_project.id, 'project_id': holiday_project.id,
'task_id': holiday_task.id, 'task_id': holiday_task.id,
'account_id': holiday_project.analytic_account_id.id, 'account_id': holiday_project.analytic_account_id.id,
@@ -77,6 +77,7 @@ class Holidays(models.Model):
def action_refuse(self): def action_refuse(self):
""" Remove the timesheets linked to the refused holidays """ """ Remove the timesheets linked to the refused holidays """
result = super(Holidays, self).action_refuse() result = super(Holidays, self).action_refuse()
self.mapped('timesheet_ids').write({'holiday_id': False}) timesheets = self.sudo().mapped('timesheet_ids')
self.mapped('timesheet_ids').unlink() timesheets.write({'holiday_id': False})
timesheets.unlink()
return result return result
+3 -1
View File
@@ -98,7 +98,9 @@ class AccountInvoice(models.Model):
def _onchange_currency_id(self): def _onchange_currency_id(self):
if self.currency_id: if self.currency_id:
for line in self.invoice_line_ids.filtered(lambda r: r.purchase_line_id): for line in self.invoice_line_ids.filtered(lambda r: r.purchase_line_id):
line.price_unit = line.purchase_id.currency_id.with_context(date=self.date_invoice).compute(line.purchase_line_id.price_unit, self.currency_id, round=False) if line.purchase_id.currency_id != self.currency_id:
line.price_unit = line.purchase_id.currency_id.with_context(date=self.date_invoice)\
.compute(line.purchase_line_id.price_unit, self.currency_id, round=False)
@api.onchange('invoice_line_ids') @api.onchange('invoice_line_ids')
def _onchange_origin(self): def _onchange_origin(self):
+4 -4
View File
@@ -776,10 +776,10 @@ class PurchaseOrderLine(models.Model):
self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id
result['domain'] = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]} result['domain'] = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]}
product_lang = self.product_id.with_context({ product_lang = self.product_id.with_context(
'lang': self.partner_id.lang, lang=self.partner_id.lang,
'partner_id': self.partner_id.id, partner_id=self.partner_id.id,
}) )
self.name = product_lang.display_name self.name = product_lang.display_name
if product_lang.description_purchase: if product_lang.description_purchase:
self.name += '\n' + product_lang.description_purchase self.name += '\n' + product_lang.description_purchase
@@ -61,7 +61,7 @@
<record id="portal_purchase_order_user_rule" model="ir.rule"> <record id="portal_purchase_order_user_rule" model="ir.rule">
<field name="name">Portal Purchase Orders</field> <field name="name">Portal Purchase Orders</field>
<field name="model_id" ref="purchase.model_purchase_order"/> <field name="model_id" ref="purchase.model_purchase_order"/>
<field name="domain_force">[('message_partner_ids','child_of',[user.commercial_partner_id.id])]</field> <field name="domain_force">['|', ('message_partner_ids','child_of',[user.commercial_partner_id.id]),('partner_id', 'child_of', [user.commercial_partner_id.id])]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/> <field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_unlink" eval="1"/> <field name="perm_unlink" eval="1"/>
<field name="perm_write" eval="1"/> <field name="perm_write" eval="1"/>
@@ -72,7 +72,7 @@
<record id="portal_purchase_order_line_rule" model="ir.rule"> <record id="portal_purchase_order_line_rule" model="ir.rule">
<field name="name">Portal Purhcase Orders Line</field> <field name="name">Portal Purhcase Orders Line</field>
<field name="model_id" ref="purchase.model_purchase_order_line"/> <field name="model_id" ref="purchase.model_purchase_order_line"/>
<field name="domain_force">[('order_id.message_partner_ids','child_of',[user.commercial_partner_id.id])]</field> <field name="domain_force">['|',('order_id.message_partner_ids','child_of',[user.commercial_partner_id.id]),('order_id.partner_id','child_of',[user.commercial_partner_id.id])]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/> <field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record> </record>
</data> </data>
+8 -6
View File
@@ -128,11 +128,13 @@ class AccountAnalyticLine(models.Model):
raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name) raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name)
price = analytic_line._sale_get_invoice_price(sale_order) price = analytic_line._sale_get_invoice_price(sale_order)
so_line = self.env['sale.order.line'].search([ so_line = None
('order_id', '=', sale_order.id), if analytic_line.product_id.expense_policy == 'sales_price' and analytic_line.product_id.invoice_policy == 'delivery':
('price_unit', '=', price), so_line = self.env['sale.order.line'].search([
('product_id', '=', self.product_id.id) ('order_id', '=', sale_order.id),
], limit=1) ('price_unit', '=', price),
('product_id', '=', self.product_id.id)
], limit=1)
if not so_line: if not so_line:
# generate a new SO line # generate a new SO line
@@ -142,7 +144,7 @@ class AccountAnalyticLine(models.Model):
so_line = self.env['sale.order.line'].create(so_line_values) so_line = self.env['sale.order.line'].create(so_line_values)
so_line._compute_tax_id() so_line._compute_tax_id()
else: else:
so_line.write({'qty_delivered': analytic_line.unit_amount}) so_line.write({'qty_delivered': so_line.qty_delivered + analytic_line.unit_amount})
if so_line: # if so line found or created, then update AAL (this will trigger the recomputation of qty delivered on SO line) if so_line: # if so line found or created, then update AAL (this will trigger the recomputation of qty delivered on SO line)
analytic_line.with_context(sale_analytic_norecompute=True).write({'so_line': so_line.id}) analytic_line.with_context(sale_analytic_norecompute=True).write({'so_line': so_line.id})
+5 -2
View File
@@ -150,7 +150,10 @@
<t t-foreach="doc._get_tax_amount_by_group()" t-as="amount_by_group"> <t t-foreach="doc._get_tax_amount_by_group()" t-as="amount_by_group">
<tr style="border-bottom:1px solid #dddddd;"> <tr style="border-bottom:1px solid #dddddd;">
<t t-if="amount_by_group[3] == 1 and doc.amount_untaxed == amount_by_group[2]"> <t t-if="amount_by_group[3] == 1 and doc.amount_untaxed == amount_by_group[2]">
<td><span t-esc="amount_by_group[0]"/></td> <td>
<span t-esc="amount_by_group[0]"/>
<span>&amp;nbsp;<span>on</span>&amp;nbsp;<t t-esc="amount_by_group[2]" t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/></span>
</td>
<td class="text-right"> <td class="text-right">
<span t-esc="amount_by_group[1]" <span t-esc="amount_by_group[1]"
t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/> t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/>
@@ -158,7 +161,7 @@
</t> </t>
<t t-else =""> <t t-else ="">
<td> <td>
<span t-esc="amount_by_group[0]"/><span>&amp;nbsp;<span>on</span>&amp;nbsp;<t t-esc="amount_by_group[2]" t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/></span> <span t-esc="amount_by_group[0]"/>
</td> </td>
<td class="text-right"> <td class="text-right">
<span t-esc="amount_by_group[1]" <span t-esc="amount_by_group[1]"
+33 -1
View File
@@ -9,6 +9,16 @@ from odoo.addons.iap.models import iap
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
try:
import phonenumbers
_sms_phonenumbers_lib_imported = True
except ImportError:
_sms_phonenumbers_lib_imported = False
_logger.warning(
"The `phonenumbers` Python module is not installed. Try: pip install phonenumbers."
)
class SendSMS(models.TransientModel): class SendSMS(models.TransientModel):
_name = 'sms.send_sms' _name = 'sms.send_sms'
@@ -16,6 +26,27 @@ class SendSMS(models.TransientModel):
recipients = fields.Char('Recipients', required=True) recipients = fields.Char('Recipients', required=True)
message = fields.Text('Message', required=True) message = fields.Text('Message', required=True)
def _phone_get_country(self, partner):
if 'country_id' in partner:
return partner.country_id
return self.env.user.company_id.country_id
def _sms_sanitization(self, partner, field_name):
number = partner[field_name]
if _sms_phonenumbers_lib_imported:
country = self._phone_get_country(partner)
country_code = country.code if country else None
try:
phone_nbr = phonenumbers.parse(number, region=country_code, keep_raw_input=True)
except phonenumbers.phonenumberutil.NumberParseException:
return number
if not phonenumbers.is_possible_number(phone_nbr) or not phonenumbers.is_valid_number(phone_nbr):
return number
phone_fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
return phonenumbers.format_number(phone_nbr, phone_fmt).replace(' ', '')
else:
return number
def _get_records(self, model): def _get_records(self, model):
if self.env.context.get('active_domain'): if self.env.context.get('active_domain'):
records = model.search(self.env.context.get('active_domain')) records = model.search(self.env.context.get('active_domain'))
@@ -38,7 +69,8 @@ class SendSMS(models.TransientModel):
phone_numbers = [] phone_numbers = []
no_phone_partners = [] no_phone_partners = []
for partner in partners: for partner in partners:
number = partner.mobile default_field_name = 'mobile' if partner.mobile else 'phone'
number = self._sms_sanitization(partner, self.env.context('field_name') or default_field_name)
if number: if number:
phone_numbers.append(number) phone_numbers.append(number)
else: else:
-4
View File
@@ -183,10 +183,6 @@ class Route(models.Model):
'domain': [('route_ids', 'in', self.ids)], 'domain': [('route_ids', 'in', self.ids)],
} }
@api.onchange('warehouse_selectable')
def _onchange_warehouse_selectable(self):
if not self.warehouse_selectable:
self.warehouse_ids = [(5, 0, 0)]
class PushedFlow(models.Model): class PushedFlow(models.Model):
_name = "stock.location.path" _name = "stock.location.path"
+9 -5
View File
@@ -1000,8 +1000,16 @@ class StockMove(models.Model):
moves = self.filtered(lambda x: x.state not in ('done', 'cancel')) moves = self.filtered(lambda x: x.state not in ('done', 'cancel'))
moves_todo = self.env['stock.move'] moves_todo = self.env['stock.move']
# Create extra moves where necessary
# Cancel moves where necessary ; we should do it before creating the extra moves because
# this operation could trigger a merge of moves.
for move in moves: for move in moves:
if move.quantity_done <= 0:
if float_compare(move.product_uom_qty, 0.0, precision_rounding=move.product_uom.rounding) == 0:
move._action_cancel()
# Create extra moves where necessary
for move in moves.filtered(lambda m: m.state != 'cancel'):
# Here, the `quantity_done` was already rounded to the product UOM by the `do_produce` wizard. However, # Here, the `quantity_done` was already rounded to the product UOM by the `do_produce` wizard. However,
# it is possible that the user changed the value before posting the inventory by a value that should be # it is possible that the user changed the value before posting the inventory by a value that should be
# rounded according to the move's UOM. In this specific case, we chose to round up the value, because it # rounded according to the move's UOM. In this specific case, we chose to round up the value, because it
@@ -1010,10 +1018,6 @@ class StockMove(models.Model):
# FIXME: move rounding to move line # FIXME: move rounding to move line
# rounding = move.product_uom.rounding # rounding = move.product_uom.rounding
# move.quantity_done = float_round(move.quantity_done, precision_rounding=rounding, rounding_method ='UP') # move.quantity_done = float_round(move.quantity_done, precision_rounding=rounding, rounding_method ='UP')
if move.quantity_done <= 0:
if float_compare(move.product_uom_qty, 0.0, precision_rounding=move.product_uom.rounding) == 0:
move._action_cancel()
continue
moves_todo |= move moves_todo |= move
moves_todo |= move._create_extra_move() moves_todo |= move._create_extra_move()
# Split moves where necessary and move quants # Split moves where necessary and move quants
+32 -38
View File
@@ -109,36 +109,49 @@ class StockMoveLine(models.Model):
@api.onchange('lot_name', 'lot_id') @api.onchange('lot_name', 'lot_id')
def onchange_serial_number(self): def onchange_serial_number(self):
""" When the user is encoding a move line for a tracked product, we apply some logic to
help him. This includes:
- automatically switch `qty_done` to 1.0
- warn if he has already encoded `lot_name` in another move line
"""
res = {} res = {}
if self.product_id.tracking == 'serial': if self.product_id.tracking == 'serial':
self.qty_done = 1 if not self.qty_done:
move_lines_to_check = self._get_similar_move_lines() - self self.qty_done = 1
message = move_lines_to_check._check_for_duplicated_serial_numbers()
message = None
if self.lot_name or self.lot_id:
move_lines_to_check = self._get_similar_move_lines() - self
if self.lot_name:
counter = Counter(move_lines_to_check.mapped('lot_name'))
if counter.get(self.lot_name) and counter[self.lot_name] > 1:
message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.')
elif self.lot_id:
counter = Counter(move_lines_to_check.mapped('lot_id.id'))
if counter.get(self.lot_id.id) and counter[self.lot_id.id] > 1:
message = _('You cannot use the same serial number twice. Please correct the serial numbers encoded.')
if message: if message:
res['warning'] = {'title': _('Warning'), 'message': message} res['warning'] = {'title': _('Warning'), 'message': message}
return res return res
@api.onchange('qty_done')
def _onchange_qty_done(self):
""" When the user is encoding a move line for a tracked product, we apply some logic to
help him. This onchange will warn him if he set `qty_done` to a non-supported value.
"""
res = {}
if self.product_id.tracking == 'serial':
if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id.rounding) != 0:
message = _('You can only process 1.0 %s for products with unique serial number.') % self.product_id.uom_id.name
res['warning'] = {'title': _('Warning'), 'message': message}
return res
@api.constrains('qty_done') @api.constrains('qty_done')
def _check_positive_qty_done(self): def _check_positive_qty_done(self):
if any([ml.qty_done < 0 for ml in self]): if any([ml.qty_done < 0 for ml in self]):
raise ValidationError(_('You can not enter negative quantities!')) raise ValidationError(_('You can not enter negative quantities!'))
@api.constrains('lot_id', 'lot_name', 'qty_done')
def _check_unique_serial_number(self):
for ml in self.filtered(lambda ml: ml.move_id.product_id.tracking == 'serial' and (ml.lot_id or ml.lot_name)):
move_lines_to_check = ml._get_similar_move_lines()
message = move_lines_to_check._check_for_duplicated_serial_numbers()
if message:
raise ValidationError(message)
if float_compare(ml.qty_done, 1.0, precision_rounding=ml.move_id.product_id.uom_id.rounding) == 1:
raise UserError(_(
'You can only process 1.0 %s for products with unique serial number.') % ml.product_id.uom_id.name)
if ml.lot_name:
already_exist = self.env['stock.production.lot'].search(
[('name', '=', ml.lot_name), ('product_id', '=', ml.product_id.id)])
if already_exist:
return _('You have already assigned this serial number to this product. Please correct the serial numbers encoded.')
def _get_similar_move_lines(self): def _get_similar_move_lines(self):
self.ensure_one() self.ensure_one()
lines = self.env['stock.move.line'] lines = self.env['stock.move.line']
@@ -147,25 +160,6 @@ class StockMoveLine(models.Model):
lines |= picking_id.move_line_ids.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name)) lines |= picking_id.move_line_ids.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
return lines return lines
def _check_for_duplicated_serial_numbers(self):
""" This method is used in _check_unique_serial_number and in onchange_serial_number to check that a same serial number is not used twice amongst the recordset passed.
:return: an error message directed to the user if needed else False
"""
if self.mapped('lot_id'):
lots_map = [(ml.product_id.id, ml.lot_id.name) for ml in self]
recorded_serials_counter = Counter(lots_map)
for (product_id, lot_id), occurrences in recorded_serials_counter.items():
if occurrences > 1 and lot_id is not False:
return _('You cannot use the same serial number twice. Please correct the serial numbers encoded.')
elif self.mapped('lot_name'):
lots_map = [(ml.product_id.id, ml.lot_name) for ml in self]
recorded_serials_counter = Counter(lots_map)
for (product_id, lot_id), occurrences in recorded_serials_counter.items():
if occurrences > 1 and lot_id is not False:
return _('You cannot use the same serial number twice. Please correct the serial numbers encoded.')
return False
@api.model @api.model
def create(self, vals): def create(self, vals):
vals['ordered_qty'] = vals.get('product_uom_qty') vals['ordered_qty'] = vals.get('product_uom_qty')
+1 -1
View File
@@ -246,7 +246,7 @@
</group> </group>
<group> <group>
<field name="scheduled_date"/> <field name="scheduled_date"/>
<field name="origin" placeholder="e.g. PO0032" readonly="1"/> <field name="origin" placeholder="e.g. PO0032"/>
<field name="owner_id" groups="stock.group_tracking_owner"/> <field name="owner_id" groups="stock.group_tracking_owner"/>
<div groups="stock.group_tracking_owner" colspan="2" col="2"> <div groups="stock.group_tracking_owner" colspan="2" col="2">
<button name="action_assign_owner" string="Assign Owner" type="object" attrs="{'invisible': ['|',('move_line_exist', '=', False),('state', 'not in', ('draft','assigned','confirmed'))]}" <button name="action_assign_owner" string="Assign Owner" type="object" attrs="{'invisible': ['|',('move_line_exist', '=', False),('state', 'not in', ('draft','assigned','confirmed'))]}"
@@ -171,9 +171,13 @@ class LandedCost(models.Model):
val_line_values.update({'cost_id': cost.id, 'cost_line_id': cost_line.id}) val_line_values.update({'cost_id': cost.id, 'cost_line_id': cost_line.id})
self.env['stock.valuation.adjustment.lines'].create(val_line_values) self.env['stock.valuation.adjustment.lines'].create(val_line_values)
total_qty += val_line_values.get('quantity', 0.0) total_qty += val_line_values.get('quantity', 0.0)
total_cost += val_line_values.get('former_cost', 0.0)
total_weight += val_line_values.get('weight', 0.0) total_weight += val_line_values.get('weight', 0.0)
total_volume += val_line_values.get('volume', 0.0) total_volume += val_line_values.get('volume', 0.0)
former_cost = val_line_values.get('former_cost', 0.0)
# round this because former_cost on the valuation lines is also rounded
total_cost += tools.float_round(former_cost, precision_digits=digits[1]) if digits else former_cost
total_line += 1 total_line += 1
for line in cost.cost_lines: for line in cost.cost_lines:
@@ -8,7 +8,7 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="Add pickings to batch"> <form string="Add pickings to batch">
<separator string="Select a batch"/> <separator string="Select a batch"/>
<field name="batch_id" class="oe_inline"/> <field name="batch_id" class="oe_inline" options="{'no_create_edit': True, 'no_open': True}"/>
<footer> <footer>
<button name="attach_pickings" type="object" string="Add to Batch" class="btn-primary"/> <button name="attach_pickings" type="object" string="Add to Batch" class="btn-primary"/>
<button string="Cancel" class="btn-default" special="cancel"/> <button string="Cancel" class="btn-default" special="cancel"/>
@@ -274,3 +274,71 @@ class TestMessageAccess(common.BaseFunctionalTest, common.MockEmails):
def test_mail_message_access_create_reply(self): def test_mail_message_access_create_reply(self):
self.message.write({'partner_ids': [(4, self.user_employee.partner_id.id)]}) self.message.write({'partner_ids': [(4, self.user_employee.partner_id.id)]})
self.env['mail.message'].sudo(self.user_employee).create({'model': 'mail.channel', 'res_id': self.group_private.id, 'body': 'Test', 'parent_id': self.message.id}) self.env['mail.message'].sudo(self.user_employee).create({'model': 'mail.channel', 'res_id': self.group_private.id, 'body': 'Test', 'parent_id': self.message.id})
@mute_logger('openerp.addons.mail.models.mail_mail')
def test_mark_all_as_read(self):
self.user_employee.notification_type = 'inbox'
emp_partner = self.user_employee.partner_id.sudo(self.user_employee.id)
group_private = self.env['mail.channel'].with_context({
'mail_create_nolog': True,
'mail_create_nosubscribe': True,
'mail_channel_noautofollow': True,
}).create({
'name': 'Private',
'description': 'Private James R.',
'public': 'private',
'alias_name': 'private',
'alias_contact': 'followers'}
).with_context({'mail_create_nosubscribe': False})
# mark all as read clear needactions
group_private.message_post(body='Test', message_type='comment', subtype='mail.mt_comment', partner_ids=[emp_partner.id])
emp_partner.env['mail.message'].mark_all_as_read(channel_ids=[], domain=[])
na_count = emp_partner.get_needaction_count()
self.assertEqual(na_count, 0, "mark all as read should conclude all needactions")
# mark all as read also clear inaccessible needactions
new_msg = group_private.message_post(body='Zest', message_type='comment', subtype='mail.mt_comment', partner_ids=[emp_partner.id])
needaction_accessible = len(emp_partner.env['mail.message'].search([['needaction', '=', True]]))
self.assertEqual(needaction_accessible, 1, "a new message to a partner is readable to that partner")
new_msg.sudo().partner_ids = self.env['res.partner']
emp_partner.env['mail.message'].search([['needaction', '=', True]])
needaction_length = len(emp_partner.env['mail.message'].search([['needaction', '=', True]]))
self.assertEqual(needaction_length, 1, "message should still be readable when notified")
na_count = emp_partner.get_needaction_count()
self.assertEqual(na_count, 1, "message not accessible is currently still counted")
emp_partner.env['mail.message'].mark_all_as_read(channel_ids=[], domain=[])
na_count = emp_partner.get_needaction_count()
self.assertEqual(na_count, 0, "mark all read should conclude all needactions even inacessible ones")
@mute_logger('openerp.addons.mail.models.mail_mail')
def test_mark_all_as_read_share(self):
self.user_portal.notification_type = 'inbox'
portal_partner = self.user_portal.partner_id.sudo(self.user_portal.id)
# mark all as read clear needactions
self.group_pigs.message_post(body='Test', message_type='comment', subtype='mail.mt_comment', partner_ids=[portal_partner.id])
portal_partner.env['mail.message'].mark_all_as_read(channel_ids=[], domain=[])
na_count = portal_partner.get_needaction_count()
self.assertEqual(na_count, 0, "mark all as read should conclude all needactions")
# mark all as read also clear inaccessible needactions
new_msg = self.group_pigs.message_post(body='Zest', message_type='comment', subtype='mail.mt_comment', partner_ids=[portal_partner.id])
needaction_accessible = len(portal_partner.env['mail.message'].search([['needaction', '=', True]]))
self.assertEqual(needaction_accessible, 1, "a new message to a partner is readable to that partner")
new_msg.sudo().partner_ids = self.env['res.partner']
needaction_length = len(portal_partner.env['mail.message'].search([['needaction', '=', True]]))
self.assertEqual(needaction_length, 1, "message should still be readable when notified")
na_count = portal_partner.get_needaction_count()
self.assertEqual(na_count, 1, "message not accessible is currently still counted")
portal_partner.env['mail.message'].mark_all_as_read(channel_ids=[], domain=[])
na_count = portal_partner.get_needaction_count()
self.assertEqual(na_count, 0, "mark all read should conclude all needactions even inacessible ones")
@@ -44,11 +44,6 @@ var AbstractField = Widget.extend({
navigation_move: '_onNavigationMove', navigation_move: '_onNavigationMove',
}, },
/**
* fields can extend the context, e.g. binary fields add {bin_size: true}
*/
context: null,
/** /**
* An object representing fields to be fetched by the model eventhough not present in the view * An object representing fields to be fetched by the model eventhough not present in the view
* This object contains "field name" as key and an object as value. * This object contains "field name" as key and an object as value.
@@ -1094,7 +1094,6 @@ var AbstractFieldBinary = AbstractField.extend({
}, },
'click .o_clear_file_button': 'on_clear', 'click .o_clear_file_button': 'on_clear',
}), }),
context: { bin_size: true },
init: function (parent, name, record) { init: function (parent, name, record) {
this._super.apply(this, arguments); this._super.apply(this, arguments);
this.fields = record.fields; this.fields = record.fields;
@@ -30,7 +30,7 @@ var _t = core._t;
/** /**
* Convert binary to bin_size * Convert binary to bin_size
* *
* @param {string} value base64 representation of the binary (might be already a bin_size!) * @param {string} [value] base64 representation of the binary (might be already a bin_size!)
* @param {Object} [field] * @param {Object} [field]
* a description of the field (note: this parameter is ignored) * a description of the field (note: this parameter is ignored)
* @param {Object} [options] additional options (note: this parameter is ignored) * @param {Object} [options] additional options (note: this parameter is ignored)
@@ -38,6 +38,9 @@ var _t = core._t;
* @returns {string} bin_size (which is human-readable) * @returns {string} bin_size (which is human-readable)
*/ */
function formatBinary(value, field, options) { function formatBinary(value, field, options) {
if (!value) {
return '';
}
return utils.binaryToBinsize(value); return utils.binaryToBinsize(value);
} }
@@ -387,10 +387,6 @@ return core.Class.extend({
attrs.fieldDependencies = attrs.Widget.prototype.fieldDependencies; attrs.fieldDependencies = attrs.Widget.prototype.fieldDependencies;
} }
if (attrs.Widget.prototype.context) {
attrs.context = attrs.Widget.prototype.context;
}
return attrs; return attrs;
}, },
/** /**
@@ -1854,7 +1854,7 @@ var BasicModel = AbstractModel.extend({
model: record.model, model: record.model,
method: 'read', method: 'read',
args: [[record.res_id], fieldNames], args: [[record.res_id], fieldNames],
context: record.getContext(), context: _.extend({}, record.getContext(), {bin_size: true}),
}) })
.then(function (result) { .then(function (result) {
if (result.length === 0) { if (result.length === 0) {
@@ -2374,7 +2374,7 @@ var BasicModel = AbstractModel.extend({
}); });
record.data[fieldName] = list.id; record.data[fieldName] = list.id;
if (!fieldInfo.__no_fetch) { if (!fieldInfo.__no_fetch) {
var def = self._readUngroupedList(list).then(function (list) { var def = self._readUngroupedList(list).then(function () {
return $.when( return $.when(
self._fetchX2ManysBatched(list), self._fetchX2ManysBatched(list),
self._fetchReferencesBatched(list) self._fetchReferencesBatched(list)
@@ -2403,6 +2403,7 @@ var BasicModel = AbstractModel.extend({
var fields = view ? view.fields : fieldInfo.relatedFields; var fields = view ? view.fields : fieldInfo.relatedFields;
var viewType = view ? view.type : fieldInfo.viewType; var viewType = view ? view.type : fieldInfo.viewType;
list = this._applyX2ManyOperations(list); list = this._applyX2ManyOperations(list);
this._sortList(list);
var x2mRecords = []; var x2mRecords = [];
// step 1: collect ids // step 1: collect ids
@@ -2640,7 +2641,7 @@ var BasicModel = AbstractModel.extend({
var relRecordAdded = []; var relRecordAdded = [];
var relRecordUpdated = []; var relRecordUpdated = [];
_.each(list._changes, function (change) { _.each(list._changes, function (change) {
if (change.operation === 'ADD') { if (change.operation === 'ADD' && change.id) {
relRecordAdded.push(self.localData[change.id]); relRecordAdded.push(self.localData[change.id]);
} else if (change.operation === 'UPDATE' && !self.isNew(change.id)) { } else if (change.operation === 'UPDATE' && !self.isNew(change.id)) {
// ignore new records that would have been updated // ignore new records that would have been updated
@@ -2699,6 +2700,10 @@ var BasicModel = AbstractModel.extend({
} else if (_.contains(addedIds, list.res_ids[i])) { } else if (_.contains(addedIds, list.res_ids[i])) {
// this is a new id (maybe existing in DB, but new in JS) // this is a new id (maybe existing in DB, but new in JS)
relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]}); relRecord = _.findWhere(relRecordAdded, {res_id: list.res_ids[i]});
if (!relRecord) {
commands[fieldName].push(x2ManyCommands.link_to(list.res_ids[i]));
continue;
}
changes = this._generateChanges(relRecord, options); changes = this._generateChanges(relRecord, options);
if (changes.id) { if (changes.id) {
// the subrecord already exists in db // the subrecord already exists in db
@@ -3092,23 +3097,13 @@ var BasicModel = AbstractModel.extend({
id: {type: 'integer'}, id: {type: 'integer'},
}, params.fields); }, params.fields);
// datapoint context is extended by fields widgets
var context = _.extend({}, params.context);
if (params.fieldsInfo && params.viewType) {
_.each(params.fieldsInfo[params.viewType], function (field) {
if (field.context) {
_.extend(context, field.context);
}
});
}
var dataPoint = { var dataPoint = {
_cache: type === 'list' ? {} : undefined, _cache: type === 'list' ? {} : undefined,
_changes: null, _changes: null,
_domains: {}, _domains: {},
_rawChanges: {}, _rawChanges: {},
aggregateValues: params.aggregateValues || {}, aggregateValues: params.aggregateValues || {},
context: context, context: params.context,
count: params.count || res_ids.length, count: params.count || res_ids.length,
data: data, data: data,
domain: params.domain || [], domain: params.domain || [],
@@ -167,6 +167,13 @@ var KanbanColumn = Widget.extend({
* Adds the quick create record to the top of the column. * Adds the quick create record to the top of the column.
*/ */
addQuickCreate: function () { addQuickCreate: function () {
if (this.folded) {
// first open the column, and then add the quick create
this.trigger_up('column_toggle_fold', {
openQuickCreate: true,
});
return;
}
if (this.quickCreateWidget) { if (this.quickCreateWidget) {
return; return;
} }
@@ -207,6 +207,7 @@ var KanbanController = BasicController.extend({
* @param {OdooEvent} event * @param {OdooEvent} event
*/ */
_onButtonClicked: function (event) { _onButtonClicked: function (event) {
event.stopPropagation();
var self = this; var self = this;
var attrs = event.data.attrs; var attrs = event.data.attrs;
var record = event.data.record; var record = event.data.record;
@@ -388,13 +389,18 @@ var KanbanController = BasicController.extend({
/** /**
* @private * @private
* @param {OdooEvent} event * @param {OdooEvent} event
* @param {boolean} [event.data.openQuickCreate=false] if true, opens the
* QuickCreate in the toggled column (it assumes that we are opening it)
*/ */
_onToggleColumn: function (event) { _onToggleColumn: function (event) {
var self = this; var self = this;
var column = event.target; var column = event.target;
this.model.toggleGroup(column.db_id).then(function (db_id) { this.model.toggleGroup(column.db_id).then(function (db_id) {
var data = self.model.get(db_id); var data = self.model.get(db_id);
self.renderer.updateColumn(db_id, data); var options = {
openQuickCreate: !!event.data.openQuickCreate,
};
self.renderer.updateColumn(db_id, data, options);
self._updateEnv(); self._updateEnv();
}); });
}, },
@@ -22,6 +22,7 @@ var KanbanModel = BasicModel.extend({
* @returns {Deferred<string>} resolves to the local id of the new record * @returns {Deferred<string>} resolves to the local id of the new record
*/ */
addRecordToGroup: function (groupID, resId) { addRecordToGroup: function (groupID, resId) {
var self = this;
var group = this.localData[groupID]; var group = this.localData[groupID];
var new_record = this._makeDataPoint({ var new_record = this._makeDataPoint({
res_id: resId, res_id: resId,
@@ -31,15 +32,16 @@ var KanbanModel = BasicModel.extend({
viewType: group.viewType, viewType: group.viewType,
parentID: groupID, parentID: groupID,
}); });
group.data.unshift(new_record.id);
group.res_ids.unshift(resId);
group.count++;
// update the res_ids and count of the parent
this.localData[group.parentID].count++;
this._updateParentResIDs(group);
return this._fetchRecord(new_record).then(function (result) { return this._fetchRecord(new_record).then(function (result) {
group.data.unshift(new_record.id);
group.res_ids.unshift(resId);
group.count++;
// update the res_ids and count of the parent
self.localData[group.parentID].count++;
self._updateParentResIDs(group);
return result.id; return result.id;
}); });
}, },
@@ -6916,6 +6916,7 @@ QUnit.module('relational_fields', {
if (args.method === 'read' && args.model === 'partner') { if (args.method === 'read' && args.model === 'partner') {
assert.deepEqual(args.kwargs.context, { assert.deepEqual(args.kwargs.context, {
active_field: 2, active_field: 2,
bin_size: true,
someKey: 'some value', someKey: 'some value',
}, "sent context should be correct"); }, "sent context should be correct");
} }
@@ -7639,6 +7640,74 @@ QUnit.module('relational_fields', {
form.destroy(); form.destroy();
}); });
QUnit.test('one2many with multiple pages and sequence field, part2', function (assert) {
assert.expect(1);
this.data.partner.records[0].turtles = [3, 2, 1];
this.data.partner.onchanges.turtles = function () {};
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<field name="turtles">' +
'<tree limit="2">' +
'<field name="turtle_int" widget="handle"/>' +
'<field name="turtle_foo"/>' +
'<field name="partner_ids" invisible="1"/>' +
'</tree>' +
'</field>' +
'</form>',
res_id: 1,
mockRPC: function (route, args) {
if (args.method === 'onchange') {
return $.when({value: { turtles: [
[5],
[1, 1, {turtle_foo: "from onchange id2", partner_ids: [[5]]}],
[1, 3, {turtle_foo: "from onchange id3", partner_ids: [[5]]}],
]}});
}
return this._super(route, args);
},
viewOptions: {
mode: 'edit',
},
});
form.$('span[name="delete"]').first().click();
assert.strictEqual(form.$('.o_data_row').text(), 'from onchange id2from onchange id3',
'onchange has been properly applied');
form.destroy();
});
QUnit.test('new record, with one2many with more default values than limit', function (assert) {
assert.expect(2);
var form = createView({
View: FormView,
model: 'partner',
data: this.data,
arch:'<form string="Partners">' +
'<field name="turtles">' +
'<tree limit="2">' +
'<field name="turtle_foo"/>' +
'</tree>' +
'</field>' +
'</form>',
context: { default_turtles: [1,2,3]},
viewOptions: {
mode: 'edit',
},
});
assert.strictEqual(form.$('.o_data_row').text(), 'yopblip',
'data has been properly loaded');
form.$buttons.find('.o_form_button_save').click();
assert.strictEqual(form.$('.o_data_row').text(), 'yopblip',
'data has been properly saved');
form.destroy();
});
QUnit.module('FieldMany2Many'); QUnit.module('FieldMany2Many');
QUnit.test('many2many kanban: edition', function (assert) { QUnit.test('many2many kanban: edition', function (assert) {
@@ -74,6 +74,7 @@ QUnit.module('Views', {
mockRPC: function (route, args) { mockRPC: function (route, args) {
assert.deepEqual(args.kwargs.context, { assert.deepEqual(args.kwargs.context, {
active_field: 2, active_field: 2,
bin_size: true,
someKey: 'some value', someKey: 'some value',
}, "should have sent the correct context"); }, "should have sent the correct context");
return this._super.apply(this, arguments); return this._super.apply(this, arguments);
+3 -1
View File
@@ -199,7 +199,7 @@ QUnit.module('Views', {
}); });
QUnit.test('only necessary fields are fetched with correct context', function (assert) { QUnit.test('only necessary fields are fetched with correct context', function (assert) {
assert.expect(1); assert.expect(2);
var form = createView({ var form = createView({
View: FormView, View: FormView,
@@ -214,6 +214,8 @@ QUnit.module('Views', {
// field, not sure why. Maybe this test should be modified. // field, not sure why. Maybe this test should be modified.
assert.deepEqual(args.args[1], ["foo", "display_name"], assert.deepEqual(args.args[1], ["foo", "display_name"],
"should only fetch requested fields"); "should only fetch requested fields");
assert.deepEqual(args.kwargs.context, {bin_size: true},
"bin_size should always be in the context");
return this._super(route, args); return this._super(route, args);
} }
}); });
+146 -1
View File
@@ -33,9 +33,10 @@ QUnit.module('Views', {
state: { string: "State", type: "selection", selection: [["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"]]}, state: { string: "State", type: "selection", selection: [["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"]]},
date: {string: "Date Field", type: 'date'}, date: {string: "Date Field", type: 'date'},
datetime: {string: "Datetime Field", type: 'datetime'}, datetime: {string: "Datetime Field", type: 'datetime'},
image: {string: "Image", type: "binary"},
}, },
records: [ records: [
{id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: []}, {id: 1, bar: true, foo: "yop", int_field: 10, qux: 0.4, product_id: 3, state: "abc", category_ids: [], 'image': 'R0lGODlhAQABAAD/ACwAAAAAAQABAAACAA=='},
{id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6]}, {id: 2, bar: true, foo: "blip", int_field: 9, qux: 13, product_id: 5, state: "def", category_ids: [6]},
{id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7]}, {id: 3, bar: true, foo: "gnap", int_field: 17, qux: -3, product_id: 3, state: "ghi", category_ids: [7]},
{id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: []}, {id: 4, bar: false, foo: "blip", int_field: -4, qux: 9, product_id: 5, state: "ghi", category_ids: []},
@@ -440,6 +441,50 @@ QUnit.module('Views', {
kanban.destroy(); kanban.destroy();
}); });
QUnit.test('quick create when first column is folded', function (assert) {
assert.expect(6);
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: '<kanban on_create="quick_create">' +
'<field name="bar"/>' +
'<templates><t t-name="kanban-box">' +
'<div><field name="foo"/></div>' +
'</t></templates></kanban>',
groupBy: ['bar'],
});
assert.notOk(kanban.$('.o_kanban_group:first').hasClass('o_column_folded'),
"first column should not be folded");
// fold the first column
kanban.$('.o_kanban_group:first .o_kanban_toggle_fold').click();
assert.ok(kanban.$('.o_kanban_group:first').hasClass('o_column_folded'),
"first column should be folded");
// click on 'Create' to open the quick create in the first column
kanban.$buttons.find('.o-kanban-button-new').click();
assert.notOk(kanban.$('.o_kanban_group:first').hasClass('o_column_folded'),
"first column should no longer be folded");
var $quickCreate = kanban.$('.o_kanban_group:first .o_kanban_quick_create');
assert.strictEqual($quickCreate.length, 1,
"should have added a quick create element in first column");
// fold again the first column
kanban.$('.o_kanban_group:first .o_kanban_toggle_fold').click();
assert.ok(kanban.$('.o_kanban_group:first').hasClass('o_column_folded'),
"first column should be folded");
assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 0,
"there should be no more quick create");
kanban.destroy();
});
QUnit.test('quick create and edit in grouped mode', function (assert) { QUnit.test('quick create and edit in grouped mode', function (assert) {
assert.expect(6); assert.expect(6);
@@ -545,6 +590,80 @@ QUnit.module('Views', {
kanban.destroy(); kanban.destroy();
}); });
QUnit.test('quick create several records on slow network', function (assert) {
assert.expect(6);
var self = this;
var def;
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: '<kanban class="o_kanban_test" on_create="quick_create">' +
'<field name="bar"/>' +
'<templates><t t-name="kanban-box">' +
'<div><field name="foo"/></div>' +
'</t></templates></kanban>',
groupBy: ['bar'],
mockRPC: function (route, args) {
var result = this._super.apply(this, arguments);
if (args.method === 'name_create') {
return result.then(function (result) {
// simulate a dynamic default value for foo based on display_name,
// so that we can check that the record has been fetched before
// being displayed
var newRec = _.findWhere(self.data.partner.records, {id: result[0]});
newRec.foo = newRec.display_name;
return result;
});
}
if (args.method === 'read') {
return $.when(def).then(_.constant(result));
}
return result;
},
});
assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 1,
"first column should contain one record");
// click to add a record, and add two in a row (first one will be delayed)
kanban.$('.o_kanban_header .o_kanban_quick_add i').first().click();
assert.strictEqual(kanban.$('.o_kanban_quick_create').length, 1,
"the quick create should be open");
def = $.Deferred();
kanban.$('.o_kanban_quick_create input')
.val('new partner 1')
.trigger($.Event('keypress', {
which: $.ui.keyCode.ENTER,
keyCode: $.ui.keyCode.ENTER,
}));
var firstDef = def;
def = null;
kanban.$('.o_kanban_quick_create input')
.val('new partner 2')
.trigger($.Event('keypress', {
which: $.ui.keyCode.ENTER,
keyCode: $.ui.keyCode.ENTER,
}));
assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 2,
"first column should now contain two records");
assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record:first').text(),
'new partner 2', "new record should have been fetched");
firstDef.resolve();
assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record').length, 3,
"first column should now contain three records");
assert.strictEqual(kanban.$('.o_kanban_group:first .o_kanban_record:first').text(),
'new partner 1', "new record should have been fetched");
kanban.destroy();
});
QUnit.test('quick create fail in grouped', function (assert) { QUnit.test('quick create fail in grouped', function (assert) {
assert.expect(7); assert.expect(7);
@@ -2339,6 +2458,32 @@ QUnit.module('Views', {
"quick create should have been added in the first column"); "quick create should have been added in the first column");
} }
}); });
QUnit.test('test displaying image', function (assert) {
assert.expect(2);
var kanban = createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: '<kanban class="o_kanban_test">' +
'<field name="id"/>' +
'<field name="image"/>' +
'<templates><t t-name="kanban-box"><div>' +
'<img t-att-src="kanban_image(\'partner\', \'image\', record.id.raw_value)"/>' +
'</div></t></templates>' +
'</kanban>',
});
var imageOnRecord = kanban.$('img[src*="/web/image"][src*="&id=1"]');
assert.strictEqual(imageOnRecord.length, 1, "partner with image display image");
var placeholders = kanban.$('img[src$="/web/static/src/img/placeholder.png"]');
assert.strictEqual(placeholders.length, this.data.partner.records.length - 1,
"partner with image should displaiy");
kanban.destroy();
});
}); });
}); });
+1 -1
View File
@@ -13,7 +13,7 @@ class WebSuite(odoo.tests.HttpCase):
def test_01_js(self): def test_01_js(self):
# webclient desktop test suite # webclient desktop test suite
self.phantom_js('/web/tests?mod=web', "", "", login='admin', timeout=300) self.phantom_js('/web/tests?mod=web', "", "", login='admin', timeout=360)
def test_02_js(self): def test_02_js(self):
# webclient mobile test suite # webclient mobile test suite
@@ -374,10 +374,11 @@ var RTEWidget = Widget.extend({
* @param {boolean} internal_history * @param {boolean} internal_history
*/ */
historyRecordUndo: function ($target, event, internal_history) { historyRecordUndo: function ($target, event, internal_history) {
$target = $($target);
var rng = range.create(); var rng = range.create();
var $editable = $(rng && rng.sc).closest('.o_editable'); var $editable = $(rng && rng.sc).closest('.o_editable');
if (!rng || !$editable.length) { if (!rng || !$editable.length) {
$editable = $($target).closest('.o_editable'); $editable = $target.closest('.o_editable');
rng = range.create($target.closest('*')[0],0); rng = range.create($target.closest('*')[0],0);
} else { } else {
rng = $editable.data('range') || rng; rng = $editable.data('range') || rng;
@@ -846,37 +846,67 @@ range.WrappedRange.prototype.reRange = function (keep_end, isNotBreakable) {
return new range.WrappedRange(sc, so, ec, eo); return new range.WrappedRange(sc, so, ec, eo);
}; };
// isOnImg: judge whether range is an image node or not /**
* Returns the image the range is in or matches (if any, false otherwise).
*
* @todo this implementation may not cover all corner cases but should do the
* trick for all reproductible ones
* @returns {DOMElement|boolean}
*/
range.WrappedRange.prototype.isOnImg = function () { range.WrappedRange.prototype.isOnImg = function () {
// If not a selection but a cursor position, just check if a point's
// ancestor is an image or not
if (this.sc === this.ec && this.so === this.eo) {
return dom.ancestor(this.sc, dom.isImg);
}
var startPoint = {node: this.sc, offset: this.so};
var endPoint = {node: this.ec, offset: this.eo};
var nb = 0; var nb = 0;
var image; var image;
var startPoint = {node: this.sc.childNodes.length && this.sc.childNodes[this.so] || this.sc}; var textNode;
startPoint.offset = startPoint.node === this.sc ? this.so : 0;
var endPoint = {node: this.ec.childNodes.length && this.ec.childNodes[this.eo] || this.ec};
endPoint.offset = endPoint.node === this.ec ? this.eo : 0;
if (dom.isImg(startPoint.node)) {
nb ++;
image = startPoint.node;
}
dom.walkPoint(startPoint, endPoint, function (point) { dom.walkPoint(startPoint, endPoint, function (point) {
if (!dom.isText(endPoint.node) && point.node === endPoint.node && point.offset === endPoint.offset) { // If the element has children (not a text node and not empty node),
// the element cannot be considered as selected (these children will
// be processed to determine that)
if (dom.hasChildren(point.node)) {
return; return;
} }
var node = point.node.childNodes.length && point.node.childNodes[point.offset] || point.node;
var offset = node === point.node ? point.offset : 0; // Check if an ancestor of the current point is an image
var isImg = dom.ancestor(node, dom.isImg); var pointImg = dom.ancestor(point.node, dom.isImg);
if (!isImg && ((!dom.isBR(node) && !dom.isText(node)) || (offset && node.textContent.length !== offset && node.textContent.match(/\S|\u00A0/)))) { var isText = dom.isText(point.node);
// Check if a visible element is selected, i.e.
// - If an ancestor of the current is an image we did not see yet
// - If the point is not in a br or a text (so a node with no children)
// - If the point is in a non empty text node we already saw
if (pointImg ?
(image !== pointImg) :
((!dom.isBR(point.node) && !isText) || (textNode === point.node && point.node.textContent.match(/\S|\u00A0/)))) {
nb++; nb++;
} }
if (isImg && image !== isImg) {
image = isImg; // If an ancestor of the current point is an image, then save it as the
nb ++; // image we are looking for
if (pointImg) {
image = pointImg;
}
// If the current point is a text node save it as the last text node
// seen (if we see it again, this might mean it is selected)
if (isText) {
textNode = point.node;
} }
}); });
return nb === 1 && image; return nb === 1 && image;
}; };
range.WrappedRange.prototype.deleteContents = function (towrite) { range.WrappedRange.prototype.deleteContents = function (towrite) {
if (this.sc === this.ec && this.so === this.eo) {
return this;
}
var prevBP = dom.removeBetween(this.sc, this.so, this.ec, this.eo, towrite); var prevBP = dom.removeBetween(this.sc, this.so, this.ec, this.eo, towrite);
$(dom.node(prevBP.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler $(dom.node(prevBP.sc)).trigger("click"); // trigger click to disable and reanable editor and image handler
@@ -22,7 +22,7 @@ odoo.define('website_form.animation', function (require) {
start: function () { start: function () {
var self = this; var self = this;
qweb.add_template('/website_form/static/src/xml/website_form.xml'); this.templates_loaded = ajax.loadXML('/website_form/static/src/xml/website_form.xml', qweb);
this.$target.find('.o_website_form_send').on('click',function (e) {self.send(e);}); this.$target.find('.o_website_form_send').on('click',function (e) {self.send(e);});
// Initialize datetimepickers // Initialize datetimepickers
@@ -233,7 +233,10 @@ odoo.define('website_form.animation', function (require) {
if (status !== 'success') { // Restore send button behavior if result is an error if (status !== 'success') { // Restore send button behavior if result is an error
this.$target.find('.o_website_form_send').on('click',function (e) {self.send(e);}); this.$target.find('.o_website_form_send').on('click',function (e) {self.send(e);});
} }
this.$target.find('#o_website_form_result').replaceWith(qweb.render("website_form.status_" + status)); var $result = this.$('#o_website_form_result');
this.templates_loaded.done(function () {
$result.replaceWith(qweb.render("website_form.status_" + status));
});
}, },
}); });
}); });
@@ -21,7 +21,10 @@ $(document).ready(function () {
setTimeout(function () { payment_transaction_poll_status(); }, Math.ceil(_poll_nbr / 3) * 1000); setTimeout(function () { payment_transaction_poll_status(); }, Math.ceil(_poll_nbr / 3) * 1000);
} }
else { else {
result.message = "<i class='fa fa-warning' />"; var $message = $(result.message);
$message.find('span:first').prepend($(
"<i title='We are waiting the confirmation of the bank or payment provider' class='fa fa-warning' style='margin-right:10px;'>"));
result.message = $message.html();
} }
} }
$('div.oe_website_sale_tx_status').html(result.message); $('div.oe_website_sale_tx_status').html(result.message);
+1
View File
@@ -684,6 +684,7 @@
</template> </template>
<template id="wizard_checkout" name="Wizard Checkout"> <template id="wizard_checkout" name="Wizard Checkout">
<t t-set="website_sale_order" t-value="website.sale_get_order()"/>
<ul class="wizard pull-right hidden-xs"> <ul class="wizard pull-right hidden-xs">
<li t-att-class="step==10 and 'text-primary' or 'text-muted'" id="step10"> <li t-att-class="step==10 and 'text-primary' or 'text-muted'" id="step10">
<t t-set="step_review_order" t-value="step&gt;10 and step&lt;50"/> <t t-set="step_review_order" t-value="step&gt;10 and step&lt;50"/>
@@ -11,12 +11,14 @@ odoo.define('website_sale_delivery.checkout', function (require) {
var $amount_delivery = $('#order_delivery span.oe_currency_value'); var $amount_delivery = $('#order_delivery span.oe_currency_value');
var $amount_untaxed = $('#order_total_untaxed span.oe_currency_value'); var $amount_untaxed = $('#order_total_untaxed span.oe_currency_value');
var $amount_tax = $('#order_total_taxes span.oe_currency_value'); var $amount_tax = $('#order_total_taxes span.oe_currency_value');
var $amount_total = $('#order_total span.oe_currency_value');
var $carrier_badge = $('#delivery_carrier input[name="delivery_type"][value=' + result.carrier_id + '] ~ .badge.hidden'); var $carrier_badge = $('#delivery_carrier input[name="delivery_type"][value=' + result.carrier_id + '] ~ .badge.hidden');
var $compute_badge = $('#delivery_carrier input[name="delivery_type"][value=' + result.carrier_id + '] ~ .o_delivery_compute'); var $compute_badge = $('#delivery_carrier input[name="delivery_type"][value=' + result.carrier_id + '] ~ .o_delivery_compute');
if (result.status === true) { if (result.status === true) {
$amount_delivery.text(result.new_amount_delivery); $amount_delivery.text(result.new_amount_delivery);
$amount_untaxed.text(result.new_amount_untaxed); $amount_untaxed.text(result.new_amount_untaxed);
$amount_tax.text(result.new_amount_tax); $amount_tax.text(result.new_amount_tax);
$amount_total.text(result.new_amount_delivery + result.new_amount_untaxed + result.new_amount_tax);
$carrier_badge.children('span').text(result.new_amount_delivery); $carrier_badge.children('span').text(result.new_amount_delivery);
$carrier_badge.removeClass('hidden'); $carrier_badge.removeClass('hidden');
$compute_badge.addClass('hidden'); $compute_badge.addClass('hidden');
@@ -7,12 +7,12 @@ var core = require('web.core');
var time = require('web.time'); var time = require('web.time');
var Widget = require('web.Widget'); var Widget = require('web.Widget');
var local_storage = require('web.local_storage'); var local_storage = require('web.local_storage');
var websiteRootInstance = require('website.WebsiteRoot.instance');
var _t = core._t; var _t = core._t;
var page_widgets = {}; var page_widgets = {};
$(document).ready(function () { (function () {
var widget_parent = $('body'); var widget_parent = $('body');
@@ -23,7 +23,7 @@ $(document).ready(function () {
// then return fix formate string else timeago // then return fix formate string else timeago
display_str = ""; display_str = "";
if (datetime_obj && new Date().getTime() - datetime_obj.getTime() > 7 * 24 * 60 * 60 * 1000) { if (datetime_obj && new Date().getTime() - datetime_obj.getTime() > 7 * 24 * 60 * 60 * 1000) {
display_str = datetime_obj.toDateString(); display_str = moment(datetime_obj).format('ll');
} else { } else {
display_str = moment(datetime_obj).fromNow(); display_str = moment(datetime_obj).fromNow();
} }
@@ -216,7 +216,7 @@ $(document).ready(function () {
}); });
}); });
} }
}); })();
return { return {
page_widgets: page_widgets, page_widgets: page_widgets,
@@ -112,11 +112,6 @@
<i class="fa fa-cloud-upload"></i> Upload <i class="fa fa-cloud-upload"></i> Upload
</a> </a>
</t> </t>
<t t-if="channel.can_upload and is_public_user">
<a class="btn btn-primary" title="Upload Presentation" t-attf-href="/web/login?redirect=/slides/#{slug(channel)}">
<i class="fa fa-cloud-upload"></i> Upload
</a>
</t>
</h3> </h3>
</div> </div>
<!-- Search box --> <!-- Search box -->
-16
View File
@@ -1,16 +0,0 @@
Netherlands, 8-2-2015
BAS Solutions agrees to the terms of the Odoo Corporate Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Erwin van der Ploeg erwin@bas-solutions.nl https://github.com/erwin-bas-solutions
List of contributors:
Erwin van der Ploeg erwin@bas-solutions.nl https://github.com/erwin-bas-solutions
Ruchir Shukla ruchir@bas-solutions.nl https://github.com/ruchir-bas-solutions
+16
View File
@@ -0,0 +1,16 @@
Netherlands, 18-1-2018
Odoo Experts agrees to the terms of the Odoo Corporate Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Erwin van der Ploeg erwin@bas-solutions.nl https://github.com/erwin-bas-solutions
List of contributors:
Erwin van der Ploeg erwin@odooexperts.nl https://github.com/ploegvde
Yenthe Van Ginneken yenthe@odooexperts.nl https://github.com/Yenthe666
+11
View File
@@ -0,0 +1,11 @@
Vietnam, 2018-01-16,
I hereby agree to the terms of the Odoo Individual Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Nguyen Duc Tam nguyenductamlhp@gmail.com https://github.com/nguyenductamlhp
-7
View File
@@ -360,13 +360,6 @@ Source installation requires manually installing dependencies:
Install ``psycopg`` using the installer here Install ``psycopg`` using the installer here
http://www.stickpeople.com/projects/python/win-psycopg/ http://www.stickpeople.com/projects/python/win-psycopg/
Then edit the requirements.txt file:
- remove ``psycopg2`` as you already have it.
- remove the optional ``python-ldap``, ``gevent`` and ``psutil`` because
they require compilation.
- add ``pypiwin32`` because it's needed under windows.
Then use pip to install the dependencies using the following Then use pip to install the dependencies using the following
command from a cmd.exe prompt (replace ``\YourOdooPath`` by the actual command from a cmd.exe prompt (replace ``\YourOdooPath`` by the actual
path where you downloaded Odoo): path where you downloaded Odoo):
+38 -8
View File
@@ -82,8 +82,9 @@ Overview
The Credits The Credits
Every service provided through the In-App platform can be used by the Every service provided through the In-App platform can be used by the
clients with tokens or *credits*. The monetary value of a credit depends clients with tokens or *credits*. The credits are an integer unit and
on the service and is decided by the provider. This could be: their monetary value depends on the service and is decided by the
provider. This could be:
* for an sms service: 1 credit = 1 sms, * for an sms service: 1 credit = 1 sms,
* for an add service: 1 credit = 1 add, * for an add service: 1 credit = 1 add,
@@ -210,14 +211,20 @@ The credit packages are essentially a product with 4 characteristics.
* Credits: the amount of credits the client is entitled to when buying the package, * Credits: the amount of credits the client is entitled to when buying the package,
* Price: the price in *EUROS* for the time being (USD support is planned). * Price: the price in *EUROS* for the time being (USD support is planned).
.. image:: images/package.png .. note::
:align: center
Odoo takes a 25% commission on all package sales. Adjust your selling price accordingly.
.. note:: .. note::
Depending on the strategy, the price per credit can vary from one Depending on the strategy, the price per credit can vary from one
package to another. package to another.
.. image:: images/package.png
:align: center
.. _iap-odoo-app: .. _iap-odoo-app:
Odoo App Odoo App
@@ -309,15 +316,22 @@ perform the service within:
.. todo:: "My Account" > "Your InApp Services"? .. todo:: "My Account" > "Your InApp Services"?
The :class:`~odoo.addons.iap.models.iap.charge` helper will: The :class:`~odoo.addons.iap.models.iap.charge` helper will:
.. note::
Since the 15th of January 2018, a new functionality that allows one to capture a different amount than autorized has been added.
See :ref:`Charging <iap-charging>`
1. authorize (create) a transaction with the specified number of credits, 1. authorize (create) a transaction with the specified number of credits,
if the account does not have enough credits it will raise the relevant if the account does not have enough credits it will raise the relevant
error error
2. execute the body of the ``with`` statement 2. execute the body of the ``with`` statement
3. if the body of the ``with`` executes succesfully, capture (confirm) the 3. (NEW) if the body of the ``with`` executes succesfully, update the price
transaction of the transaction if needed
4. otherwise if an error is raised from the body of the ``with`` cancel the 4. capture (confirm) the transaction
5. otherwise if an error is raised from the body of the ``with`` cancel the
transaction (and release the hold on the credits) transaction (and release the hold on the credits)
.. danger:: .. danger::
@@ -420,9 +434,11 @@ Capture
:param TransactionToken token: :param TransactionToken token:
:param ServiceKey key: :param ServiceKey key:
:param int credit_to_capture: (new - 15 Jan 2018) optional parameter to capture a smaller amount of credits than authorized
:raises: :class:`~odoo.exceptions.AccessError` :raises: :class:`~odoo.exceptions.AccessError`
.. code-block:: python .. code-block:: python
:emphasize-lines: 8
r2 = requests.post(ODOO + '/iap/1/capture', json={ r2 = requests.post(ODOO + '/iap/1/capture', json={
'jsonrpc': '2.0', 'jsonrpc': '2.0',
@@ -431,6 +447,7 @@ Capture
'params': { 'params': {
'token': tx, 'token': tx,
'key': SERVICE_KEY, 'key': SERVICE_KEY,
'credit_to_capture': credit or False,
} }
}).json() }).json()
if 'error' in r: if 'error' in r:
@@ -523,9 +540,17 @@ Odoo Helpers
For convenience, if you are implementing your service using Odoo the ``iap`` For convenience, if you are implementing your service using Odoo the ``iap``
module provides a few helpers to make IAP flow even simpler: module provides a few helpers to make IAP flow even simpler:
.. _iap-charging:
Charging Charging
-------- --------
.. note::
A new functionality was introduced to capture a different amount of credits than reserved.
As this patch was added on the 15th of January 2018, you will need to upgrade your ``iap`` module in order to use it.
The specifics of the new functionality are highlighted in the code.
.. class:: odoo.addons.iap.models.iap.charge(env, key, account_token, credit[, description, credit_template]) .. class:: odoo.addons.iap.models.iap.charge(env, key, account_token, credit[, description, credit_template])
A *context manager* for authorizing and automatically capturing or A *context manager* for authorizing and automatically capturing or
@@ -544,8 +569,10 @@ Charging
:param UserToken token: :param UserToken token:
:param int credit: :param int credit:
:param str description: :param str description:
:param Qweb template credit_template:
.. code-block:: python .. code-block:: python
:emphasize-lines: 10,13,14,15
@route('/deathstar/superlaser', type='json') @route('/deathstar/superlaser', type='json')
def superlaser(self, user_account, def superlaser(self, user_account,
@@ -556,8 +583,11 @@ Charging
0.0 is none, 1.0 is full power 0.0 is none, 1.0 is full power
""" """
credits = int(MAXIMUM_POWER * factor) credits = int(MAXIMUM_POWER * factor)
with charge(request.env, SERVICE_KEY, user_account, credits): with charge(request.env, SERVICE_KEY, user_account, credits) as transaction:
# TODO: allow other targets # TODO: allow other targets
transaction.credit = max(credits, 2)
# Sales ongoing one the energy price,
# a maximum of 2 credits will be charged/captured.
self.env['systems.planets'].search([ self.env['systems.planets'].search([
('grid', '=', 'M-10'), ('grid', '=', 'M-10'),
('name', '=', 'Alderaan'), ('name', '=', 'Alderaan'),
+1 -1
View File
@@ -1597,7 +1597,7 @@
<field name="code">tl</field> <field name="code">tl</field>
<field file="base/static/img/country_flags/tl.png" name="image" type="base64" /> <field file="base/static/img/country_flags/tl.png" name="image" type="base64" />
<field name="currency_id" ref="USD" /> <field name="currency_id" ref="USD" />
<field eval="0" name="phone_code" /> <field eval="670" name="phone_code" />
</record> </record>
<record id="tr" model="res.country"> <record id="tr" model="res.country">
<field name="name">Turkey</field> <field name="name">Turkey</field>
+1 -1
View File
@@ -678,7 +678,7 @@ class IrActionsReport(models.Model):
""" """
discard_logo_check = self.env.context.get('discard_logo_check') discard_logo_check = self.env.context.get('discard_logo_check')
if (self.env.uid == SUPERUSER_ID) and ((not self.env.user.company_id.external_report_layout) or (not discard_logo_check and not self.env.user.company_id.logo)) and config: if (self.env.uid == SUPERUSER_ID) and ((not self.env.user.company_id.external_report_layout) or (not discard_logo_check and not self.env.user.company_id.logo)) and config:
template = self.env.ref('base.view_company_report_form_with_print') template = self.env.ref('base.view_company_report_form_with_print') if self.env.context.get('from_transient_model', False) else self.env.ref('base.view_company_report_form')
return { return {
'name': _('Choose Your Document Layout'), 'name': _('Choose Your Document Layout'),
'type': 'ir.actions.act_window', 'type': 'ir.actions.act_window',
+9 -8
View File
@@ -3,22 +3,23 @@ decorator==4.0.10
docutils==0.12 docutils==0.12
ebaysdk==2.1.5 ebaysdk==2.1.5
feedparser==5.2.1 feedparser==5.2.1
gevent==1.1.2 gevent==1.1.2 ; sys_platform != 'win32'
greenlet==0.4.10 greenlet==0.4.10
html2text==2016.9.19 html2text==2016.9.19
Jinja2==2.8 Jinja2==2.8
lxml==3.5.0 lxml==3.7.1 ; sys_platform != 'win32'
lxml ; sys_platform == 'win32'
Mako==1.0.4 Mako==1.0.4
MarkupSafe==0.23 MarkupSafe==0.23
mock==2.0.0 mock==2.0.0
num2words==0.5.4 num2words==0.5.4
ofxparse==0.16 ofxparse==0.16
passlib==1.6.5 passlib==1.6.5
Pillow==3.4.1 Pillow==4.0.0
psutil==4.3.1 psutil==4.3.1; sys_platform != 'win32'
psycopg2==2.7.3.1 psycopg2==2.7.3.1; sys_platform != 'win32'
pydot==1.2.3 pydot==1.2.3
pyldap==2.4.28 pyldap==2.4.28; sys_platform != 'win32'
pyparsing==2.1.10 pyparsing==2.1.10
PyPDF2==1.26.0 PyPDF2==1.26.0
pyserial==3.1.1 pyserial==3.1.1
@@ -29,11 +30,11 @@ PyYAML==3.12
qrcode==5.3 qrcode==5.3
reportlab==3.3.0 reportlab==3.3.0
requests==2.11.1 requests==2.11.1
six==1.10.0
suds-jurko==0.6 suds-jurko==0.6
vatnumber==1.2 vatnumber==1.2
vobject==0.9.3 vobject==0.9.3
Werkzeug==0.11.11 Werkzeug==0.11.15
XlsxWriter==0.9.3 XlsxWriter==0.9.3
xlwt==1.3.* xlwt==1.3.*
xlrd==1.0.0 xlrd==1.0.0
pypiwin32 ; sys_platform == 'win32'