[MERGE] forward port branch 11.0 up to 950d9b2369

This commit is contained in:
Christophe Simonis
2018-01-22 17:47:32 +01:00
86 changed files with 1190 additions and 435 deletions
+1 -18
View File
@@ -350,7 +350,7 @@ class AccountInvoice(models.Model):
payment_move_line_ids = fields.Many2many('account.move.line', string='Payment Move Lines', compute='_compute_payments', store=True)
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',
+1 -2
View File
@@ -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>&amp;nbsp;</td>
<td t-translation="off">&amp;nbsp;</td>
<td class="hidden"></td>
<td></td>
<td></td>
+7 -7
View File
@@ -88,7 +88,7 @@
</td>
</tr>
<tr t-foreach="range(max(5-len(o.invoice_line_ids),0))" t-as="l">
<td>&amp;nbsp;</td>
<td t-translation="off">&amp;nbsp;</td>
<td class="hidden"></td>
<td></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>&amp;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();
});
});
+3 -3
View File
@@ -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
+86 -55
View File
@@ -1,78 +1,109 @@
odoo.define('google_drive.google_drive', function (require) {
odoo.define('google_drive.sidebar', function (require) {
"use strict";
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>
+3 -3
View File
@@ -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)
+1
View File
@@ -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)
+16 -1
View File
@@ -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>
+45 -3
View File
@@ -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
})
+9 -1
View File
@@ -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):
+4 -4
View File
@@ -79,13 +79,13 @@ class pos_order(models.Model):
_('An error occured when computing the inalterability. Impossible to get the unique previous posted point of sale order.'))
#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):
+21 -9
View File
@@ -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,
+5 -3
View File
@@ -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,
+1 -1
View File
@@ -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 -1
View File
@@ -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);
+10 -8
View File
@@ -100,15 +100,17 @@ function inline (node, transform_children) {
// Parses text to find email: Tagada <address@mail.fr> -> [Tagada, address@mail.fr] or False
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();
});
});
});
+6 -1
View File
@@ -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">
+9
View File
@@ -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
+3 -2
View File
@@ -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
+27 -4
View File
@@ -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,
+6
View File
@@ -195,6 +195,12 @@ class StockMove(models.Model):
move_lines = self.move_line_ids.filtered(lambda ml: ml.lot_id == lot and not ml.lot_produced_id)
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:
+1 -1
View File
@@ -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"/>
+16 -16
View File
@@ -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):
+4 -1
View File
@@ -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):
+1 -1
View File
@@ -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>
+9
View File
@@ -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', '&lt;', context_today().strftime('%Y-%m-%d'))]"
help="Show all records which has next action date is before today"/>
<filter string="Today Activities" name="activities_today"
domain="[('activity_ids.date_deadline', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Future Activities" name="activities_upcoming_all"
domain="[('activity_ids.date_deadline', '&gt;', context_today().strftime('%Y-%m-%d'))
]"/>
</search>
</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]
+5 -5
View File
@@ -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
+3 -1
View File
@@ -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):
+4 -4
View File
@@ -776,10 +776,10 @@ class PurchaseOrderLine(models.Model):
self.product_uom = self.product_id.uom_po_id or self.product_id.uom_id
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>
+8 -6
View File
@@ -128,11 +128,13 @@ class AccountAnalyticLine(models.Model):
raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name)
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})
+5 -2
View File
@@ -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>&amp;nbsp;<span>on</span>&amp;nbsp;<t t-esc="amount_by_group[2]" t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/></span>
</td>
<td class="text-right">
<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>&amp;nbsp;<span>on</span>&amp;nbsp;<t t-esc="amount_by_group[2]" t-options='{"widget": "monetary", "display_currency": doc.pricelist_id.currency_id}'/></span>
<span t-esc="amount_by_group[0]"/>
</td>
<td class="text-right">
<span t-esc="amount_by_group[1]"
+33 -1
View File
@@ -9,6 +9,16 @@ from odoo.addons.iap.models import iap
_logger = logging.getLogger(__name__)
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:
-4
View File
@@ -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"
+9 -5
View File
@@ -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
+32 -38
View File
@@ -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')
+1 -1
View File
@@ -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);
+3 -1
View File
@@ -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);
}
});
+146 -1
View File
@@ -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();
});
});
});
+1 -1
View File
@@ -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);
+1
View File
@@ -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&gt;10 and step&lt;50"/>
@@ -11,12 +11,14 @@ odoo.define('website_sale_delivery.checkout', function (require) {
var $amount_delivery = $('#order_delivery span.oe_currency_value');
var $amount_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 -->
-16
View File
@@ -1,16 +0,0 @@
Netherlands, 8-2-2015
BAS Solutions agrees to the terms of the Odoo Corporate Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Erwin van der Ploeg erwin@bas-solutions.nl https://github.com/erwin-bas-solutions
List of contributors:
Erwin van der Ploeg erwin@bas-solutions.nl https://github.com/erwin-bas-solutions
Ruchir Shukla ruchir@bas-solutions.nl https://github.com/ruchir-bas-solutions
+16
View File
@@ -0,0 +1,16 @@
Netherlands, 18-1-2018
Odoo Experts agrees to the terms of the Odoo Corporate Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Erwin van der Ploeg erwin@bas-solutions.nl https://github.com/erwin-bas-solutions
List of contributors:
Erwin van der Ploeg erwin@odooexperts.nl https://github.com/ploegvde
Yenthe Van Ginneken yenthe@odooexperts.nl https://github.com/Yenthe666
+11
View File
@@ -0,0 +1,11 @@
Vietnam, 2018-01-16,
I hereby agree to the terms of the Odoo Individual Contributor License
Agreement v1.0.
I declare that I am authorized and able to make this agreement and sign this
declaration.
Signed,
Nguyen Duc Tam nguyenductamlhp@gmail.com https://github.com/nguyenductamlhp
-7
View File
@@ -360,13 +360,6 @@ Source installation requires manually installing dependencies:
Install ``psycopg`` using the installer here
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
View File
@@ -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'),
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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'