[MERGE] forward port branch saas-11.3 up to 53300648ed
This commit is contained in:
@@ -12,13 +12,14 @@
|
||||
# Pedro M. Baeza <pedro.baeza@gmail.com>, 2017
|
||||
# Nicolás Broggi <rnbroggi@gmail.com>, 2018
|
||||
# RGB Consulting <odoo@rgbconsulting.com>, 2018
|
||||
# Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~11.2\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-03-22 14:10+0000\n"
|
||||
"PO-Revision-Date: 2018-03-22 14:10+0000\n"
|
||||
"Last-Translator: RGB Consulting <odoo@rgbconsulting.com>, 2018\n"
|
||||
"Last-Translator: Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018\n"
|
||||
"Language-Team: Spanish (https://www.transifex.com/odoo/teams/41243/es/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -174,6 +175,8 @@ msgid ""
|
||||
"The invoice was not confirmed despite response from the acquirer (%s): "
|
||||
"invoice amount is %r but acquirer replied with %r."
|
||||
msgstr ""
|
||||
"La factura no fue confirmada a pesar de la respuesta del cliente (%s): el "
|
||||
"monto de la factura es %r pero el cliente respondió con %r."
|
||||
|
||||
#. module: account_payment
|
||||
#: model:ir.ui.view,arch_db:account_payment.portal_invoice_error
|
||||
|
||||
@@ -118,7 +118,7 @@ class ResUsers(models.Model):
|
||||
|
||||
if not values.get('login'):
|
||||
raise ValueError(_('Signup: no login given for new user'))
|
||||
if not values.get('partner_id') or not values.get('name'):
|
||||
if not values.get('partner_id') and not values.get('name'):
|
||||
raise ValueError(_('Signup: no name or partner given for new user'))
|
||||
|
||||
# create a copy of the template user (attached to a specific partner_id if given)
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
# RGB Consulting <odoo@rgbconsulting.com>, 2017
|
||||
# Pedro M. Baeza <pedro.baeza@gmail.com>, 2017
|
||||
# e2f <projects@e2f.com>, 2018
|
||||
# Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~11.2\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-03-22 14:10+0000\n"
|
||||
"PO-Revision-Date: 2018-03-22 14:10+0000\n"
|
||||
"Last-Translator: e2f <projects@e2f.com>, 2018\n"
|
||||
"Last-Translator: Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018\n"
|
||||
"Language-Team: Spanish (https://www.transifex.com/odoo/teams/41243/es/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -154,7 +155,7 @@ msgstr "Separador de decimales:"
|
||||
#: code:addons/base_import/static/src/xml/base_import.xml:76
|
||||
#, python-format
|
||||
msgid "Defer parent/child computation"
|
||||
msgstr ""
|
||||
msgstr "Diferir cálculo de padres e hijos"
|
||||
|
||||
#. module: base_import
|
||||
#: model:ir.model.fields,field_description:base_import.field_base_import_import__display_name
|
||||
@@ -331,6 +332,9 @@ msgid ""
|
||||
" parent / child relation occurs on every line, and lead"
|
||||
" to a slower import. Defering it can speed up import."
|
||||
msgstr ""
|
||||
"Si el modelo utiliza relaciones padre/hijo, se calcula la relación hija en "
|
||||
"cada línea, lo que lleva a una importación lenta. Diferir el proceso de "
|
||||
"cálculo puede acelerar la importación."
|
||||
|
||||
#. module: base_import
|
||||
#. openerp-web
|
||||
|
||||
@@ -14,9 +14,17 @@ var MASTER_TAB_HEARTBEAT_PERIOD = 1500; // 1.5 second
|
||||
bus.ERROR_DELAY = 10000;
|
||||
|
||||
bus.Bus = Widget.extend({
|
||||
init: function () {
|
||||
/**
|
||||
* @override
|
||||
* @param {Object} params
|
||||
* @param {Object} params.session the session to use to perform the poll RPC
|
||||
* @param {string} params.pollRoute the longpolling route to use
|
||||
*/
|
||||
init: function (params) {
|
||||
var self = this;
|
||||
this._super();
|
||||
this.pollRoute = params.pollRoute;
|
||||
this.session = params.session;
|
||||
this.options = {};
|
||||
this.activated = false;
|
||||
this.bus_id = _.uniqueId('bus');
|
||||
@@ -77,7 +85,7 @@ bus.Bus = Widget.extend({
|
||||
}
|
||||
var data = {channels: self.channels, last: self.last, options: options};
|
||||
// The backend has a maximum cycle time of 50 seconds so give +10 seconds
|
||||
this._pollRpc = session.rpc('/longpolling/poll', data, {shadow : true, timeout: 60000});
|
||||
this._pollRpc = session.rpc(this.pollRoute, data, {shadow : true, timeout: 60000});
|
||||
this._pollRpc.then(function (result) {
|
||||
self._pollRpc = false;
|
||||
self.on_notification(result);
|
||||
@@ -164,13 +172,17 @@ bus.Bus = Widget.extend({
|
||||
* - bus.tab_list : list of opened tab ids
|
||||
* - bus.tab_master : generated id of the master tab
|
||||
*/
|
||||
var CrossTabBus = bus.Bus.extend({
|
||||
bus.CrossTabBus = bus.Bus.extend({
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
|
||||
// used to prefix localStorage keys
|
||||
this.sanitizedOrigin = this.session.origin.replace(/:\/{0,2}/g, '_');
|
||||
|
||||
this.is_master = false;
|
||||
this.is_registered = false;
|
||||
if (parseInt(getItem('bus.last_ts', 0)) + 50000 < new Date().getTime()) {
|
||||
setItem('bus.last', -1);
|
||||
if (parseInt(this._getItem('last_ts', 0)) + 50000 < new Date().getTime()) {
|
||||
this._setItem('last', -1);
|
||||
}
|
||||
|
||||
on("storage", this.on_storage.bind(this));
|
||||
@@ -189,15 +201,15 @@ var CrossTabBus = bus.Bus.extend({
|
||||
// Write last_presence in local storage if it has been updated since last heartbeat
|
||||
var hb_period = this.is_master ? MASTER_TAB_HEARTBEAT_PERIOD : TAB_HEARTBEAT_PERIOD;
|
||||
if (self.last_presence + hb_period > new Date().getTime()) {
|
||||
setItem('bus.last_presence', self.last_presence);
|
||||
self._setItem('last_presence', self.last_presence);
|
||||
}
|
||||
});
|
||||
}, this._generateKey.bind(this));
|
||||
if (this.is_master) {
|
||||
setItem('bus.channels', this.channels);
|
||||
setItem('bus.options', this.options);
|
||||
this._setItem('channels', this.channels);
|
||||
this._setItem('options', this.options);
|
||||
} else {
|
||||
this.channels = getItem('bus.channels', this.channels);
|
||||
this.options = getItem('bus.options', this.options);
|
||||
this.channels = this._getItem('channels', this.channels);
|
||||
this.options = this._getItem('options', this.options);
|
||||
}
|
||||
return; // start_polling will be called again on tab registration
|
||||
}
|
||||
@@ -208,7 +220,7 @@ var CrossTabBus = bus.Bus.extend({
|
||||
},
|
||||
on_notification: function (notifications) {
|
||||
if (this.is_master) { // broadcast to other tabs
|
||||
var last = getItem('bus.last', -1);
|
||||
var last = this._getItem('last', -1);
|
||||
var max_id = Math.max(last, 0);
|
||||
var new_notifications = _.filter(notifications, function (notif) {
|
||||
max_id = Math.max(max_id, notif.id);
|
||||
@@ -216,9 +228,9 @@ var CrossTabBus = bus.Bus.extend({
|
||||
});
|
||||
this.last = max_id;
|
||||
if (new_notifications.length) {
|
||||
setItem('bus.last', max_id);
|
||||
setItem('bus.last_ts', new Date().getTime());
|
||||
setItem('bus.notification', new_notifications);
|
||||
this._setItem('last', max_id);
|
||||
this._setItem('last_ts', new Date().getTime());
|
||||
this._setItem('notification', new_notifications);
|
||||
this._super(new_notifications);
|
||||
}
|
||||
} else {
|
||||
@@ -230,45 +242,78 @@ var CrossTabBus = bus.Bus.extend({
|
||||
// localStorage (avoid race condition)
|
||||
var value = e.newValue;
|
||||
// notifications changed
|
||||
if (e.key === 'bus.notification') {
|
||||
if (e.key === this._generateKey('notification')) {
|
||||
var notifs = JSON.parse(value);
|
||||
this.on_notification(notifs);
|
||||
}
|
||||
// update channels
|
||||
if (e.key === 'bus.channels') {
|
||||
if (e.key === this._generateKey('channels')) {
|
||||
this.channels = JSON.parse(value);
|
||||
}
|
||||
// update options
|
||||
if (e.key === 'bus.options') {
|
||||
if (e.key === this._generateKey('options')) {
|
||||
this.options = JSON.parse(value);
|
||||
}
|
||||
// update focus
|
||||
if (e.key === 'bus.focus') {
|
||||
if (e.key === this._generateKey('focus')) {
|
||||
this.set('window_focus', JSON.parse(value));
|
||||
}
|
||||
},
|
||||
add_channel: function () {
|
||||
this._super.apply(this, arguments);
|
||||
setItem('bus.channels', this.channels);
|
||||
this._setItem('channels', this.channels);
|
||||
},
|
||||
delete_channel: function () {
|
||||
this._super.apply(this, arguments);
|
||||
setItem('bus.channels', this.channels);
|
||||
this._setItem('channels', this.channels);
|
||||
},
|
||||
get_last_presence: function () {
|
||||
return getItem('bus.last_presence') || new Date().getTime();
|
||||
return this._getItem('last_presence') || new Date().getTime();
|
||||
},
|
||||
update_option: function () {
|
||||
this._super.apply(this, arguments);
|
||||
setItem('bus.options', this.options);
|
||||
this._setItem('options', this.options);
|
||||
},
|
||||
delete_option: function () {
|
||||
this._super.apply(this, arguments);
|
||||
setItem('bus.options', this.options);
|
||||
this._setItem('options', this.options);
|
||||
},
|
||||
focus_change: function (focus) {
|
||||
this._super.apply(this, arguments);
|
||||
setItem('bus.focus', focus);
|
||||
this._setItem('focus', focus);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generates localStorage keys prefixed by bus. (the name of this addon),
|
||||
* and the sanitized origin, to prevent keys from conflicting when several
|
||||
* bus instances (polling different origins) co-exist.
|
||||
*
|
||||
* @private
|
||||
* @param {string} key
|
||||
* @returns key prefixed with the origin
|
||||
*/
|
||||
_generateKey: function (key) {
|
||||
return 'bus.' + this.sanitizedOrigin + '.' + key;
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} key
|
||||
* @param {*} defaultValue
|
||||
*/
|
||||
_getItem: function (key, defaultValue) {
|
||||
return getItem(this._generateKey(key), defaultValue);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
*/
|
||||
_setItem: function (key, value) {
|
||||
setItem(this._generateKey(key), value);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -291,14 +336,16 @@ function setItem(key, value) {
|
||||
local_storage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
|
||||
var tab_manager = {
|
||||
peersKey: 'bus.peers',
|
||||
masterKey: 'bus.master',
|
||||
heartbeatKey: 'bus.heartbeat',
|
||||
isMaster: false,
|
||||
id: new Date().getTime() + ':' + (Math.random() * 1000000000 | 0),
|
||||
|
||||
register_tab: function (is_master_callback, is_no_longer_master, on_heartbeat_callback) {
|
||||
register_tab: function (is_master_callback, is_no_longer_master, on_heartbeat_callback, generateKey) {
|
||||
this.heartbeatKey = generateKey('heartbeat');
|
||||
this.masterKey = generateKey('master');
|
||||
this.peersKey = generateKey('peers');
|
||||
|
||||
this.is_master_callback = is_master_callback;
|
||||
this.is_no_longer_master = is_no_longer_master || function () {};
|
||||
this.on_heartbeat_callback = on_heartbeat_callback || function () {};
|
||||
@@ -417,10 +464,14 @@ var tab_manager = {
|
||||
|
||||
// bus singleton, depending of the browser :
|
||||
// if supporting LocalStorage, there will be only one tab polling
|
||||
var params = {
|
||||
pollRoute: '/longpolling/poll',
|
||||
session: session,
|
||||
};
|
||||
if (typeof Storage !== "undefined") {
|
||||
bus.bus = new CrossTabBus();
|
||||
bus.bus = new bus.CrossTabBus(params);
|
||||
} else {
|
||||
bus.bus = new bus.Bus();
|
||||
bus.bus = new bus.Bus(params);
|
||||
}
|
||||
|
||||
return bus;
|
||||
|
||||
@@ -34,13 +34,14 @@
|
||||
# Juan José Scarafía <scarafia.juanjose@gmail.com>, 2017
|
||||
# Pablo Rojas <rojaspablo88@gmail.com>, 2018
|
||||
# José Matias Zuazo <jose.zuazo@tpsistemas.com.ar>, 2018
|
||||
# Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~11.2\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-03-22 14:09+0000\n"
|
||||
"PO-Revision-Date: 2018-03-22 14:09+0000\n"
|
||||
"Last-Translator: José Matias Zuazo <jose.zuazo@tpsistemas.com.ar>, 2018\n"
|
||||
"Last-Translator: Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018\n"
|
||||
"Language-Team: Spanish (https://www.transifex.com/odoo/teams/41243/es/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -1053,7 +1054,7 @@ msgstr "Forzar asignación"
|
||||
#. module: crm
|
||||
#: model:ir.ui.view,arch_db:crm.res_config_settings_view_form
|
||||
msgid "Format phone numbers based on national conventions"
|
||||
msgstr ""
|
||||
msgstr "Dar formato a los números de teléfono basado en estándares nacionales"
|
||||
|
||||
#. module: crm
|
||||
#: code:addons/crm/models/crm_lead.py:588
|
||||
@@ -1461,12 +1462,12 @@ msgstr ""
|
||||
#. module: crm
|
||||
#: model:ir.actions.server,name:crm.action_mark_activities_done
|
||||
msgid "Mark All Activities as Done"
|
||||
msgstr ""
|
||||
msgstr "Marcar Todas las Actividades como Hechas"
|
||||
|
||||
#. module: crm
|
||||
#: model:ir.actions.server,name:crm.action_mark_late_activities_done
|
||||
msgid "Mark Late Activities as Done"
|
||||
msgstr ""
|
||||
msgstr "Marcar Actividades Atrasadas como Hechas"
|
||||
|
||||
#. module: crm
|
||||
#: model:ir.ui.view,arch_db:crm.crm_case_form_view_oppor
|
||||
@@ -1912,7 +1913,7 @@ msgstr "Teléfono"
|
||||
#. module: crm
|
||||
#: model:ir.model.fields,field_description:crm.field_res_config_settings__module_crm_phone_validation
|
||||
msgid "Phone Formatting"
|
||||
msgstr ""
|
||||
msgstr "Formateo de Números Telefónicos"
|
||||
|
||||
#. module: crm
|
||||
#: code:addons/crm/models/crm_team.py:165
|
||||
|
||||
@@ -25,13 +25,14 @@
|
||||
# Christopher Ormaza <chris.ormaza@gmail.com>, 2017
|
||||
# Nicolás Broggi <rnbroggi@gmail.com>, 2018
|
||||
# e2f <projects@e2f.com>, 2018
|
||||
# Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~11.2\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-03-22 14:10+0000\n"
|
||||
"PO-Revision-Date: 2018-03-22 14:10+0000\n"
|
||||
"Last-Translator: e2f <projects@e2f.com>, 2018\n"
|
||||
"Last-Translator: Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018\n"
|
||||
"Language-Team: Spanish (https://www.transifex.com/odoo/teams/41243/es/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -751,7 +752,7 @@ msgstr "Peso del envío"
|
||||
#: model:ir.ui.view,arch_db:delivery.report_package_barcode_delivery
|
||||
#: model:ir.ui.view,arch_db:delivery.report_package_barcode_small_delivery
|
||||
msgid "Shipping Weight:"
|
||||
msgstr ""
|
||||
msgstr "Peso del envío:"
|
||||
|
||||
#. module: delivery
|
||||
#: model:ir.model.fields,help:delivery.field_product_product__hs_code
|
||||
@@ -884,7 +885,7 @@ msgstr ""
|
||||
#: model:ir.ui.view,arch_db:delivery.report_package_barcode_delivery
|
||||
#: model:ir.ui.view,arch_db:delivery.report_package_barcode_small_delivery
|
||||
msgid "Weight:"
|
||||
msgstr ""
|
||||
msgstr "Peso:"
|
||||
|
||||
#. module: delivery
|
||||
#: model:ir.model.fields,field_description:delivery.field_product_packaging__width
|
||||
|
||||
@@ -34,7 +34,7 @@ class DeliveryCarrier(models.Model):
|
||||
# Internals for shipping providers #
|
||||
# -------------------------------- #
|
||||
|
||||
name = fields.Char(required=True)
|
||||
name = fields.Char(required=True, translate=True)
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(help="Determine the display order", default=10)
|
||||
# This field will be overwritten by internal shipping providers by adding their own type (ex: 'fedex')
|
||||
|
||||
@@ -96,10 +96,9 @@ class SaleOrder(models.Model):
|
||||
taxes_ids = self.fiscal_position_id.map_tax(taxes, carrier.product_id, self.partner_id).ids
|
||||
|
||||
# Create the sales order line
|
||||
carrier_name = carrier.product_id.with_context(lang=self.partner_id.lang).name_get()[0][1]
|
||||
values = {
|
||||
'order_id': self.id,
|
||||
'name': carrier_name,
|
||||
'name': carrier.with_context(lang=self.partner_id.lang).name,
|
||||
'product_uom_qty': 1,
|
||||
'product_uom': carrier.product_id.uom_id.id,
|
||||
'product_id': carrier.product_id.id,
|
||||
|
||||
@@ -224,11 +224,13 @@ class Employee(models.Model):
|
||||
def write(self, values):
|
||||
res = super(Employee, self).write(values)
|
||||
if 'parent_id' in values or 'department_id' in values:
|
||||
holidays = self.env['hr.leave'].search([('state', 'in', ['draft', 'confirm']), ('employee_id', 'in', self.ids)])
|
||||
hr_vals = {}
|
||||
if values.get('parent_id') is not None:
|
||||
hr_vals['manager_id'] = values['parent_id']
|
||||
if values.get('department_id') is not None:
|
||||
hr_vals['department_id'] = values['department_id']
|
||||
holidays = self.env['hr.leave'].search([('state', 'in', ['draft', 'confirm']), ('employee_id', 'in', self.ids)])
|
||||
holidays.write(hr_vals)
|
||||
allocations = self.env['hr.leave.allocation'].search([('state', 'in', ['draft', 'confirm']), ('employee_id', 'in', self.ids)])
|
||||
allocations.write(hr_vals)
|
||||
return res
|
||||
|
||||
@@ -132,18 +132,6 @@
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
<record id="hr_salary_rule_category_tree_view" model="ir.ui.view">
|
||||
<field name="name">hr.salary.rule.category.tree.view</field>
|
||||
<field name="model">hr.salary.rule.category</field>
|
||||
<field name="field_parent">children_ids</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Salary Rule Categories">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="parent_id" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_hr_salary_rule_category_filter" model="ir.ui.view">
|
||||
<field name="name">hr.salary.rule.category.select</field>
|
||||
@@ -169,20 +157,6 @@
|
||||
sequence="11"
|
||||
groups="base.group_no_one"
|
||||
/>
|
||||
<record id="action_hr_salary_rule_category_tree_view" model="ir.actions.act_window">
|
||||
<field name="name">Salary Rule Categories Hierarchy</field>
|
||||
<field name="res_model">hr.salary.rule.category</field>
|
||||
<field name="view_type">tree</field>
|
||||
<field name="domain">[('parent_id','=',False)]</field>
|
||||
<field name="view_id" ref="hr_salary_rule_category_tree_view"/>
|
||||
</record>
|
||||
<menuitem
|
||||
id="menu_hr_salary_rule_category_tree_view"
|
||||
action="action_hr_salary_rule_category_tree_view"
|
||||
parent="menu_hr_payroll_configuration"
|
||||
sequence="12"
|
||||
groups="base.group_no_one"
|
||||
/>
|
||||
|
||||
<!--
|
||||
Contribution Register
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'Livechat Support',
|
||||
'version': '1.0',
|
||||
'category': 'Hidden',
|
||||
'description': """
|
||||
This module allows employee users to communicate with livechat operators
|
||||
of another database on which a counterpart addon is installed.
|
||||
""",
|
||||
'author': 'Odoo SA',
|
||||
'website': 'https://www.odoo.com',
|
||||
'depends': [
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'views/assets.xml',
|
||||
],
|
||||
'qweb': [
|
||||
"static/src/xml/discuss.xml",
|
||||
"static/src/xml/systray.xml",
|
||||
],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import ir_http
|
||||
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import hmac
|
||||
from hashlib import sha256
|
||||
|
||||
from odoo import models
|
||||
from odoo.http import request
|
||||
|
||||
|
||||
class Http(models.AbstractModel):
|
||||
_inherit = 'ir.http'
|
||||
|
||||
def session_info(self):
|
||||
result = super(Http, self).session_info()
|
||||
|
||||
if self.env.user.has_group('base.group_user'):
|
||||
icp = request.env['ir.config_parameter'].sudo()
|
||||
db_uuid = icp.get_param('database.uuid')
|
||||
db_secret = icp.get_param('database.secret')
|
||||
message = db_uuid + str(request.uid)
|
||||
token = hmac.new(message.encode('utf-8'), db_secret.encode('utf-8'), sha256).hexdigest()
|
||||
|
||||
result['db_uuid'] = db_uuid
|
||||
result['support_token'] = token
|
||||
result['support_origin'] = False # must be overridden to specify the correct origin
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,455 @@
|
||||
odoo.define('im_support.ChatManager', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module includes the ChatManager to handle the case of the Support
|
||||
* channel, allowing the users of the current database to communicate with
|
||||
* livechat operators from another database (the Support database).
|
||||
*/
|
||||
|
||||
var ChatManager = require('mail.ChatManager');
|
||||
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
var WebClient = require('web.WebClient');
|
||||
|
||||
var supportSession = require('im_support.SupportSession');
|
||||
var supportBus = require('im_support.SupportBus');
|
||||
|
||||
var _t = core._t;
|
||||
var CHANNEL_STATES = {
|
||||
CLOSED: 'closed',
|
||||
FOLDED: 'folded',
|
||||
OPEN: 'open',
|
||||
};
|
||||
var ODOOBOT_ID = "ODOOBOT"; // FIXME: should be exported by the ChatManager
|
||||
var POLL_TIMEOUT_DELAY = 1000 * 60 * 30; // 30 minutes
|
||||
var POLL_TIMEOUT_KEY = 'im_support.poll_timeout';
|
||||
var SUPPORT_CHANNEL_LIMIT = 30; // limit of messages to fetch
|
||||
var SUPPORT_CHANNEL_STATE_KEY = 'im_support.channel_state';
|
||||
|
||||
ChatManager.include({
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
closeChatSession: function (channelID) {
|
||||
if (channelID === this.supportChannelUUID) {
|
||||
this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.CLOSED);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
detachChannel: function (channelID) {
|
||||
if (channelID === this.supportChannelUUID) {
|
||||
this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.OPEN);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
foldChannel: function (channelID, folded) {
|
||||
if (channelID === this.supportChannelUUID) {
|
||||
var value = folded ? CHANNEL_STATES.FOLDED : CHANNEL_STATES.OPEN;
|
||||
this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, value);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to filter out the Support channel from the previews.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
getChannelsPreview: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function (channelsPreview) {
|
||||
return _.reject(channelsPreview, {id: self.supportChannelUUID});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
getMentionPartnerSuggestions: function (channel) {
|
||||
if (channel && channel.id === this.supportChannelUUID) {
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Initialises the Support: checks if there is a pending chat session
|
||||
* between the user and Support, and if so, re-opens it.
|
||||
* Note: we can't directly override init(), because it is already called
|
||||
* when the include is applied, so we use this hook instead (called at
|
||||
* webclient startup)
|
||||
*/
|
||||
initSupport: function () {
|
||||
var self = this;
|
||||
this.supportChannelDef = null;
|
||||
this.supportChannelUUID = null;
|
||||
this.pollTimeout = null;
|
||||
this.pollingSupport = false;
|
||||
|
||||
// listen to notifications coming from Support longpolling
|
||||
supportBus.on('notification', this, this._onSupportNotification);
|
||||
|
||||
// check if there is a pending chat session with the Support
|
||||
var timeoutTimestamp = this.call('local_storage', 'getItem', POLL_TIMEOUT_KEY);
|
||||
var pollingDelay = timeoutTimestamp && (JSON.parse(timeoutTimestamp) - Date.now());
|
||||
if (pollingDelay && pollingDelay > 0) {
|
||||
var channelState = this.call('local_storage', 'getItem', SUPPORT_CHANNEL_STATE_KEY);
|
||||
this.startSupportLivechat(channelState).then(function () {
|
||||
var supportChannel = self.getChannel(self.supportChannelUUID);
|
||||
if (supportChannel.available) {
|
||||
self._startPollingSupport(pollingDelay);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
markChannelAsSeen: function (channel) {
|
||||
if (channel.id === this.supportChannelUUID) {
|
||||
this._updateChannelUnreadCounter(channel, 0);
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to call a specific route for messages posted in the Support
|
||||
* channel.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
postMessage: function (data, options) {
|
||||
if (options.channelID && options.channelID === this.supportChannelUUID) {
|
||||
// ensure that the poll is active before posting the message
|
||||
if (!this.pollingSupport) {
|
||||
this._startPollingSupport();
|
||||
}
|
||||
return supportSession.rpc("/odoo_im_support/chat_post", {
|
||||
uuid: this.supportChannelUUID,
|
||||
message_content: data.content,
|
||||
});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Opens the support channel between a livechat operator from the Support
|
||||
* database and the current user (if there is an available operator).
|
||||
* Ensures to perform only once the request to create/retrieve the Support
|
||||
* channel.
|
||||
*
|
||||
* @param {string} [channelState=CHANNEL_STATES.OPEN] state of the Support
|
||||
* channel (see CHANNEL_STATES for accepted values)
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
startSupportLivechat: function (channelState) {
|
||||
var self = this;
|
||||
if (!this.supportChannelDef) {
|
||||
// retrieve or create the channel
|
||||
this.supportChannelDef = supportSession.rpc('/odoo_im_support/get_support_channel', {
|
||||
channel_uuid: session.support_token,
|
||||
db_uuid: session.db_uuid,
|
||||
user_name: session.name,
|
||||
});
|
||||
}
|
||||
return this.supportChannelDef.then(function (channel) {
|
||||
if (!channel) {
|
||||
// there is no channel (because there is no online operator, and
|
||||
// no support channel has been created yet), so create one to
|
||||
// open in a chat window
|
||||
channel = {
|
||||
available: false,
|
||||
support_channel: true,
|
||||
type: 'livechat',
|
||||
uuid: "support_unavailable",
|
||||
};
|
||||
}
|
||||
if (!channelState) {
|
||||
channelState = self.discussOpen ? CHANNEL_STATES.CLOSED : CHANNEL_STATES.OPEN;
|
||||
self.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, channelState);
|
||||
}
|
||||
if (!self.supportChannelUUID) {
|
||||
// this part is only executed the first time the RPC is resolved
|
||||
self.supportChannelUUID = channel.uuid;
|
||||
|
||||
// add the channel to the ChatManager
|
||||
self._addChannel(_.extend(channel, {
|
||||
id: channel.uuid,
|
||||
is_minimized: _.contains([CHANNEL_STATES.OPEN, CHANNEL_STATES.FOLDED], channelState),
|
||||
state: channelState,
|
||||
}));
|
||||
|
||||
// display automatic messages in the channel
|
||||
if (!channel.available) {
|
||||
self._addSupportNotAvailableMessage();
|
||||
} else {
|
||||
self._addSupportWelcomeMessage();
|
||||
}
|
||||
} else {
|
||||
// the channel has already been added to the ChatManager, so
|
||||
// simply re-open it
|
||||
channel = self.getChannel(self.supportChannelUUID);
|
||||
if (self.discussOpen) {
|
||||
self.openChannel(channel);
|
||||
} else {
|
||||
channel.is_folded = channelState === CHANNEL_STATES.FOLDED;
|
||||
self.call('chat_window_manager', 'openChat', channel);
|
||||
}
|
||||
}
|
||||
}).fail(function () {
|
||||
self.do_warn(_t("The Support server can't be reached."));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server for messages of the Support
|
||||
* channel (which are records on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
toggleStarStatus: function (msgID) {
|
||||
var message = this.getMessage(msgID);
|
||||
if (_.contains(message.channel_ids, this.supportChannelUUID)) {
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Overrides to prevent from calling the server with the Support channel
|
||||
* (which is a record on the Support database).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
unsubscribe: function (channel) {
|
||||
if (channel.id === this.supportChannelUUID) {
|
||||
return $.when();
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adds a message in the Support channel indicating that this channel is
|
||||
* not available for now.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addSupportNotAvailableMessage: function () {
|
||||
var msg = {
|
||||
author_id: ODOOBOT_ID,
|
||||
body: _t("None of our operators are available. <a href='https://www.odoo.com/help' " +
|
||||
"target='_blank'>Submit a ticket</a> to ask your question now."),
|
||||
channel_ids: [this.supportChannelUUID],
|
||||
id: Number.MAX_SAFE_INTEGER, // last message in the channel
|
||||
};
|
||||
this._addMessage(msg, {silent: true});
|
||||
},
|
||||
/**
|
||||
* Adds a welcome message as first message of the Support channel.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_addSupportWelcomeMessage: function () {
|
||||
var channel = this.getChannel(this.supportChannelUUID);
|
||||
if (channel.welcome_message) {
|
||||
var msg = {
|
||||
author_id: channel.operator,
|
||||
body: channel.welcome_message,
|
||||
channel_ids: [this.supportChannelUUID],
|
||||
id: -1, // first message of the channel
|
||||
};
|
||||
this._addMessage(msg, {silent: true});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to handle the Support channel case: fetches the messages from
|
||||
* the Support database in that case.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_fetchFromChannel: function (channel, options) {
|
||||
var self = this;
|
||||
if (channel.id === this.supportChannelUUID) {
|
||||
var domain = options && options.domain || [];
|
||||
var cache = this._getChannelCache(channel, domain);
|
||||
if (options && options.loadMore) {
|
||||
var minMessageID = cache.messages[0].id;
|
||||
domain = [['id', '<', minMessageID]].concat(domain);
|
||||
}
|
||||
|
||||
return supportSession.rpc('/odoo_im_support/fetch_messages', {
|
||||
domain: domain,
|
||||
channel_uuid: session.support_token,
|
||||
limit: SUPPORT_CHANNEL_LIMIT,
|
||||
}).then(function (msgs) {
|
||||
if (!cache.all_history_loaded) {
|
||||
cache.all_history_loaded = msgs.length < SUPPORT_CHANNEL_LIMIT;
|
||||
}
|
||||
cache.loaded = true;
|
||||
|
||||
_.each(msgs, function (msg) {
|
||||
_.extend(msg, {channel_ids: [self.supportChannelUUID]});
|
||||
self._addMessage(msg, {
|
||||
channel_id: self.supportChannelUUID,
|
||||
silent: true,
|
||||
domain: domain,
|
||||
});
|
||||
});
|
||||
var channelCache = self._getChannelCache(channel, domain);
|
||||
return channelCache.messages;
|
||||
});
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_makeChannel: function (data) {
|
||||
var channel = this._super.apply(this, arguments);
|
||||
if (channel.id === this.supportChannelUUID) {
|
||||
channel.supportChannel = true;
|
||||
channel.name = _t('Support');
|
||||
channel.available = data.available;
|
||||
if (!channel.available) {
|
||||
channel.name += _t(' (offline)');
|
||||
}
|
||||
channel.welcome_message = data.welcome_message;
|
||||
channel.operator = data.operator;
|
||||
}
|
||||
return channel;
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_makeMessage: function (data) {
|
||||
if (_.contains(data.channel_ids, this.supportChannelUUID) && data.author_id !== ODOOBOT_ID) {
|
||||
if (!data.author_id[0]) { // the author is the client
|
||||
data.author_id = [session.partner_id, session.name];
|
||||
} else { // the author is the operator
|
||||
data.author_id[0] = -1; // prevent from conflicting with partners of this instance
|
||||
var msg = this._super.apply(this, arguments);
|
||||
msg.author_redirect = false;
|
||||
msg.avatar_src = "/mail/static/src/img/odoo_o.png";
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* Automatically stop polling for Support messages after a given delay of
|
||||
* inactivity.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling
|
||||
* timeout delay to set
|
||||
*/
|
||||
_setPollTimeout: function (pollingDelay) {
|
||||
pollingDelay = pollingDelay || POLL_TIMEOUT_DELAY;
|
||||
clearTimeout(this.pollTimeout);
|
||||
this.pollTimeout = setTimeout(this._stopPollingSupport.bind(this), pollingDelay);
|
||||
// save the timeout expiration datetime into the LocalStorage so that
|
||||
// we can re-open the Support channel if necessary on F5
|
||||
var timeoutTimestamp = Date.now() + pollingDelay;
|
||||
this.call('local_storage', 'setItem', POLL_TIMEOUT_KEY, timeoutTimestamp);
|
||||
},
|
||||
/**
|
||||
* Initiates a longpoll with the server hosting the Support channel.
|
||||
*
|
||||
* @private
|
||||
* @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling
|
||||
* timeout delay to set
|
||||
*/
|
||||
_startPollingSupport: function (pollingDelay) {
|
||||
this.pollingSupport = true;
|
||||
supportBus.add_channel(this.supportChannelUUID);
|
||||
supportBus.start_polling();
|
||||
this._setPollTimeout(pollingDelay);
|
||||
},
|
||||
/**
|
||||
* Stops the longpoll with the server hosting the Support channel.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_stopPollingSupport: function () {
|
||||
this.pollingSupport = false;
|
||||
supportBus.stop_polling();
|
||||
this.call('local_storage', 'removeItem', POLL_TIMEOUT_KEY);
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handles poll notifications from the Support server.
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} notifications
|
||||
*/
|
||||
_onSupportNotification: function (notifications) {
|
||||
var self = this;
|
||||
if (notifications && notifications.length) {
|
||||
this._setPollTimeout();
|
||||
}
|
||||
_.each(notifications, function (notification) {
|
||||
if (notification[1]._type === 'history_command') {
|
||||
// ignore history requests
|
||||
return;
|
||||
}
|
||||
var msg = _.extend(notification[1], {
|
||||
channel_ids: [self.supportChannelUUID],
|
||||
});
|
||||
self._manageChannelNotification(msg);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Unfortunately, we can't override init() of ChatManager because it is called
|
||||
// before the include is applied, so we override the WebClient instead to call
|
||||
// an initialization hook for Livechat Support in the ChatManager service.
|
||||
WebClient.include({
|
||||
/**
|
||||
* Overrides to ask the ChatManager service to check whether there is a
|
||||
* pending chat session with Support, and if so, to re-open it.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
show_application: function () {
|
||||
this.call('chat_manager', 'initSupport');
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
odoo.define('im_support.chat_discuss', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module includes Discuss to handle the case of the Support channel.
|
||||
*/
|
||||
|
||||
var Discuss = require('mail.chat_discuss');
|
||||
|
||||
Discuss.include({
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overrides to hide stars in the Support channel, as this feature does not
|
||||
* work in that channel.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_getThreadRenderingOptions: function () {
|
||||
var options = this._super.apply(this, arguments);
|
||||
if (this.channel.supportChannel) {
|
||||
options.display_stars = false;
|
||||
}
|
||||
return options;
|
||||
},
|
||||
/**
|
||||
* Overrides to hide the attachment button in the composer of the Support
|
||||
* channel, as this feature does not work in that channel. Also hides the
|
||||
* composer when no operator is available.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_setChannel: function (channel) {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
var $buttonAddAttachment = self.basicComposer.$('.o_composer_button_add_attachment');
|
||||
if (channel.supportChannel) {
|
||||
if (!channel.available) {
|
||||
self.basicComposer.do_hide();
|
||||
}
|
||||
$buttonAddAttachment.hide();
|
||||
} else {
|
||||
$buttonAddAttachment.show();
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Overrides to handle the Support channel case (hide all buttons).
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_updateControlPanelButtons: function (channel) {
|
||||
this._super.apply(this, arguments);
|
||||
if (channel.supportChannel) {
|
||||
this.$buttons.find('button').hide();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
odoo.define('im_support.ExtendedChatWindow', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module includes ExtendedChatWindow to handle the case of the Support
|
||||
* channel.
|
||||
*/
|
||||
|
||||
|
||||
var ExtendedChatWindow = require('mail.ExtendedChatWindow');
|
||||
|
||||
ExtendedChatWindow.include({
|
||||
/**
|
||||
* Overrides to tweak the options in the case of the Support channel (no
|
||||
* star, and no input if no operator available).
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
var channel = this.call('chat_manager', 'getChannel', this.channel_id);
|
||||
if (channel && channel.supportChannel) {
|
||||
this.options.display_stars = false;
|
||||
if (!channel.available) {
|
||||
this.options.input_less = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Overrides to remove the attachment button in the Support channel as this
|
||||
* feature does not work in that channel.
|
||||
*
|
||||
* @note: this feature could be enabled by storing the attachments on the
|
||||
* Support server.
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var def = this._super.apply(this, arguments);
|
||||
var channel = this.call('chat_manager', 'getChannel', this.channel_id);
|
||||
if (channel && channel.supportChannel) {
|
||||
this.$('.o_composer .o_composer_button_add_attachment').remove();
|
||||
}
|
||||
return def;
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
odoo.define('im_support.SupportBus', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module instantiates and exports the instance of the Bus, parameterized
|
||||
* to poll the Support server.
|
||||
*/
|
||||
|
||||
var supportSession = require('im_support.SupportSession');
|
||||
|
||||
var bus = require('bus.bus');
|
||||
|
||||
var Bus;
|
||||
if(typeof Storage !== "undefined"){
|
||||
Bus = bus.CrossTabBus;
|
||||
} else {
|
||||
Bus = bus.Bus;
|
||||
}
|
||||
|
||||
var params = {
|
||||
session: supportSession,
|
||||
pollRoute: '/longpolling/support_poll',
|
||||
};
|
||||
|
||||
return new Bus(params);
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
odoo.define('im_support.SupportSession', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module returns an instance of Session which is linked to the Support
|
||||
* server, allowing the current instance to communicate with the Support
|
||||
* server (CORS).
|
||||
*/
|
||||
|
||||
var session = require('web.session');
|
||||
var Session = require('web.Session');
|
||||
|
||||
return new Session(null, session.support_origin, {
|
||||
modules: odoo._modules,
|
||||
use_cors: true,
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
odoo.define('im_support.systray', function (require) {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* This module adds a fake Support channel in the messaging menu of the systray.
|
||||
*/
|
||||
|
||||
var mailSystray = require('mail.systray');
|
||||
|
||||
var config = require('web.config');
|
||||
var core = require('web.core');
|
||||
var session = require('web.session');
|
||||
|
||||
var _t = core._t;
|
||||
var MessagingMenu = mailSystray.MessagingMenu;
|
||||
var SUPPORT_CHANNEL_ID = 'SupportChannel';
|
||||
|
||||
// Disable Support in mobile for design purposes (for now at least), and don't
|
||||
// add it to the messaging dropdown if it isn't available
|
||||
if (config.device.isMobile || !session.support_token || !session.support_origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
MessagingMenu.include({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
init: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.supportChannel = {
|
||||
id: SUPPORT_CHANNEL_ID,
|
||||
name: _t('Support'),
|
||||
image_src: '/mail/static/src/img/odoo_o.png',
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Overrides to add a className to the bottom part of the dropdown
|
||||
* (containing the Support channel), so that the css rules apply. This
|
||||
* className can't be added directly in the template, otherwise
|
||||
* this.$channels_preview would be a nodeset containing the bottom part as
|
||||
* well, and it will cause rendering issues when the dropdown is rerendered.
|
||||
*
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var self = this;
|
||||
return this._super.apply(this, arguments).then(function () {
|
||||
self.$('.o_mail_navbar_dropdown_bottom').addClass('o_mail_navbar_dropdown_channels');
|
||||
});
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Handlers
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overrides to handle the click on the fake Support channel.
|
||||
*
|
||||
* @override
|
||||
* @private
|
||||
*/
|
||||
_onClickChannel: function (ev) {
|
||||
var channelID = $(ev.currentTarget).data('channel_id');
|
||||
if (channelID === SUPPORT_CHANNEL_ID) {
|
||||
this.call('chat_manager', 'startSupportLivechat');
|
||||
} else {
|
||||
this._super.apply(this, arguments);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
.o_mail_navbar_item {
|
||||
.o_mail_navbar_dropdown {
|
||||
.o_mail_navbar_dropdown_bottom {
|
||||
border-top: 1px solid @gray-lighter-darker;
|
||||
box-sizing: content-box;
|
||||
|
||||
.o_channel_info {
|
||||
.o-flex-display();
|
||||
.o-align-items(center);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<template>
|
||||
|
||||
<t t-extend="mail.chat.Sidebar">
|
||||
<t t-jquery="hr" t-operation="before">
|
||||
<t t-foreach="channels" t-as="channel">
|
||||
<t t-if="channel.type == 'livechat'">
|
||||
<div t-attf-class="o_mail_chat_title_main o_mail_chat_channel_item #{(active_channel_id == channel.id) ? 'o_active': ''}"
|
||||
t-att-data-channel-id="channel.id">
|
||||
<span class="o_channel_name"><i class="fa fa-question-circle mr8"/>Odoo Support</span>
|
||||
<t t-set="counter" t-value="0"/>
|
||||
<t t-call="mail.chat.SidebarNeedaction"/>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-extend="mail.chat.MessagingMenu">
|
||||
<t t-jquery=".o_mail_navbar_dropdown" t-operation="append">
|
||||
<li t-if="widget.supportChannel" class="o_mail_navbar_dropdown_bottom">
|
||||
<t t-set="channel" t-value="widget.supportChannel"/>
|
||||
<t t-call="mail.chat.ChannelPreview"/>
|
||||
</li>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,16 @@
|
||||
<openerp>
|
||||
<data>
|
||||
<template id="assets_backend" name="im_support_backend assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<link rel="stylesheet" href="/im_support/static/src/less/systray.less" type="text/less"/>
|
||||
|
||||
<script type="text/javascript" src="/im_support/static/src/js/chat_manager.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/discuss.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/extended_chat_window.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/support_bus.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/support_session.js"></script>
|
||||
<script type="text/javascript" src="/im_support/static/src/js/systray.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</data>
|
||||
</openerp>
|
||||
@@ -11,6 +11,11 @@ from odoo.tools import consteq
|
||||
|
||||
class MassMailController(http.Controller):
|
||||
|
||||
@http.route(['/unsubscribe_from_list'], type='http', website=True, multilang=False, auth='public')
|
||||
def unsubscribe_placeholder_link(self, **post):
|
||||
"""Dummy route so placeholder is not prefixed by language, MUST have multilang=False"""
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
@http.route(['/mail/mailing/<int:mailing_id>/unsubscribe'], type='http', website=True, auth='public')
|
||||
def mailing(self, mailing_id, email=None, res_id=None, token="", **post):
|
||||
mailing = request.env['mail.mass_mailing'].sudo().browse(mailing_id)
|
||||
|
||||
@@ -29,12 +29,12 @@ class StockWarehouse(models.Model):
|
||||
return result
|
||||
|
||||
def _get_manufacture_route_id(self):
|
||||
manufacture_route_id = self.env.ref('mrp.route_warehouse0_manufacture').id
|
||||
if not manufacture_route_id:
|
||||
manufacture_route_id = self.env['stock.location.route'].search([('name', 'like', _('Manufacture'))], limit=1).id
|
||||
if not manufacture_route_id:
|
||||
manufacture_route = self.env.ref('mrp.route_warehouse0_manufacture', raise_if_not_found=False)
|
||||
if not manufacture_route:
|
||||
manufacture_route = self.env['stock.location.route'].search([('name', 'like', _('Manufacture'))], limit=1)
|
||||
if not manufacture_route:
|
||||
raise exceptions.UserError(_('Can\'t find any generic Manufacture route.'))
|
||||
return manufacture_route_id
|
||||
return manufacture_route.id
|
||||
|
||||
def _get_manufacture_pull_rules_values(self, route_values):
|
||||
if not self.manu_type_id:
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</page>
|
||||
<page string="Messages">
|
||||
<group>
|
||||
<field name="pre_msg"/>
|
||||
<field name="pre_msg" invisible="1"/>
|
||||
<field name="post_msg"/>
|
||||
<field name="pending_msg"/>
|
||||
<field name="done_msg"/>
|
||||
|
||||
@@ -140,9 +140,10 @@ img, .media_iframe_video, .o_image {
|
||||
margin-right: $grid-gutter-width / 2;
|
||||
}
|
||||
}
|
||||
img {
|
||||
// Make all images responsive by default but with inline-block display
|
||||
@include img-responsive(inline-block);
|
||||
img.o_we_custom_image {
|
||||
// Images added with the editor are .img-responsive by default but should
|
||||
// still behave like inline content.
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
// Others
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
<tbody>
|
||||
<tr style="width: 1in;">
|
||||
<td style="border: 2px solid black;text-align: center; vertical-align: middle;" class="col-xs-5">
|
||||
<img t-if="product.barcode" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
|
||||
<img t-if="product.barcode and len(product.barcode) == 13" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN13', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
|
||||
<img t-elif="product.barcode and len(product.barcode) == 8" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('EAN8', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
|
||||
<img t-else="" t-att-src="'/report/barcode/?type=%s&value=%s&width=%s&height=%s' % ('Code128', product.barcode, 600, 150)" style="width:100%;height:20%;"/>
|
||||
<span t-field="product.barcode"/>
|
||||
</td>
|
||||
<td style="border: 2px solid black; text-align: center;" class="col-xs-7">
|
||||
|
||||
@@ -34,13 +34,14 @@
|
||||
# David Arnold <blaggacao@users.noreply.github.com>, 2018
|
||||
# Lina Maria Avendaño Carvajal <lina8823@gmail.com>, 2018
|
||||
# Gustavo Valverde, 2018
|
||||
# Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~11.2\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-03-22 14:10+0000\n"
|
||||
"PO-Revision-Date: 2018-03-22 14:10+0000\n"
|
||||
"Last-Translator: Gustavo Valverde, 2018\n"
|
||||
"Last-Translator: Carlos Vásquez <carlos.vasquez@clearcorp.co.cr>, 2018\n"
|
||||
"Language-Team: Spanish (https://www.transifex.com/odoo/teams/41243/es/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@@ -78,6 +79,32 @@ msgid ""
|
||||
"% endif\n"
|
||||
"</p>\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"<p>Estimado(a)\n"
|
||||
"% if object.partner_id.is_company and object.partner_id.child_ids:\n"
|
||||
" ${object.partner_id.child_ids[0].name}\n"
|
||||
"% else :\n"
|
||||
" ${object.partner_id.name}\n"
|
||||
"% endif\n"
|
||||
",</p><p>\n"
|
||||
"Aqui se encuenta, en adjunto, una ${object.state in ('draft', 'sent') and 'solicitud de presupuesto' or 'confirmación de orden de compra'} <strong>${object.name}</strong>\n"
|
||||
"% if object.partner_ref:\n"
|
||||
" con referencia: ${object.partner_ref}\n"
|
||||
"% endif\n"
|
||||
"% if object.origin:\n"
|
||||
" (SdP origen: ${object.origin})\n"
|
||||
"% endif\n"
|
||||
"con un monto de <strong>${format_amount(object.amount_total, object.currency_id)}</strong>\n"
|
||||
"de ${object.company_id.name}.\n"
|
||||
"</p>\n"
|
||||
"\n"
|
||||
"<p>No dude en contactarnos en caso de tener alguna inquietud.</p>\n"
|
||||
"<p>Saludos cordiales,</p>\n"
|
||||
"<p style=\"color:#888888;\">\n"
|
||||
"% if object.user_id and object.user_id.signature:\n"
|
||||
" ${object.user_id.signature | safe}\n"
|
||||
"% endif\n"
|
||||
"</p>\n"
|
||||
|
||||
#. module: purchase
|
||||
#: model:mail.template,body_html:purchase.email_template_edi_purchase_done
|
||||
@@ -103,6 +130,26 @@ msgid ""
|
||||
"<p>You can reply to this email if you have any questions.</p>\n"
|
||||
"<p>Thank you,</p>\n"
|
||||
msgstr ""
|
||||
"\n"
|
||||
"<p>Estimado(a) ${object.partner_id.name} \n"
|
||||
"% if object.partner_id.parent_id:\n"
|
||||
" (<i>${object.partner_id.parent_id.name}</i>)\n"
|
||||
"% endif\n"
|
||||
",</p>\n"
|
||||
"<p>\n"
|
||||
"Adjunto le remito la <strong>${object.state in ('draft', 'sent') and 'solicitud de presupuesto' or 'confirmación de pedido de compra} ${object.name}</strong>\n"
|
||||
"% if object.partner_ref:\n"
|
||||
" con su referencia: ${object.partner_ref}\n"
|
||||
"% endif\n"
|
||||
"% if object.origin:\n"
|
||||
" (SdP origen: ${object.origin})\n"
|
||||
"% endif\n"
|
||||
"con un monto de <strong>${object.amount_total} ${object.currency_id.name}</strong>\n"
|
||||
"de ${object.company_id.name}.\n"
|
||||
"</p>\n"
|
||||
"\n"
|
||||
"<p>Si tiene alguna duda, puede responder este email.</p>\n"
|
||||
"<p>Gracias,</p>\n"
|
||||
|
||||
#. module: purchase
|
||||
#: code:addons/purchase/models/stock.py:100
|
||||
|
||||
@@ -56,7 +56,6 @@ class MrpStockReport(models.TransientModel):
|
||||
lines = move_line.search([
|
||||
('lot_id', '=', context.get('lot_name') or rec_id),
|
||||
('state', '=', 'done'),
|
||||
('move_id.returned_move_ids', '=', False),
|
||||
])
|
||||
if model == 'stock.move.line':
|
||||
for line in lines:
|
||||
|
||||
@@ -80,6 +80,9 @@ var ControlPanel = Widget.extend({
|
||||
}
|
||||
|
||||
this.bus = new Bus(this);
|
||||
// the updateIndex is used to prevent concurrent updates of the control
|
||||
// panel depending on asynchronous code to be executed in the wrong order
|
||||
this.bus.updateIndex = 0;
|
||||
this.bus.on("update", this, this.update);
|
||||
},
|
||||
/**
|
||||
@@ -126,6 +129,8 @@ var ControlPanel = Widget.extend({
|
||||
* elements that are not in status.cp_content
|
||||
*/
|
||||
update: function(status, options) {
|
||||
this.bus.updateIndex++;
|
||||
|
||||
this._toggle_visibility(!status.hidden);
|
||||
|
||||
// Don't update the ControlPanel in headless mode as the views have
|
||||
|
||||
@@ -235,11 +235,26 @@ var AbstractController = AbstractAction.extend(ControlPanelMixin, {
|
||||
var self = this;
|
||||
var shouldReload = (options && 'reload' in options) ? options.reload : true;
|
||||
var def = shouldReload ? this.model.reload(this.handle, params) : $.when();
|
||||
// we check here that the updateIndex of the control panel hasn't changed
|
||||
// between the start of the update request and the moment the controller
|
||||
// asks the control panel to update itself ; indeed, it could happen that
|
||||
// another action/controller is executed during this one reloads itself,
|
||||
// and if that one finishes first, it replaces this controller in the DOM,
|
||||
// and this controller should no longer update the control panel.
|
||||
// note that this won't be necessary as soon as each controller will have
|
||||
// its own control panel
|
||||
var cpUpdateIndex = this.cp_bus && this.cp_bus.updateIndex;
|
||||
return this.dp.add(def).then(function (handle) {
|
||||
if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) {
|
||||
return;
|
||||
}
|
||||
self.handle = handle || self.handle; // update handle if we reloaded
|
||||
var state = self.model.get(self.handle);
|
||||
var localState = self.renderer.getLocalState();
|
||||
return self.dp.add(self.renderer.updateState(state, params)).then(function () {
|
||||
if (self.cp_bus && cpUpdateIndex !== self.cp_bus.updateIndex) {
|
||||
return;
|
||||
}
|
||||
self.renderer.setLocalState(localState);
|
||||
self._update(state);
|
||||
});
|
||||
|
||||
@@ -886,6 +886,15 @@ var BasicModel = AbstractModel.extend({
|
||||
// x2Many case: the new record has been stored in _changes, as a
|
||||
// command so we remove the command(s) related to that record
|
||||
parent._changes = _.filter(parent._changes, function (change) {
|
||||
if (change.id === elementID &&
|
||||
change.operation === 'ADD' && // For now, only an ADD command increases limits
|
||||
parent.tempLimitIncrement) {
|
||||
// The record will be deleted from the _changes.
|
||||
// So we won't be passing into the logic of _applyX2ManyOperations anymore
|
||||
// implying that we have to cancel out the effects of an ADD command here
|
||||
parent.tempLimitIncrement--;
|
||||
parent.limit--;
|
||||
}
|
||||
return change.id !== elementID;
|
||||
});
|
||||
} else {
|
||||
@@ -3138,6 +3147,15 @@ var BasicModel = AbstractModel.extend({
|
||||
// when sent to the server, the x2manys values must be a list
|
||||
// of commands in a context, but the list of ids in a domain
|
||||
ids.toJSON = _generateX2ManyCommands.bind(null, fieldName);
|
||||
} else if (field.type === 'one2many') { // Ids are evaluated as a list of ids
|
||||
/* Filtering out virtual ids from the ids list
|
||||
* The server will crash if there are virtual ids in there
|
||||
* The webClient doesn't do literal id list comparison like ids == list
|
||||
* Only relevant in o2m: m2m does create actual records in db
|
||||
*/
|
||||
ids = _.filter(ids, function (id) {
|
||||
return typeof id !== 'string';
|
||||
});
|
||||
}
|
||||
context[fieldName] = ids;
|
||||
}
|
||||
|
||||
@@ -661,15 +661,23 @@ var BasicRenderer = AbstractRenderer.extend({
|
||||
|
||||
// Prepare widget rendering and save the related deferred
|
||||
var def = widget._widgetRenderAndInsert(function () {});
|
||||
if (def.state() === 'pending') {
|
||||
var async = def.state() === 'pending';
|
||||
if (async) {
|
||||
this.defs.push(def);
|
||||
}
|
||||
var $el = async ? $('<div>') : widget.$el;
|
||||
|
||||
// handle other attributes/modifiers
|
||||
this._handleAttributes(widget.$el, node);
|
||||
this._registerModifiers(node, record, widget);
|
||||
widget.$el.addClass('o_widget');
|
||||
return widget.$el;
|
||||
var self = this;
|
||||
def.then(function () {
|
||||
self._handleAttributes(widget.$el, node);
|
||||
self._registerModifiers(node, record, widget);
|
||||
widget.$el.addClass('o_widget');
|
||||
if (async) {
|
||||
$el.replaceWith(widget.$el);
|
||||
}
|
||||
});
|
||||
|
||||
return $el;
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -1559,6 +1559,66 @@ QUnit.module('ActionManager', {
|
||||
actionManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('open a record while reloading the list view', function (assert) {
|
||||
assert.expect(12);
|
||||
|
||||
var def;
|
||||
var actionManager = createActionManager({
|
||||
actions: this.actions,
|
||||
archs: this.archs,
|
||||
data: this.data,
|
||||
mockRPC: function (route) {
|
||||
var result = this._super.apply(this, arguments);
|
||||
if (route === '/web/dataset/search_read') {
|
||||
return $.when(def).then(_.constant(result));
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
actionManager.doAction(3);
|
||||
|
||||
assert.strictEqual(actionManager.$('.o_list_view').length, 1,
|
||||
"should display the list view");
|
||||
assert.strictEqual(actionManager.$('.o_list_view .o_data_row').length, 5,
|
||||
"list view should contain 5 records");
|
||||
assert.strictEqual($('.o_control_panel .o_list_buttons').length, 1,
|
||||
"list view buttons should be displayed in control panel");
|
||||
|
||||
// reload (the search_read RPC will be blocked)
|
||||
def = $.Deferred();
|
||||
$('.o_control_panel .o_cp_switch_list').click(); // click on the switch button
|
||||
|
||||
assert.strictEqual(actionManager.$('.o_list_view .o_data_row').length, 5,
|
||||
"list view should still contain 5 records");
|
||||
assert.strictEqual($('.o_control_panel .o_list_buttons').length, 1,
|
||||
"list view buttons should still be displayed in control panel");
|
||||
|
||||
// open a record in form view
|
||||
actionManager.$('.o_list_view .o_data_row:first').click();
|
||||
|
||||
assert.strictEqual(actionManager.$('.o_form_view').length, 1,
|
||||
"should display the form view");
|
||||
assert.strictEqual($('.o_control_panel .o_list_buttons').length, 0,
|
||||
"list view buttons should no longer be displayed in control panel");
|
||||
assert.strictEqual($('.o_control_panel .o_form_buttons_view').length, 1,
|
||||
"form view buttons should be displayed instead");
|
||||
|
||||
// unblock the search_read RPC
|
||||
def.resolve();
|
||||
|
||||
assert.strictEqual(actionManager.$('.o_form_view').length, 1,
|
||||
"should display the form view");
|
||||
assert.strictEqual(actionManager.$('.o_list_view').length, 0,
|
||||
"should not display the list view");
|
||||
assert.strictEqual($('.o_control_panel .o_list_buttons').length, 0,
|
||||
"list view buttons should still not be displayed in control panel");
|
||||
assert.strictEqual($('.o_control_panel .o_form_buttons_view').length, 1,
|
||||
"form view buttons should still be displayed instead");
|
||||
|
||||
actionManager.destroy();
|
||||
});
|
||||
|
||||
QUnit.module('Client Actions');
|
||||
|
||||
QUnit.test('can execute client actions from tag name', function (assert) {
|
||||
|
||||
@@ -2551,6 +2551,117 @@ QUnit.module('relational_fields', {
|
||||
|
||||
QUnit.module('FieldOne2Many');
|
||||
|
||||
QUnit.test('O2M List with pager, decoration and default_order: add and cancel adding', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
// The decoration on the list implies that its condition will be evaluated
|
||||
// against the data of the field (actual records *displayed*)
|
||||
// If one data is wrongly formed, it will crash
|
||||
// This test adds then cancels a record in a paged, ordered, and decorated list
|
||||
// That implies prefetching of records for sorting
|
||||
// and evaluation of the decoration against *visible records*
|
||||
|
||||
this.data.partner.records[0].p = [2,4];
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch:'<form string="Partners">' +
|
||||
'<field name="p">' +
|
||||
'<tree editable="bottom" limit="1" decoration-muted="foo != False" default_order="display_name">' +
|
||||
'<field name="foo" invisible="1"/>' +
|
||||
'<field name="display_name" />' +
|
||||
'</tree>' +
|
||||
'</field>' +
|
||||
'</form>',
|
||||
res_id: 1,
|
||||
viewOptions: {
|
||||
mode: 'edit',
|
||||
},
|
||||
});
|
||||
|
||||
form.$('.o_field_x2many_list .o_field_x2many_list_row_add a').click();
|
||||
|
||||
var $dataRows = form.$('.o_field_x2many_list .o_data_row');
|
||||
assert.equal($dataRows.length, 2,
|
||||
'There should be 2 rows');
|
||||
|
||||
var $expectedSelectedRow = $dataRows.eq(1);
|
||||
var $actualSelectedRow = form.$('.o_selected_row');
|
||||
assert.equal($actualSelectedRow[0], $expectedSelectedRow[0],
|
||||
'The selected row should be the new one');
|
||||
|
||||
// Cancel Creation
|
||||
var escapeKey = $.ui.keyCode.ESCAPE;
|
||||
$actualSelectedRow.find('input').trigger(
|
||||
$.Event('keydown', {which: escapeKey, keyCode: escapeKey}));
|
||||
|
||||
$dataRows = form.$('.o_field_x2many_list .o_data_row');
|
||||
assert.equal($dataRows.length, 1,
|
||||
'There should be 1 row');
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('O2M with parented m2o and domain on parent.m2o', function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
/* records in an o2m can have a m2o pointing to themselves
|
||||
* in that case, a domain evaluation on that field followed by name_search
|
||||
* shouldn't send virtual_ids to the server
|
||||
*/
|
||||
|
||||
this.data.turtle.fields.parent_id = {string: "Parent", type: "many2one", relation: 'turtle'};
|
||||
var form = createView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch:'<form string="Partners">' +
|
||||
'<field name="turtles">' +
|
||||
'<tree>' +
|
||||
'<field name="parent_id" />' +
|
||||
'</tree>' +
|
||||
'</field>' +
|
||||
'</form>',
|
||||
archs: {
|
||||
'turtle,false,form': '<form><field name="parent_id" domain="[(\'id\', \'in\', parent.turtles)]"/></form>',
|
||||
},
|
||||
|
||||
mockRPC: function(route, args) {
|
||||
if (route === '/web/dataset/call_kw/turtle/name_search') {
|
||||
// We are going to pass twice here
|
||||
// First time, we really have nothing
|
||||
// Second time, a virtual_id has been created
|
||||
assert.deepEqual(args.kwargs.args, [['id', 'in', []]]);
|
||||
}
|
||||
return this._super(route, args);
|
||||
}
|
||||
});
|
||||
|
||||
form.$('.o_field_x2many_list[name=turtles] .o_field_x2many_list_row_add a').click();
|
||||
var $modal = $('.modal-content');
|
||||
|
||||
var $turtleParent = $modal.find('.o_field_many2one input');
|
||||
var $dropdown = $turtleParent.autocomplete('widget');
|
||||
|
||||
$turtleParent.click();
|
||||
|
||||
$dropdown.find('li.o_m2o_dropdown_option:contains(Create)').first().mouseenter().click();
|
||||
|
||||
$modal = $('.modal-content');
|
||||
|
||||
$modal.eq(1).find('.modal-footer .btn-primary').eq(0).click(); // Confirm new Record
|
||||
$modal.eq(0).find('.modal-footer .btn-primary').eq(1).click(); // Save & New
|
||||
|
||||
assert.equal(form.$('.o_data_row').length, 1,
|
||||
'The main record should have the new record in its o2m');
|
||||
|
||||
$modal = $('.modal-content');
|
||||
$modal.find('.o_field_many2one input').click();
|
||||
|
||||
form.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('one2many list editable with cell readonly modifier', function (assert) {
|
||||
assert.expect(4);
|
||||
|
||||
|
||||
@@ -7248,6 +7248,35 @@ QUnit.module('Views', {
|
||||
assert.ok("Behavior can't be tested");
|
||||
});
|
||||
|
||||
QUnit.test('asynchronous rendering of a widget tag', function (assert) {
|
||||
assert.expect(1);
|
||||
|
||||
var def1 = $.Deferred();
|
||||
|
||||
var MyWidget = Widget.extend({
|
||||
willStart: function() {
|
||||
return def1;
|
||||
},
|
||||
});
|
||||
|
||||
widgetRegistry.add('test', MyWidget);
|
||||
|
||||
createAsyncView({
|
||||
View: FormView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<form>' +
|
||||
'<widget name="test"/>' +
|
||||
'</form>',
|
||||
}).then(function(form) {
|
||||
assert.strictEqual(form.$('div.o_widget').length, 1,
|
||||
"there should be a div with widget class");
|
||||
form.destroy();
|
||||
delete widgetRegistry.map.test;
|
||||
});
|
||||
|
||||
def1.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -304,9 +304,12 @@ var RTEWidget = Widget.extend({
|
||||
});
|
||||
|
||||
// start element observation
|
||||
$(document).on('content_changed', '.o_editable', function (event) {
|
||||
self.trigger_up('rte_change', {target: event.target});
|
||||
$(this).addClass('o_dirty');
|
||||
$(document).on('content_changed', '.o_editable', function (ev) {
|
||||
self.trigger_up('rte_change', {target: ev.target});
|
||||
if (!ev.__isDirtyHandled) {
|
||||
$(this).addClass('o_dirty');
|
||||
ev.__isDirtyHandled = true;
|
||||
}
|
||||
});
|
||||
|
||||
$('#wrapwrap, .o_editable').on('click.rte', '*', this, this._onClick.bind(this));
|
||||
@@ -424,6 +427,10 @@ var RTEWidget = Widget.extend({
|
||||
save: function (context) {
|
||||
var self = this;
|
||||
|
||||
$('.o_editable')
|
||||
.destroy()
|
||||
.removeClass('o_editable o_is_inline_editable');
|
||||
|
||||
var $dirty = $('.o_dirty');
|
||||
$dirty
|
||||
.removeAttr('contentEditable')
|
||||
|
||||
@@ -308,7 +308,10 @@ var ImageWidget = MediaWidget.extend({
|
||||
img.src += _.str.sprintf('?access_token=%s', img.access_token);
|
||||
}
|
||||
if (!self.$media.is('img')) {
|
||||
self._replaceMedia($('<img/>'));
|
||||
// Note: by default the images receive the bootstrap opt-in
|
||||
// img-responsive class. We cannot make them all responsive
|
||||
// by design because of libraries and client databases img.
|
||||
self._replaceMedia($('<img/>', {class: 'img-responsive o_we_custom_image'}));
|
||||
}
|
||||
self.$media.attr('src', img.src);
|
||||
|
||||
|
||||
+932
-60
File diff suppressed because it is too large
Load Diff
@@ -259,6 +259,14 @@
|
||||
<rng:optional><rng:attribute name="avatar_field" /></rng:optional>
|
||||
<rng:optional><rng:attribute name="write_model" /></rng:optional>
|
||||
<rng:optional><rng:attribute name="write_field" /></rng:optional>
|
||||
<rng:optional>
|
||||
<rng:attribute name="force_save">
|
||||
<rng:choice>
|
||||
<rng:value>1</rng:value>
|
||||
<rng:value>0</rng:value>
|
||||
</rng:choice>
|
||||
</rng:attribute>
|
||||
</rng:optional>
|
||||
<!-- Widget *static* options defined as an arbitrary JSON dict, with
|
||||
widget-dependent parameters. To be ignored if widget/client does
|
||||
not support them. -->
|
||||
|
||||
Reference in New Issue
Block a user