[MERGE] forward port branch 11.0 up to 950d9b2369
This commit is contained in:
@@ -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)
|
||||
user_id = fields.Many2one('res.users', string='Salesperson', track_visibility='onchange',
|
||||
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',
|
||||
readonly=True, states={'draft': [('readonly', False)]})
|
||||
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,
|
||||
'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)
|
||||
return res
|
||||
|
||||
@@ -1445,21 +1443,6 @@ class AccountInvoiceLine(models.Model):
|
||||
_description = "Invoice Line"
|
||||
_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.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity',
|
||||
'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id',
|
||||
|
||||
@@ -174,7 +174,6 @@ Best Regards,'''))
|
||||
def setting_init_fiscal_year_action(self):
|
||||
""" Called by the 'Fiscal Year Opening' button of the setup bar."""
|
||||
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})
|
||||
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)
|
||||
|
||||
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({
|
||||
'name': _('Opening Journal Entry'),
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-foreach="range(max(5-len(invoice.invoice_line_ids),0))" t-as="l">
|
||||
<td>&nbsp;</td>
|
||||
<td t-translation="off">&nbsp;</td>
|
||||
<td class="hidden"></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-foreach="range(max(5-len(o.invoice_line_ids),0))" t-as="l">
|
||||
<td>&nbsp;</td>
|
||||
<td t-translation="off">&nbsp;</td>
|
||||
<td class="hidden"></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
@@ -112,18 +112,18 @@
|
||||
<t t-foreach="o._get_tax_amount_by_group()" t-as="amount_by_group">
|
||||
<tr style="border-bottom:1px solid #dddddd;">
|
||||
<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>
|
||||
<span t-esc="amount_by_group[0]"/>
|
||||
<span>&nbsp;<span>on</span>
|
||||
<t t-esc="amount_by_group[4]"/>
|
||||
</span>
|
||||
</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">
|
||||
<span t-esc="amount_by_group[3]" />
|
||||
</td>
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
<label for="currency_exchange_journal_id" class="col-md-3 o_light_label"/>
|
||||
<field name="currency_exchange_journal_id"/>
|
||||
</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>
|
||||
|
||||
@@ -402,5 +402,57 @@ QUnit.test('non-existing action in a dashboard', function (assert) {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -122,10 +122,10 @@
|
||||
<field name="stop" 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="stop_date" string="Ending 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="{'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"/>
|
||||
<label for="duration" attrs="{'invisible': [('allday','=',True)]}"/>
|
||||
<div attrs="{'invisible': [('allday','=',True)]}">
|
||||
|
||||
@@ -9,6 +9,7 @@ import werkzeug.urls
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import RedirectWarning, UserError
|
||||
from odoo.tools import pycompat
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
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)
|
||||
: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:
|
||||
raise UserError(_("Creating google drive may only be done by one at a time."))
|
||||
# check if a model is configured with a template
|
||||
|
||||
@@ -1,78 +1,109 @@
|
||||
odoo.define('google_drive.google_drive', function (require) {
|
||||
odoo.define('google_drive.sidebar', function (require) {
|
||||
"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');
|
||||
|
||||
|
||||
Sidebar.include({
|
||||
init: function () {
|
||||
var self = this;
|
||||
var ids;
|
||||
this._super.apply(this, arguments);
|
||||
var view = self.getParent();
|
||||
var result;
|
||||
if (view.fields_view && view.fields_view.type === "form") {
|
||||
ids = [];
|
||||
view.on("load_record", self, function (r) {
|
||||
ids = [r.id];
|
||||
self.add_gdoc_items(view, r.id);
|
||||
});
|
||||
// TO DO: clean me in master
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var def;
|
||||
if (this.options.viewType === "form") {
|
||||
def = this._addGoogleDocItems(this.env.model, this.env.activeIds[0]);
|
||||
}
|
||||
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;
|
||||
if (!resID) {
|
||||
return $.when();
|
||||
}
|
||||
var gdoc_item = _.indexOf(_.pluck(self.items.other, 'classname'), 'oe_share_gdoc');
|
||||
if (gdoc_item !== -1) {
|
||||
self.items.other.splice(gdoc_item, 1);
|
||||
}
|
||||
if (res_id) {
|
||||
view.sidebar_eval_context().done(function (context) {
|
||||
var ds = new data.DataSet(this, 'google.drive.config', context);
|
||||
ds.call('get_google_drive_config', [view.dataset.model, res_id, context]).done(function (r) {
|
||||
if (!_.isEmpty(r)) {
|
||||
_.each(r, function (res) {
|
||||
var already_there = false;
|
||||
for (var i = 0;i < self.items.other.length;i++){
|
||||
if (self.items.other[i].classname === "oe_share_gdoc" && self.items.other[i].label.indexOf(res.name) > -1){
|
||||
already_there = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!already_there){
|
||||
self.add_items('other', [{
|
||||
label: res.name,
|
||||
config_id: res.id,
|
||||
res_id: res_id,
|
||||
res_model: view.dataset.model,
|
||||
callback: self.on_google_doc,
|
||||
classname: 'oe_share_gdoc'
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
return this._rpc({
|
||||
args: [this.env.model, resID],
|
||||
context: this.env.context,
|
||||
method: 'get_google_drive_config',
|
||||
model: 'google.drive.config',
|
||||
}).then(function (r) {
|
||||
if (!_.isEmpty(r)) {
|
||||
_.each(r, function (res) {
|
||||
var already_there = false;
|
||||
for (var i = 0; i < self.items.other.length; i++) {
|
||||
var item = self.items.other[i];
|
||||
if (item.classname === 'oe_share_gdoc' && item.label.indexOf(res.name) > -1) {
|
||||
already_there = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!already_there) {
|
||||
self._addItems('other', [{
|
||||
callback: self._onGoogleDocItemClicked.bind(self, res.id, resID),
|
||||
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 domain = [['id', '=', doc_item.config_id]];
|
||||
var domain = [['id', '=', configID]];
|
||||
var fields = ['google_drive_resource_id', 'google_drive_client_id'];
|
||||
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',
|
||||
method: 'search_read',
|
||||
args: [domain, fields],
|
||||
})
|
||||
.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');
|
||||
}
|
||||
});
|
||||
}).then(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>
|
||||
</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>
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
<field eval="25" name="priority"/>
|
||||
<field name="arch" type="xml">
|
||||
<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"/>
|
||||
<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')]}"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box">
|
||||
<div class="oe_button_box" invisible="context.get('expense_adding_line')">
|
||||
<button name="action_get_attachment_view"
|
||||
class="oe_stat_button"
|
||||
icon="fa-book"
|
||||
@@ -419,7 +419,7 @@
|
||||
</group>
|
||||
<notebook>
|
||||
<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">
|
||||
<field name="date"/>
|
||||
<field name="name"/>
|
||||
|
||||
@@ -31,4 +31,4 @@ class HolidaysSummaryDept(models.TransientModel):
|
||||
'model': 'hr.department',
|
||||
'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)
|
||||
|
||||
@@ -102,6 +102,7 @@ class HrPayslip(models.Model):
|
||||
def refund_sheet(self):
|
||||
for payslip in self:
|
||||
copied_payslip = payslip.copy({'credit_note': True, 'name': _('Refund: ') + payslip.name})
|
||||
copied_payslip.compute_sheet()
|
||||
copied_payslip.action_payslip_done()
|
||||
formview_ref = self.env.ref('hr_payroll.view_hr_payslip_form', False)
|
||||
treeview_ref = self.env.ref('hr_payroll.view_hr_payslip_tree', False)
|
||||
|
||||
@@ -58,4 +58,19 @@ class Task(models.Model):
|
||||
if context.get('default_parent_id', False):
|
||||
vals['parent_id'] = context.pop('default_parent_id', None)
|
||||
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_write" eval="1"/>
|
||||
<field name="perm_unlink" eval="1"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
<field name="perm_read" eval="0"/>
|
||||
</record>
|
||||
|
||||
<record id="timesheet_rule_portal" model="ir.rule">
|
||||
@@ -68,4 +68,4 @@
|
||||
|
||||
</data>
|
||||
|
||||
</odoo>
|
||||
</odoo>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
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):
|
||||
super(TestTimesheet, self).setUp()
|
||||
super(TestCommonTimesheet, self).setUp()
|
||||
|
||||
# customer partner
|
||||
self.partner = self.env['res.partner'].create({
|
||||
@@ -69,6 +69,9 @@ class TestTimesheet(TransactionCase):
|
||||
'user_id': self.user_manager.id,
|
||||
})
|
||||
|
||||
|
||||
class TestTimesheet(TestCommonTimesheet):
|
||||
|
||||
def test_log_timesheet(self):
|
||||
""" Test when log timesheet : check analytic account, user and employee are correctly set. """
|
||||
Timesheet = self.env['account.analytic.line']
|
||||
@@ -134,3 +137,42 @@ class TestTimesheet(TransactionCase):
|
||||
'employee_id': self.empl_employee2.id,
|
||||
})
|
||||
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
|
||||
})
|
||||
|
||||
@@ -72,6 +72,11 @@ def jsonrpc(url, method='call', params=None):
|
||||
#----------------------------------------------------------
|
||||
# Helpers for proxy
|
||||
#----------------------------------------------------------
|
||||
class IapTransaction(object):
|
||||
|
||||
def __init__(self):
|
||||
self.credit = None
|
||||
|
||||
@contextlib.contextmanager
|
||||
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),)
|
||||
raise e
|
||||
try:
|
||||
yield
|
||||
transaction = IapTransaction()
|
||||
transaction.credit = credit
|
||||
yield transaction
|
||||
except Exception as e:
|
||||
params = {
|
||||
'token': transaction_token,
|
||||
@@ -119,6 +126,7 @@ def charge(env, key, account_token, credit, description=None, credit_template=No
|
||||
params = {
|
||||
'token': transaction_token,
|
||||
'key': key,
|
||||
'credit_to_capture': transaction.credit,
|
||||
}
|
||||
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.")
|
||||
holidays_editable = fields.Boolean(string="Editable Leaves", default=True)
|
||||
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",
|
||||
help="Monthly net amount the employee receives.")
|
||||
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'),
|
||||
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):
|
||||
for contract in self:
|
||||
if contract.holidays > 20.0:
|
||||
@@ -85,6 +85,23 @@ class HrContract(models.Model):
|
||||
else:
|
||||
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',
|
||||
'public_transport_reimbursed_amount', 'others_reimbursed_amount')
|
||||
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.'))
|
||||
|
||||
#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):
|
||||
""" 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"""
|
||||
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()
|
||||
|
||||
def _compute_string_to_hash(self):
|
||||
@@ -63,7 +63,7 @@ class AccountMove(models.Model):
|
||||
values[k] = _getattrstring(line, field)
|
||||
#make the json serialization canonical
|
||||
# (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,
|
||||
separators=(',',':'))
|
||||
|
||||
@@ -118,7 +118,7 @@ class AccountMove(models.Model):
|
||||
|
||||
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)
|
||||
previous_hash = ''
|
||||
previous_hash = u''
|
||||
start_move_info = []
|
||||
for move in moves:
|
||||
if move.l10n_fr_hash != move._compute_hash(previous_hash=previous_hash):
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
#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):
|
||||
""" 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"""
|
||||
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()
|
||||
|
||||
def _compute_string_to_hash(self):
|
||||
@@ -108,7 +108,7 @@ class pos_order(models.Model):
|
||||
values[k] = _getattrstring(line, field)
|
||||
#make the json serialization canonical
|
||||
# (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,
|
||||
separators=(',',':'))
|
||||
|
||||
@@ -155,7 +155,7 @@ class pos_order(models.Model):
|
||||
|
||||
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)
|
||||
previous_hash = ''
|
||||
previous_hash = u''
|
||||
start_order_info = []
|
||||
for order in orders:
|
||||
if order.l10n_fr_hash != order._compute_hash(previous_hash=previous_hash):
|
||||
|
||||
@@ -148,7 +148,7 @@ class Message(models.Model):
|
||||
given, restrict to messages written in one of those channels. """
|
||||
partner_id = self.env.user.partner_id.id
|
||||
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"
|
||||
args = [(partner_id,)]
|
||||
if channel_ids:
|
||||
@@ -468,6 +468,7 @@ class Message(models.Model):
|
||||
- if author_id == pid, uid is the author, OR
|
||||
- uid belongs to a notified channel, 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
|
||||
- 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
|
||||
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
|
||||
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
|
||||
ON channel_rel.mail_message_id = m.id
|
||||
LEFT JOIN "mail_channel" channel
|
||||
ON channel.id = channel_rel.mail_channel_id
|
||||
LEFT JOIN "mail_channel_partner" channel_partner
|
||||
ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s)
|
||||
WHERE m.id = ANY (%%s)""" % self._table, (pid, pid, ids,))
|
||||
ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s
|
||||
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():
|
||||
if author_id == pid:
|
||||
author_ids.add(id)
|
||||
@@ -533,6 +539,7 @@ class Message(models.Model):
|
||||
- read: if
|
||||
- author_id == pid, uid is the author 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 have read access to the related document if model, res_id
|
||||
- otherwise: raise
|
||||
@@ -583,17 +590,22 @@ class Message(models.Model):
|
||||
message_values = dict((res_id, {}) for res_id in self.ids)
|
||||
|
||||
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
|
||||
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
|
||||
ON channel_rel.mail_message_id = m.id
|
||||
LEFT JOIN "mail_channel" channel
|
||||
ON channel.id = channel_rel.mail_channel_id
|
||||
LEFT JOIN "mail_channel_partner" channel_partner
|
||||
ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s)
|
||||
WHERE m.id = ANY (%%s)""" % self._table, (self.env.user.partner_id.id, self.env.user.partner_id.id, self.ids,))
|
||||
ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s
|
||||
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():
|
||||
message_values[mid] = {
|
||||
'model': rmod,
|
||||
|
||||
@@ -125,7 +125,7 @@ class Users(models.Model):
|
||||
|
||||
@api.model
|
||||
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
|
||||
WHEN now()::date - act.date_deadline::date = 0 Then 'today'
|
||||
WHEN now()::date - act.date_deadline::date > 0 Then 'overdue'
|
||||
@@ -134,16 +134,18 @@ class Users(models.Model):
|
||||
FROM mail_activity AS act
|
||||
JOIN ir_model AS m ON act.res_model_id = m.id
|
||||
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])
|
||||
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 = {}
|
||||
for activity in activity_data:
|
||||
if not user_activities.get(activity['model']):
|
||||
user_activities[activity['model']] = {
|
||||
'name': activity['name'],
|
||||
'name': model_names[activity['id']],
|
||||
'model': activity['model'],
|
||||
'icon': modules.module.get_module_icon(self.env[activity['model']]._original_module),
|
||||
'total_count': 0, 'today_count': 0, 'overdue_count': 0, 'planned_count': 0,
|
||||
|
||||
@@ -258,7 +258,7 @@ var Chatter = Widget.extend(chat_mixin, {
|
||||
var suggested_partners = [];
|
||||
var thread_recipients = result[self.context.default_res_id];
|
||||
_.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({
|
||||
checked: true,
|
||||
partner_id: recipient[0],
|
||||
|
||||
@@ -3,7 +3,6 @@ odoo.define('mail.systray', function (require) {
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var framework = require('web.framework');
|
||||
var session = require('web.session');
|
||||
var SystrayMenu = require('web.SystrayMenu');
|
||||
var Widget = require('web.Widget');
|
||||
@@ -175,6 +174,9 @@ var ActivityMenu = Widget.extend({
|
||||
return self._rpc({
|
||||
model: 'res.users',
|
||||
method: 'activity_user_count',
|
||||
kwargs: {
|
||||
context: session.user_context,
|
||||
},
|
||||
}).then(function (data) {
|
||||
self.activities = data;
|
||||
self.activityCounter = _.reduce(data, function(total_count, p_data){ return total_count + p_data.total_count; }, 0);
|
||||
|
||||
@@ -100,15 +100,17 @@ function inline (node, transform_children) {
|
||||
|
||||
// Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False
|
||||
function parse_email (text) {
|
||||
var result = text.match(/(.*)<(.*@.*)>/);
|
||||
if (result) {
|
||||
return [_.str.trim(result[1]), _.str.trim(result[2])];
|
||||
if (text){
|
||||
var result = text.match(/(.*)<(.*@.*)>/);
|
||||
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>)
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
@@ -43,13 +43,18 @@
|
||||
|
||||
<template id="qunit_suite" name="mail_tests" inherit_id="web.qunit_suite">
|
||||
<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/systray_tests.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/tests/chat_window_test.js"></script>
|
||||
</xpath>
|
||||
</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">
|
||||
<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">
|
||||
|
||||
@@ -213,6 +213,15 @@ class MaintenanceEquipment(models.Model):
|
||||
self.message_subscribe_users(user_ids=[vals['owner_user_id']])
|
||||
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
|
||||
def _read_group_category_ids(self, categories, domain, order):
|
||||
""" Read group customization in order to display all the categories in
|
||||
|
||||
@@ -585,14 +585,15 @@ class MassMailing(models.Model):
|
||||
self.ensure_one()
|
||||
mass_mailing_copy = self.copy()
|
||||
if mass_mailing_copy:
|
||||
context = dict(self.env.context)
|
||||
context['form_view_initial_mode'] = 'edit'
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_type': 'form',
|
||||
'view_mode': 'form',
|
||||
'res_model': 'mail.mass_mailing',
|
||||
'res_id': mass_mailing_copy.id,
|
||||
'context': self.env.context,
|
||||
'flags': {'initial_mode': 'edit'},
|
||||
'context': context,
|
||||
}
|
||||
return False
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ class MrpUnbuild(models.Model):
|
||||
if self.product_id.tracking != 'none' and not self.lot_id.id:
|
||||
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]
|
||||
produce_moves = self._generate_produce_moves()
|
||||
|
||||
@@ -156,12 +160,31 @@ class MrpUnbuild(models.Model):
|
||||
def _generate_produce_moves(self):
|
||||
moves = self.env['stock.move']
|
||||
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
|
||||
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'])
|
||||
if unbuild.mo_id:
|
||||
raw_moves = unbuild.mo_id.move_raw_ids.filtered(lambda move: move.state == 'done')
|
||||
factor = unbuild.product_qty / unbuild.mo_id.product_uom_id._compute_quantity(unbuild.mo_id.product_qty, unbuild.product_uom_id)
|
||||
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
|
||||
|
||||
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):
|
||||
return self.env['stock.move'].create({
|
||||
'name': self.name,
|
||||
|
||||
@@ -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)
|
||||
else:
|
||||
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:
|
||||
rounding = ml.product_uom_id.rounding
|
||||
if float_compare(qty_to_add, 0, precision_rounding=rounding) <= 0:
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
</div>
|
||||
</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_dest_id" groups="stock.group_stock_multi_locations"/>
|
||||
<field name="has_tracking" invisible="1"/>
|
||||
|
||||
@@ -125,6 +125,8 @@ class MrpProductProduce(models.TransientModel):
|
||||
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)
|
||||
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.qty_done += self.product_qty
|
||||
else:
|
||||
@@ -181,27 +183,25 @@ class MrpProductProduceLine(models.TransientModel):
|
||||
|
||||
@api.onchange('lot_id')
|
||||
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 = {}
|
||||
if self.product_id.tracking == 'serial':
|
||||
self.qty_done = 1
|
||||
return res
|
||||
|
||||
@api.constrains('lot_id')
|
||||
def _check_lot_id(self):
|
||||
for ml in self:
|
||||
if ml.product_id.tracking == 'serial':
|
||||
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()
|
||||
if message:
|
||||
raise ValidationError(message)
|
||||
|
||||
def _check_for_duplicated_serial_numbers(self):
|
||||
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 consume the same serial number twice. Please correct the serial numbers encoded.')
|
||||
@api.onchange('qty_done')
|
||||
def _onchange_qty_done(self):
|
||||
""" When the user is encoding a produce 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.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
|
||||
@@ -51,7 +51,10 @@ class WebsitePayment(http.Controller):
|
||||
'amount': float(amount),
|
||||
'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)
|
||||
def transaction(self, reference, amount, currency_id, acquirer_id):
|
||||
|
||||
@@ -127,7 +127,7 @@ class PaymentAcquirerAuthorize(models.Model):
|
||||
for field_name in mandatory_fields:
|
||||
if not data.get(field_name):
|
||||
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 if error else True
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ odoo.define('payment_stripe.stripe', function(require) {
|
||||
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;
|
||||
if (so_id) {
|
||||
so_id = parseInt(so_id[1]);
|
||||
|
||||
@@ -43,11 +43,10 @@ try:
|
||||
else:
|
||||
_logger.warning(_('Unable to format %s:\n%s') % number, e)
|
||||
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
|
||||
else:
|
||||
phone_fmt = phonenumbers.PhoneNumberFormat.NATIONAL
|
||||
|
||||
return phonenumbers.format_number(phone_nbr, phone_fmt)
|
||||
|
||||
except ImportError:
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<?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>
|
||||
<record id="res_company_view_form_phone_validation" model="ir.ui.view">
|
||||
<field name="name">res.company.view.form.phone_validation</field>
|
||||
|
||||
@@ -128,6 +128,15 @@
|
||||
<filter string="Archived" name="inactive" domain="[('active','=',False)]"/>
|
||||
<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! -->
|
||||
<separator/>
|
||||
<filter string="Late Activities" name="activities_overdue"
|
||||
domain="[('activity_ids.date_deadline', '<', 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', '>', context_today().strftime('%Y-%m-%d'))
|
||||
]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -44,7 +44,7 @@ class StockProductionLot(models.Model):
|
||||
# Assign dates according to products data
|
||||
@api.model
|
||||
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:
|
||||
if not vals.get(d):
|
||||
vals[d] = dates[d]
|
||||
|
||||
@@ -512,19 +512,19 @@ class Task(models.Model):
|
||||
partner_id = fields.Many2one('res.partner',
|
||||
string='Customer',
|
||||
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',
|
||||
string='Company',
|
||||
default=lambda self: self.env['res.company']._company_default_get())
|
||||
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",
|
||||
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
|
||||
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_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True)
|
||||
legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing 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, related_sudo=False)
|
||||
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')
|
||||
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)
|
||||
|
||||
@@ -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))]
|
||||
for index, (day_date, work_hours_count) in enumerate(work_hours_data):
|
||||
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,
|
||||
'task_id': holiday_task.id,
|
||||
'account_id': holiday_project.analytic_account_id.id,
|
||||
@@ -77,6 +77,7 @@ class Holidays(models.Model):
|
||||
def action_refuse(self):
|
||||
""" Remove the timesheets linked to the refused holidays """
|
||||
result = super(Holidays, self).action_refuse()
|
||||
self.mapped('timesheet_ids').write({'holiday_id': False})
|
||||
self.mapped('timesheet_ids').unlink()
|
||||
timesheets = self.sudo().mapped('timesheet_ids')
|
||||
timesheets.write({'holiday_id': False})
|
||||
timesheets.unlink()
|
||||
return result
|
||||
|
||||
@@ -98,7 +98,9 @@ class AccountInvoice(models.Model):
|
||||
def _onchange_currency_id(self):
|
||||
if self.currency_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')
|
||||
def _onchange_origin(self):
|
||||
|
||||
@@ -776,10 +776,10 @@ class PurchaseOrderLine(models.Model):
|
||||
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)]}
|
||||
|
||||
product_lang = self.product_id.with_context({
|
||||
'lang': self.partner_id.lang,
|
||||
'partner_id': self.partner_id.id,
|
||||
})
|
||||
product_lang = self.product_id.with_context(
|
||||
lang=self.partner_id.lang,
|
||||
partner_id=self.partner_id.id,
|
||||
)
|
||||
self.name = product_lang.display_name
|
||||
if 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">
|
||||
<field name="name">Portal Purchase Orders</field>
|
||||
<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="perm_unlink" eval="1"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
@@ -72,7 +72,7 @@
|
||||
<record id="portal_purchase_order_line_rule" model="ir.rule">
|
||||
<field name="name">Portal Purhcase Orders Line</field>
|
||||
<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'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
|
||||
@@ -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)
|
||||
|
||||
price = analytic_line._sale_get_invoice_price(sale_order)
|
||||
so_line = self.env['sale.order.line'].search([
|
||||
('order_id', '=', sale_order.id),
|
||||
('price_unit', '=', price),
|
||||
('product_id', '=', self.product_id.id)
|
||||
], limit=1)
|
||||
so_line = None
|
||||
if analytic_line.product_id.expense_policy == 'sales_price' and analytic_line.product_id.invoice_policy == 'delivery':
|
||||
so_line = self.env['sale.order.line'].search([
|
||||
('order_id', '=', sale_order.id),
|
||||
('price_unit', '=', price),
|
||||
('product_id', '=', self.product_id.id)
|
||||
], limit=1)
|
||||
|
||||
if not 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._compute_tax_id()
|
||||
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)
|
||||
analytic_line.with_context(sale_analytic_norecompute=True).write({'so_line': so_line.id})
|
||||
|
||||
@@ -150,7 +150,10 @@
|
||||
<t t-foreach="doc._get_tax_amount_by_group()" t-as="amount_by_group">
|
||||
<tr style="border-bottom:1px solid #dddddd;">
|
||||
<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>&nbsp;<span>on</span>&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">
|
||||
<span t-esc="amount_by_group[1]"
|
||||
t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/>
|
||||
@@ -158,7 +161,7 @@
|
||||
</t>
|
||||
<t t-else ="">
|
||||
<td>
|
||||
<span t-esc="amount_by_group[0]"/><span>&nbsp;<span>on</span>&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 class="text-right">
|
||||
<span t-esc="amount_by_group[1]"
|
||||
|
||||
@@ -9,6 +9,16 @@ from odoo.addons.iap.models import iap
|
||||
|
||||
_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):
|
||||
_name = 'sms.send_sms'
|
||||
@@ -16,6 +26,27 @@ class SendSMS(models.TransientModel):
|
||||
recipients = fields.Char('Recipients', 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):
|
||||
if 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 = []
|
||||
no_phone_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:
|
||||
phone_numbers.append(number)
|
||||
else:
|
||||
|
||||
@@ -183,10 +183,6 @@ class Route(models.Model):
|
||||
'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):
|
||||
_name = "stock.location.path"
|
||||
|
||||
@@ -1000,8 +1000,16 @@ class StockMove(models.Model):
|
||||
|
||||
moves = self.filtered(lambda x: x.state not in ('done', 'cancel'))
|
||||
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:
|
||||
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,
|
||||
# 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
|
||||
@@ -1010,10 +1018,6 @@ class StockMove(models.Model):
|
||||
# FIXME: move rounding to move line
|
||||
# rounding = move.product_uom.rounding
|
||||
# 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._create_extra_move()
|
||||
# Split moves where necessary and move quants
|
||||
|
||||
@@ -109,36 +109,49 @@ class StockMoveLine(models.Model):
|
||||
|
||||
@api.onchange('lot_name', 'lot_id')
|
||||
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 = {}
|
||||
if self.product_id.tracking == 'serial':
|
||||
self.qty_done = 1
|
||||
move_lines_to_check = self._get_similar_move_lines() - self
|
||||
message = move_lines_to_check._check_for_duplicated_serial_numbers()
|
||||
if not self.qty_done:
|
||||
self.qty_done = 1
|
||||
|
||||
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:
|
||||
res['warning'] = {'title': _('Warning'), 'message': message}
|
||||
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')
|
||||
def _check_positive_qty_done(self):
|
||||
if any([ml.qty_done < 0 for ml in self]):
|
||||
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):
|
||||
self.ensure_one()
|
||||
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))
|
||||
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
|
||||
def create(self, vals):
|
||||
vals['ordered_qty'] = vals.get('product_uom_qty')
|
||||
|
||||
@@ -246,7 +246,7 @@
|
||||
</group>
|
||||
<group>
|
||||
<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"/>
|
||||
<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'))]}"
|
||||
|
||||
@@ -171,9 +171,13 @@ class LandedCost(models.Model):
|
||||
val_line_values.update({'cost_id': cost.id, 'cost_line_id': cost_line.id})
|
||||
self.env['stock.valuation.adjustment.lines'].create(val_line_values)
|
||||
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_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
|
||||
|
||||
for line in cost.cost_lines:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Add pickings to 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>
|
||||
<button name="attach_pickings" type="object" string="Add to Batch" class="btn-primary"/>
|
||||
<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):
|
||||
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})
|
||||
|
||||
|
||||
@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',
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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',
|
||||
}),
|
||||
context: { bin_size: true },
|
||||
init: function (parent, name, record) {
|
||||
this._super.apply(this, arguments);
|
||||
this.fields = record.fields;
|
||||
|
||||
@@ -30,7 +30,7 @@ var _t = core._t;
|
||||
/**
|
||||
* 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]
|
||||
* a description of the field (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)
|
||||
*/
|
||||
function formatBinary(value, field, options) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return utils.binaryToBinsize(value);
|
||||
}
|
||||
|
||||
|
||||
@@ -387,10 +387,6 @@ return core.Class.extend({
|
||||
attrs.fieldDependencies = attrs.Widget.prototype.fieldDependencies;
|
||||
}
|
||||
|
||||
if (attrs.Widget.prototype.context) {
|
||||
attrs.context = attrs.Widget.prototype.context;
|
||||
}
|
||||
|
||||
return attrs;
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -1854,7 +1854,7 @@ var BasicModel = AbstractModel.extend({
|
||||
model: record.model,
|
||||
method: 'read',
|
||||
args: [[record.res_id], fieldNames],
|
||||
context: record.getContext(),
|
||||
context: _.extend({}, record.getContext(), {bin_size: true}),
|
||||
})
|
||||
.then(function (result) {
|
||||
if (result.length === 0) {
|
||||
@@ -2374,7 +2374,7 @@ var BasicModel = AbstractModel.extend({
|
||||
});
|
||||
record.data[fieldName] = list.id;
|
||||
if (!fieldInfo.__no_fetch) {
|
||||
var def = self._readUngroupedList(list).then(function (list) {
|
||||
var def = self._readUngroupedList(list).then(function () {
|
||||
return $.when(
|
||||
self._fetchX2ManysBatched(list),
|
||||
self._fetchReferencesBatched(list)
|
||||
@@ -2403,6 +2403,7 @@ var BasicModel = AbstractModel.extend({
|
||||
var fields = view ? view.fields : fieldInfo.relatedFields;
|
||||
var viewType = view ? view.type : fieldInfo.viewType;
|
||||
list = this._applyX2ManyOperations(list);
|
||||
this._sortList(list);
|
||||
var x2mRecords = [];
|
||||
|
||||
// step 1: collect ids
|
||||
@@ -2640,7 +2641,7 @@ var BasicModel = AbstractModel.extend({
|
||||
var relRecordAdded = [];
|
||||
var relRecordUpdated = [];
|
||||
_.each(list._changes, function (change) {
|
||||
if (change.operation === 'ADD') {
|
||||
if (change.operation === 'ADD' && change.id) {
|
||||
relRecordAdded.push(self.localData[change.id]);
|
||||
} else if (change.operation === 'UPDATE' && !self.isNew(change.id)) {
|
||||
// ignore new records that would have been updated
|
||||
@@ -2699,6 +2700,10 @@ var BasicModel = AbstractModel.extend({
|
||||
} else if (_.contains(addedIds, list.res_ids[i])) {
|
||||
// this is a new id (maybe existing in DB, but new in JS)
|
||||
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);
|
||||
if (changes.id) {
|
||||
// the subrecord already exists in db
|
||||
@@ -3092,23 +3097,13 @@ var BasicModel = AbstractModel.extend({
|
||||
id: {type: 'integer'},
|
||||
}, 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 = {
|
||||
_cache: type === 'list' ? {} : undefined,
|
||||
_changes: null,
|
||||
_domains: {},
|
||||
_rawChanges: {},
|
||||
aggregateValues: params.aggregateValues || {},
|
||||
context: context,
|
||||
context: params.context,
|
||||
count: params.count || res_ids.length,
|
||||
data: data,
|
||||
domain: params.domain || [],
|
||||
|
||||
@@ -167,6 +167,13 @@ var KanbanColumn = Widget.extend({
|
||||
* Adds the quick create record to the top of the column.
|
||||
*/
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,6 +207,7 @@ var KanbanController = BasicController.extend({
|
||||
* @param {OdooEvent} event
|
||||
*/
|
||||
_onButtonClicked: function (event) {
|
||||
event.stopPropagation();
|
||||
var self = this;
|
||||
var attrs = event.data.attrs;
|
||||
var record = event.data.record;
|
||||
@@ -388,13 +389,18 @@ var KanbanController = BasicController.extend({
|
||||
/**
|
||||
* @private
|
||||
* @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) {
|
||||
var self = this;
|
||||
var column = event.target;
|
||||
this.model.toggleGroup(column.db_id).then(function (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();
|
||||
});
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ var KanbanModel = BasicModel.extend({
|
||||
* @returns {Deferred<string>} resolves to the local id of the new record
|
||||
*/
|
||||
addRecordToGroup: function (groupID, resId) {
|
||||
var self = this;
|
||||
var group = this.localData[groupID];
|
||||
var new_record = this._makeDataPoint({
|
||||
res_id: resId,
|
||||
@@ -31,15 +32,16 @@ var KanbanModel = BasicModel.extend({
|
||||
viewType: group.viewType,
|
||||
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) {
|
||||
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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -6916,6 +6916,7 @@ QUnit.module('relational_fields', {
|
||||
if (args.method === 'read' && args.model === 'partner') {
|
||||
assert.deepEqual(args.kwargs.context, {
|
||||
active_field: 2,
|
||||
bin_size: true,
|
||||
someKey: 'some value',
|
||||
}, "sent context should be correct");
|
||||
}
|
||||
@@ -7639,6 +7640,74 @@ QUnit.module('relational_fields', {
|
||||
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.test('many2many kanban: edition', function (assert) {
|
||||
|
||||
@@ -74,6 +74,7 @@ QUnit.module('Views', {
|
||||
mockRPC: function (route, args) {
|
||||
assert.deepEqual(args.kwargs.context, {
|
||||
active_field: 2,
|
||||
bin_size: true,
|
||||
someKey: 'some value',
|
||||
}, "should have sent the correct context");
|
||||
return this._super.apply(this, arguments);
|
||||
|
||||
@@ -199,7 +199,7 @@ QUnit.module('Views', {
|
||||
});
|
||||
|
||||
QUnit.test('only necessary fields are fetched with correct context', function (assert) {
|
||||
assert.expect(1);
|
||||
assert.expect(2);
|
||||
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
@@ -214,6 +214,8 @@ QUnit.module('Views', {
|
||||
// field, not sure why. Maybe this test should be modified.
|
||||
assert.deepEqual(args.args[1], ["foo", "display_name"],
|
||||
"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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,9 +33,10 @@ QUnit.module('Views', {
|
||||
state: { string: "State", type: "selection", selection: [["abc", "ABC"], ["def", "DEF"], ["ghi", "GHI"]]},
|
||||
date: {string: "Date Field", type: 'date'},
|
||||
datetime: {string: "Datetime Field", type: 'datetime'},
|
||||
image: {string: "Image", type: "binary"},
|
||||
},
|
||||
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: 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: []},
|
||||
@@ -440,6 +441,50 @@ QUnit.module('Views', {
|
||||
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) {
|
||||
assert.expect(6);
|
||||
|
||||
@@ -545,6 +590,80 @@ QUnit.module('Views', {
|
||||
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) {
|
||||
assert.expect(7);
|
||||
|
||||
@@ -2339,6 +2458,32 @@ QUnit.module('Views', {
|
||||
"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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ class WebSuite(odoo.tests.HttpCase):
|
||||
|
||||
def test_01_js(self):
|
||||
# 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):
|
||||
# webclient mobile test suite
|
||||
|
||||
@@ -374,10 +374,11 @@ var RTEWidget = Widget.extend({
|
||||
* @param {boolean} internal_history
|
||||
*/
|
||||
historyRecordUndo: function ($target, event, internal_history) {
|
||||
$target = $($target);
|
||||
var rng = range.create();
|
||||
var $editable = $(rng && rng.sc).closest('.o_editable');
|
||||
if (!rng || !$editable.length) {
|
||||
$editable = $($target).closest('.o_editable');
|
||||
$editable = $target.closest('.o_editable');
|
||||
rng = range.create($target.closest('*')[0],0);
|
||||
} else {
|
||||
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);
|
||||
};
|
||||
// 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 () {
|
||||
// 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 image;
|
||||
var startPoint = {node: this.sc.childNodes.length && this.sc.childNodes[this.so] || this.sc};
|
||||
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;
|
||||
}
|
||||
var textNode;
|
||||
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;
|
||||
}
|
||||
var node = point.node.childNodes.length && point.node.childNodes[point.offset] || point.node;
|
||||
var offset = node === point.node ? point.offset : 0;
|
||||
var isImg = dom.ancestor(node, dom.isImg);
|
||||
if (!isImg && ((!dom.isBR(node) && !dom.isText(node)) || (offset && node.textContent.length !== offset && node.textContent.match(/\S|\u00A0/)))) {
|
||||
|
||||
// Check if an ancestor of the current point is an image
|
||||
var pointImg = dom.ancestor(point.node, dom.isImg);
|
||||
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++;
|
||||
}
|
||||
if (isImg && image !== isImg) {
|
||||
image = isImg;
|
||||
nb ++;
|
||||
|
||||
// If an ancestor of the current point is an image, then save it as the
|
||||
// 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;
|
||||
};
|
||||
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);
|
||||
|
||||
$(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 () {
|
||||
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);});
|
||||
|
||||
// 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
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -684,6 +684,7 @@
|
||||
</template>
|
||||
|
||||
<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">
|
||||
<li t-att-class="step==10 and 'text-primary' or 'text-muted'" id="step10">
|
||||
<t t-set="step_review_order" t-value="step>10 and step<50"/>
|
||||
|
||||
@@ -11,12 +11,14 @@ odoo.define('website_sale_delivery.checkout', function (require) {
|
||||
var $amount_delivery = $('#order_delivery 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_total = $('#order_total span.oe_currency_value');
|
||||
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');
|
||||
if (result.status === true) {
|
||||
$amount_delivery.text(result.new_amount_delivery);
|
||||
$amount_untaxed.text(result.new_amount_untaxed);
|
||||
$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.removeClass('hidden');
|
||||
$compute_badge.addClass('hidden');
|
||||
|
||||
@@ -7,12 +7,12 @@ var core = require('web.core');
|
||||
var time = require('web.time');
|
||||
var Widget = require('web.Widget');
|
||||
var local_storage = require('web.local_storage');
|
||||
var websiteRootInstance = require('website.WebsiteRoot.instance');
|
||||
|
||||
var _t = core._t;
|
||||
var page_widgets = {};
|
||||
|
||||
$(document).ready(function () {
|
||||
|
||||
(function () {
|
||||
var widget_parent = $('body');
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ $(document).ready(function () {
|
||||
// then return fix formate string else timeago
|
||||
display_str = "";
|
||||
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 {
|
||||
display_str = moment(datetime_obj).fromNow();
|
||||
}
|
||||
@@ -216,7 +216,7 @@ $(document).ready(function () {
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
return {
|
||||
page_widgets: page_widgets,
|
||||
|
||||
@@ -112,11 +112,6 @@
|
||||
<i class="fa fa-cloud-upload"></i> Upload
|
||||
</a>
|
||||
</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>
|
||||
</div>
|
||||
<!-- Search box -->
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -360,13 +360,6 @@ Source installation requires manually installing dependencies:
|
||||
Install ``psycopg`` using the installer here
|
||||
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
|
||||
command from a cmd.exe prompt (replace ``\YourOdooPath`` by the actual
|
||||
path where you downloaded Odoo):
|
||||
|
||||
+40
-10
@@ -82,8 +82,9 @@ Overview
|
||||
The Credits
|
||||
|
||||
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
|
||||
on the service and is decided by the provider. This could be:
|
||||
clients with tokens or *credits*. The credits are an integer unit and
|
||||
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 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,
|
||||
* Price: the price in *EUROS* for the time being (USD support is planned).
|
||||
|
||||
.. image:: images/package.png
|
||||
:align: center
|
||||
|
||||
.. note::
|
||||
|
||||
Odoo takes a 25% commission on all package sales. Adjust your selling price accordingly.
|
||||
|
||||
|
||||
.. note::
|
||||
|
||||
Depending on the strategy, the price per credit can vary from one
|
||||
package to another.
|
||||
|
||||
|
||||
.. image:: images/package.png
|
||||
:align: center
|
||||
|
||||
.. _iap-odoo-app:
|
||||
|
||||
Odoo App
|
||||
@@ -309,15 +316,22 @@ perform the service within:
|
||||
|
||||
.. todo:: "My Account" > "Your InApp Services"?
|
||||
|
||||
|
||||
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,
|
||||
if the account does not have enough credits it will raise the relevant
|
||||
error
|
||||
2. execute the body of the ``with`` statement
|
||||
3. if the body of the ``with`` executes succesfully, capture (confirm) the
|
||||
transaction
|
||||
4. otherwise if an error is raised from the body of the ``with`` cancel the
|
||||
3. (NEW) if the body of the ``with`` executes succesfully, update the price
|
||||
of the transaction if needed
|
||||
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)
|
||||
|
||||
.. danger::
|
||||
@@ -420,10 +434,12 @@ Capture
|
||||
|
||||
:param TransactionToken token:
|
||||
: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`
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
:emphasize-lines: 8
|
||||
|
||||
r2 = requests.post(ODOO + '/iap/1/capture', json={
|
||||
'jsonrpc': '2.0',
|
||||
'id': None,
|
||||
@@ -431,6 +447,7 @@ Capture
|
||||
'params': {
|
||||
'token': tx,
|
||||
'key': SERVICE_KEY,
|
||||
'credit_to_capture': credit or False,
|
||||
}
|
||||
}).json()
|
||||
if 'error' in r:
|
||||
@@ -523,9 +540,17 @@ Odoo Helpers
|
||||
For convenience, if you are implementing your service using Odoo the ``iap``
|
||||
module provides a few helpers to make IAP flow even simpler:
|
||||
|
||||
.. _iap-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])
|
||||
|
||||
A *context manager* for authorizing and automatically capturing or
|
||||
@@ -544,8 +569,10 @@ Charging
|
||||
:param UserToken token:
|
||||
:param int credit:
|
||||
:param str description:
|
||||
:param Qweb template credit_template:
|
||||
|
||||
.. code-block:: python
|
||||
:emphasize-lines: 10,13,14,15
|
||||
|
||||
@route('/deathstar/superlaser', type='json')
|
||||
def superlaser(self, user_account,
|
||||
@@ -556,8 +583,11 @@ Charging
|
||||
0.0 is none, 1.0 is full power
|
||||
"""
|
||||
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
|
||||
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([
|
||||
('grid', '=', 'M-10'),
|
||||
('name', '=', 'Alderaan'),
|
||||
|
||||
@@ -1597,7 +1597,7 @@
|
||||
<field name="code">tl</field>
|
||||
<field file="base/static/img/country_flags/tl.png" name="image" type="base64" />
|
||||
<field name="currency_id" ref="USD" />
|
||||
<field eval="0" name="phone_code" />
|
||||
<field eval="670" name="phone_code" />
|
||||
</record>
|
||||
<record id="tr" model="res.country">
|
||||
<field name="name">Turkey</field>
|
||||
|
||||
@@ -678,7 +678,7 @@ class IrActionsReport(models.Model):
|
||||
"""
|
||||
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:
|
||||
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 {
|
||||
'name': _('Choose Your Document Layout'),
|
||||
'type': 'ir.actions.act_window',
|
||||
|
||||
+9
-8
@@ -3,22 +3,23 @@ decorator==4.0.10
|
||||
docutils==0.12
|
||||
ebaysdk==2.1.5
|
||||
feedparser==5.2.1
|
||||
gevent==1.1.2
|
||||
gevent==1.1.2 ; sys_platform != 'win32'
|
||||
greenlet==0.4.10
|
||||
html2text==2016.9.19
|
||||
Jinja2==2.8
|
||||
lxml==3.5.0
|
||||
lxml==3.7.1 ; sys_platform != 'win32'
|
||||
lxml ; sys_platform == 'win32'
|
||||
Mako==1.0.4
|
||||
MarkupSafe==0.23
|
||||
mock==2.0.0
|
||||
num2words==0.5.4
|
||||
ofxparse==0.16
|
||||
passlib==1.6.5
|
||||
Pillow==3.4.1
|
||||
psutil==4.3.1
|
||||
psycopg2==2.7.3.1
|
||||
Pillow==4.0.0
|
||||
psutil==4.3.1; sys_platform != 'win32'
|
||||
psycopg2==2.7.3.1; sys_platform != 'win32'
|
||||
pydot==1.2.3
|
||||
pyldap==2.4.28
|
||||
pyldap==2.4.28; sys_platform != 'win32'
|
||||
pyparsing==2.1.10
|
||||
PyPDF2==1.26.0
|
||||
pyserial==3.1.1
|
||||
@@ -29,11 +30,11 @@ PyYAML==3.12
|
||||
qrcode==5.3
|
||||
reportlab==3.3.0
|
||||
requests==2.11.1
|
||||
six==1.10.0
|
||||
suds-jurko==0.6
|
||||
vatnumber==1.2
|
||||
vobject==0.9.3
|
||||
Werkzeug==0.11.11
|
||||
Werkzeug==0.11.15
|
||||
XlsxWriter==0.9.3
|
||||
xlwt==1.3.*
|
||||
xlrd==1.0.0
|
||||
pypiwin32 ; sys_platform == 'win32'
|
||||
|
||||
Reference in New Issue
Block a user