[MERGE] forward port of branch 9.0 up to 14799a9

This commit is contained in:
Christophe Simonis
2015-11-09 10:40:45 +01:00
61 changed files with 1051 additions and 845 deletions
+9 -8
View File
@@ -137,28 +137,29 @@ class AccountInvoice(models.Model):
if self.payment_move_line_ids:
info = {'title': _('Less Payment'), 'outstanding': False, 'content': []}
for payment in self.payment_move_line_ids:
#we don't take into account the movement created due to a change difference
if payment.currency_id and payment.move_id.rate_diff_partial_rec_id:
continue
if self.type in ('out_invoice', 'in_refund'):
amount = sum([p.amount for p in payment.matched_debit_ids if p.debit_move_id in self.move_id.line_ids])
amount_currency = sum([p.amount_currency for p in payment.matched_debit_ids if p.debit_move_id in self.move_id.line_ids])
elif self.type in ('in_invoice', 'out_refund'):
amount = sum([p.amount for p in payment.matched_credit_ids if p.credit_move_id in self.move_id.line_ids])
amount_currency = sum([p.amount_currency for p in payment.matched_credit_ids if p.credit_move_id in self.move_id.line_ids])
#we don't take into account the movement created due to a change difference
if payment.currency_id and payment.move_id.rate_diff_partial_rec_id:
continue
# get the payment value in invoice currency
if payment.currency_id and amount_currency != 0:
currency_id = payment.currency_id
amount_to_show = -amount_currency
else:
self.with_context(date=payment.date)
amount_to_show = payment.company_id.currency_id.compute(-amount, self.currency_id)
currency_id = payment.company_id.currency_id
amount_to_show = -amount
info['content'].append({
'name': payment.name,
'journal_name': payment.journal_id.name,
'amount': amount_to_show,
'currency': self.currency_id.symbol,
'digits': [69, self.currency_id.decimal_places],
'position': self.currency_id.position,
'currency': currency_id.symbol,
'digits': [69, currency_id.decimal_places],
'position': currency_id.position,
'date': payment.date,
'payment_id': payment.id,
'move_id': payment.move_id.id,
-12
View File
@@ -228,18 +228,6 @@ class ResPartner(models.Model):
_inherit = 'res.partner'
_description = 'Partner'
@api.multi
def onchange_state(self, state_id):
res = super(ResPartner, self).onchange_state(state_id=state_id)
res['value']['property_account_position_id'] = self.env['account.fiscal.position']._get_fpos_by_region(
country_id=self.env.context.get('country_id'), state_id=state_id, zipcode=self.env.context.get('zip'))
return res
@api.onchange('country_id', 'zip')
def _onchange_country_id(self):
self.property_account_position_id = self.env['account.fiscal.position']._get_fpos_by_region(
country_id=self.country_id.id, state_id=self.state_id.id, zipcode=self.zip)
@api.multi
def _credit_debit_get(self):
tables, where_clause, where_params = self.env['account.move.line']._query_get()
@@ -13,6 +13,7 @@ access_account_tax,account.tax,model_account_tax,account.group_account_invoice,1
access_account_account_template,account.account.template,model_account_account_template,account.group_account_manager,1,1,1,1
access_account_chart_template,account.chart.template,model_account_chart_template,account.group_account_manager,1,1,1,1
access_account_tax_template,account.tax.template,model_account_tax_template,account.group_account_manager,1,1,1,1
access_account_bank_statement_group_invoice,account.bank.statement.group.invoice,model_account_bank_statement,account.group_account_invoice,1,0,0,0
access_account_bank_statement,account.bank.statement,model_account_bank_statement,account.group_account_user,1,1,1,1
access_account_bank_statement_line,account.bank.statement.line,model_account_bank_statement_line,account.group_account_user,1,1,1,1
access_account_analytic_line_manager,account.analytic.line manager,model_account_analytic_line,account.group_account_manager,1,0,0,0
@@ -65,6 +66,7 @@ access_account_financial_report_invoice,account.financial.report invoice,model_a
access_account_account_tax,account.account.tag,model_account_account_tag,account.group_account_user,1,0,0,0
access_account_account_tax_user,account.account.tag,model_account_account_tag,account.group_account_invoice,1,0,0,0
access_account_operation_template,account.operation.template,model_account_operation_template,account.group_account_user,1,1,1,1
access_account_partial_reconcile_group_invoice,account.partial.reconcile.group.invoice,model_account_partial_reconcile,account.group_account_invoice,1,1,1,0
access_account_partial_reconcile,account.partial.reconcile,model_account_partial_reconcile,account.group_account_user,1,1,1,1
access_account_payment_method,account.payment.method,model_account_payment_method,account.group_account_invoice,1,1,1,1
access_account_payment,account.payment,model_account_payment,account.group_account_invoice,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
13 access_account_account_template account.account.template model_account_account_template account.group_account_manager 1 1 1 1
14 access_account_chart_template account.chart.template model_account_chart_template account.group_account_manager 1 1 1 1
15 access_account_tax_template account.tax.template model_account_tax_template account.group_account_manager 1 1 1 1
16 access_account_bank_statement_group_invoice account.bank.statement.group.invoice model_account_bank_statement account.group_account_invoice 1 0 0 0
17 access_account_bank_statement account.bank.statement model_account_bank_statement account.group_account_user 1 1 1 1
18 access_account_bank_statement_line account.bank.statement.line model_account_bank_statement_line account.group_account_user 1 1 1 1
19 access_account_analytic_line_manager account.analytic.line manager model_account_analytic_line account.group_account_manager 1 0 0 0
66 access_account_account_tax account.account.tag model_account_account_tag account.group_account_user 1 0 0 0
67 access_account_account_tax_user account.account.tag model_account_account_tag account.group_account_invoice 1 0 0 0
68 access_account_operation_template account.operation.template model_account_operation_template account.group_account_user 1 1 1 1
69 access_account_partial_reconcile_group_invoice account.partial.reconcile.group.invoice model_account_partial_reconcile account.group_account_invoice 1 1 1 0
70 access_account_partial_reconcile account.partial.reconcile model_account_partial_reconcile account.group_account_user 1 1 1 1
71 access_account_payment_method account.payment.method model_account_payment_method account.group_account_invoice 1 1 1 1
72 access_account_payment account.payment model_account_payment account.group_account_invoice 1 1 1 1
+1 -1
View File
@@ -10,7 +10,7 @@ It must be used as a dependency for modules that provide country-specific check
The check settings are located in the accounting journals configuration page.
""",
'website': 'https://www.odoo.com/page/accounting',
'depends' : ['account_accountant'],
'depends' : ['account'],
'data': [
'data/check_printing.xml',
'views/account_journal_dashboard_view.xml',
+3 -1
View File
@@ -96,7 +96,9 @@ class AuthSignupHome(openerp.addons.web.controllers.main.Home):
values = dict((key, qcontext.get(key)) for key in ('login', 'name', 'password'))
assert any([k for k in values.values()]), "The form was not properly filled in."
assert values.get('password') == qcontext.get('confirm_password'), "Passwords do not match; please retype them."
values['lang'] = request.lang
supported_langs = [lang['code'] for lang in request.registry['res.lang'].search_read(request.cr, openerp.SUPERUSER_ID, [], ['code'])]
if request.lang in supported_langs:
values['lang'] = request.lang
self._signup_with_values(qcontext.get('token'), values)
request.cr.commit()
+7 -4
View File
@@ -63,7 +63,10 @@ class res_partner(osv.Model):
continue # no signup token, no user, thus no signup url!
fragment = dict()
if action:
base = '/web#'
if action == '/mail/view':
base = '/mail/view?'
elif action:
fragment['action'] = action
if view_type:
fragment['view_type'] = view_type
@@ -72,10 +75,10 @@ class res_partner(osv.Model):
if model:
fragment['model'] = model
if res_id:
fragment['id'] = res_id
fragment['res_id'] = res_id
if fragment:
query['redirect'] = '/web#' + werkzeug.url_encode(fragment)
query['redirect'] = base + werkzeug.url_encode(fragment)
res[partner.id] = urljoin(base_url, "/web/%s?%s" % (route, werkzeug.url_encode(query)))
@@ -148,7 +151,7 @@ class res_partner(osv.Model):
if partner.user_ids:
res['login'] = partner.user_ids[0].login
else:
res['email'] = partner.email or ''
res['email'] = res['login'] = partner.email or ''
return res
class res_users(osv.Model):
+1 -1
View File
@@ -11,7 +11,7 @@ This module installs the base for IBAN (International Bank Account Number) bank
The ability to extract the correctly represented local accounts from IBAN accounts
with a single statement.
""",
'depends': ['account_accountant'],
'depends': ['account'],
'demo': ['demo/iban_demo.xml'],
'installable': True,
'auto_install': False,
+2 -10
View File
@@ -1011,8 +1011,10 @@ class calendar_event(osv.Model):
return value
start = datetime.strptime(start, DEFAULT_SERVER_DATETIME_FORMAT)
value['stop_date'] = (start + timedelta(hours=duration)).strftime(DEFAULT_SERVER_DATE_FORMAT)
value['stop'] = (start + timedelta(hours=duration)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
value['stop_datetime'] = (start + timedelta(hours=duration)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
value['start_date'] = start.strftime(DEFAULT_SERVER_DATE_FORMAT)
value['start'] = start.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
return {'value': value}
def onchange_dates(self, cr, uid, ids, fromtype, start=False, end=False, checkallday=False, allday=False, context=None):
@@ -1038,16 +1040,6 @@ class calendar_event(osv.Model):
value['stop_datetime'] = datetime.strftime(end, DEFAULT_SERVER_DATETIME_FORMAT)
value['stop'] = datetime.strftime(end, DEFAULT_SERVER_DATETIME_FORMAT)
else:
if fromtype == 'start' and start:
start = datetime.strptime(start, DEFAULT_SERVER_DATETIME_FORMAT)
value['start_date'] = datetime.strftime(start, DEFAULT_SERVER_DATE_FORMAT)
value['start'] = datetime.strftime(start, DEFAULT_SERVER_DATETIME_FORMAT)
if fromtype == 'stop' and end:
end = datetime.strptime(end, DEFAULT_SERVER_DATETIME_FORMAT)
value['stop_date'] = datetime.strftime(end, DEFAULT_SERVER_DATE_FORMAT)
value['stop'] = datetime.strftime(end, DEFAULT_SERVER_DATETIME_FORMAT)
return {'value': value}
def new_invitation_token(self, cr, uid, record, partner_id):
+1 -1
View File
@@ -528,7 +528,7 @@
<group>
<label for="contact_name"/>
<div>
<div class="o_row">
<field name="contact_name"/>
<field name="title" placeholder="Title" domain="[]" options='{"no_open": True}'/>
</div>
+2
View File
@@ -76,6 +76,8 @@ class StockPicking(models.Model):
if self.carrier_id and self.carrier_id.delivery_type not in ['fixed', 'base_on_rule'] and self.carrier_id.shipping_enabled:
self.send_to_shipper()
self._add_delivery_cost_to_so()
elif self.carrier_id and self.carrier_id.delivery_type in ['fixed', 'base_on_rule'] and self.carrier_id.shipping_enabled:
self.carrier_price = sum(self.sale_id.order_line.filtered(lambda r: r.is_delivery).mapped('price_unit'))
return res
+3 -3
View File
@@ -4,15 +4,15 @@
<!-- Discussion groups, done in 2 steps to remove creator from followers -->
<record model="mail.channel" id="channel_1">
<field name="name">Best Sales Practices</field>
<field name="name">sales</field>
<field name="description">Discussion about best sales practices and deals.</field>
</record>
<record model="mail.channel" id="channel_2">
<field name="name">Board meetings</field>
<field name="name">board-meetings</field>
<field name="description">Board meetings, budgets, strategic plans</field>
</record>
<record model="mail.channel" id="channel_3">
<field name="name">R&amp;D</field>
<field name="name">rd</field>
<field name="description">Research and development discussion group</field>
</record>
+24 -5
View File
@@ -7,6 +7,7 @@ import uuid
from openerp import _, api, fields, models, modules, tools
from openerp.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
from openerp.exceptions import UserError
from openerp.osv import expression
from openerp.addons.bus.models.bus_presence import AWAY_TIMER
@@ -141,14 +142,17 @@ class Channel(models.Model):
@api.multi
def action_follow(self):
return self.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id})]})
self.ensure_one()
channel_partner = self.mapped('channel_last_seen_partner_ids').filtered(lambda cp: cp.partner_id == self.env.user.partner_id)
if not channel_partner:
return self.write({'channel_last_seen_partner_ids': [(0, 0, {'partner_id': self.env.user.partner_id.id})]})
@api.multi
def action_unfollow(self):
result = self.write({'channel_partner_ids': [(3, self.env.user.partner_id.id)]})
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), self.channel_info()[0])
notification = _('<div class="o_mail_notification">left <a href="#" data-channel-id="%s">#%s</a></div>') % (self.uuid, self.name,)
self.message_post(body=notification, message_type="comment", subtype="mail.mt_comment")
notification = _('<div class="o_mail_notification">left <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,)
self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment")
return result
@@ -485,7 +489,7 @@ class Channel(models.Model):
@api.multi
def channel_join_and_get_info(self):
self.ensure_one()
notification = _('<div class="o_mail_notification">joined <a href="#" data-channel-id="%s">#%s</a></div>') % (self.uuid, self.name,)
notification = _('<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (self.id, self.name,)
self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment")
self.action_follow()
@@ -509,7 +513,22 @@ class Channel(models.Model):
'channel_partner_ids': [(4, self.env.user.partner_id.id)]
})
channel_info = new_channel.channel_info()[0]
notification = _('<div class="o_mail_notification">created <a href="#" data-channel-id="%s">#%s</a></div>') % (new_channel.uuid, new_channel.name,)
notification = _('<div class="o_mail_notification">created <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>') % (new_channel.id, new_channel.name,)
new_channel.message_post(body=notification, message_type="notification", subtype="mail.mt_comment")
self.env['bus.bus'].sendone((self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info)
return channel_info
@api.model
def get_mention_suggestions(self, search, limit=8):
""" Return 'limit'-first channels' id, name and public fields such that the name matches a
'search' string. Exclude channels of type chat (DM), and private channels the current
user isn't registered to. """
domain = expression.AND([
[('name', 'ilike', search)],
[('channel_type', '!=', 'chat')],
expression.OR([
[('public', '!=', 'private')],
[('channel_partner_ids', 'in', [self.env.user.partner_id.id])]
])
])
return self.search_read(domain, ['id', 'name', 'public'], limit=limit)
+14 -1
View File
@@ -85,7 +85,7 @@ class Message(models.Model):
# recipients
partner_ids = fields.Many2many('res.partner', string='Recipients')
needaction_partner_ids = fields.Many2many(
'res.partner', 'mail_message_res_partner_needaction_rel', string='Need Action')
'res.partner', 'mail_message_res_partner_needaction_rel', string='Partners with Need Action')
needaction = fields.Boolean(
'Need Action', compute='_get_needaction', search='_search_needaction',
help='Need Action')
@@ -155,6 +155,19 @@ class Message(models.Model):
self = self.sudo()
return self.write({'needaction_partner_ids': [(4, pid) for pid in partner_ids]})
@api.multi
def mark_all_as_read(self):
user_id = self.env.user.partner_id.id
# possibly horribly inefficient method:
# it does one db request for the search, and one for each message in
# the result set to remove the current user from the relation.
# unread_messages = self.search([('needaction_partner_ids', 'in', user_id)])
# unread_messages.write({'needaction_partner_ids': [(3, user_id)]})
# a much faster way to do this is in pure sql:
self.env.cr.execute("DELETE FROM mail_message_res_partner_needaction_rel WHERE res_partner_id IN %s", [(user_id,)])
@api.multi
def set_message_done(self, partner_ids=None):
if not partner_ids:
+27 -3
View File
@@ -9,7 +9,7 @@ var session = require('web.session');
var web_client = require('web.web_client');
var _t = core._t;
var LIMIT = 20;
var LIMIT = 100;
var preview_msg_max_size = 50;
var MessageModel = new Model('mail.message', session.context);
@@ -22,6 +22,7 @@ var channels = [];
var emojis = [];
var emoji_substitutions = {};
var needaction_counter = 0;
var discuss_ids = {};
// Message and channel manipulation helpers
//----------------------------------------------------------------------------------
@@ -34,7 +35,7 @@ function add_message (data, options) {
if (!msg) {
msg = make_message(data);
// Keep the array ordered by date when inserting the new message
messages.splice(_.sortedIndex(messages, msg, 'date'), 0, msg);
messages.splice(_.sortedIndex(messages, msg, 'id'), 0, msg);
if (options.channel_id) {
var channel = _.findWhere(channels, {id: options.channel_id});
if (channel.hidden) {
@@ -356,6 +357,17 @@ var chat_manager = {
});
},
mark_all_as_read: function () {
MessageModel.call('mark_all_as_read', [[]], {}).then(function (ids) {
_.each(messages, function (msg) {
remove_message_from_channel("channel_inbox", msg);
chat_manager.bus.trigger('update_message', msg);
});
needaction_counter = 0;
chat_manager.bus.trigger('update_needaction', needaction_counter);
});
},
get_channels: function () {
return _.clone(channels);
},
@@ -376,6 +388,10 @@ var chat_manager = {
return needaction_counter;
},
get_discuss_ids: function () {
return discuss_ids;
},
detach_channel: function (channel) {
return ChannelModel.call("channel_minimize", [channel.uuid, true]);
},
@@ -454,6 +470,10 @@ function init () {
});
});
var ir_model = new Model("ir.model.data");
var load_menu_id = ir_model.call("xmlid_to_res_id", ["mail.mail_channel_menu_root_chat"]);
var load_action_id = ir_model.call("xmlid_to_res_id", ["mail.mail_channel_action_client_chat"]);
bus.on('notification', null, function (notification) {
var model = notification[0][1];
if (model === 'ir.needaction') {
@@ -498,7 +518,11 @@ function init () {
}
});
return $.when(load_channels, load_emojis).then(function () {
return $.when(load_menu_id, load_action_id, load_channels, load_emojis).then(function (menu_id, action_id) {
discuss_ids = {
menu_id: menu_id,
action_id: action_id,
};
bus.start_polling();
return chat_manager;
});
+27 -22
View File
@@ -105,7 +105,7 @@ var Followers = form_common.AbstractField.extend({
// event: click on 'edit_subtype(pencil)' button to edit subscription
this.$el.on('click', '.o_edit_subtype', self.on_edit_subtype);
this.$el.on('click', '.o_remove_follower', self.on_remove_follower);
this.$el.on('click', 'a[data-partner]', self.on_follower_clicked);
this.$el.on('click', '.o_mail_redirect', self.on_click_redirect);
},
on_edit_subtype: function (event) {
@@ -167,24 +167,11 @@ var Followers = form_common.AbstractField.extend({
}
},
on_follower_clicked: function (event) {
on_click_redirect: function (event) {
event.preventDefault();
var partner_id = $(event.target).data('partner');
var state = {
'model': 'res.partner',
'id': partner_id,
'title': this.record_name
};
web_client.action_manager.do_push_state(state);
var action = {
type:'ir.actions.act_window',
view_type: 'form',
view_mode: 'form',
res_model: 'res.partner',
views: [[false, 'form']],
res_id: partner_id,
};
this.do_action(action);
var res_id = $(event.target).data('oe-id');
var res_model = $(event.target).data('oe-model');
this.trigger('redirect', res_model, res_id);
},
read_value: function () {
@@ -697,7 +684,6 @@ var Chatter = form_common.AbstractField.extend({
template: 'mail.Chatter',
events: {
"click .o_mail_redirect": "on_click_redirect",
"click .o_chatter_button_new_message": "on_open_composer_new_message",
"click .o_chatter_button_log_note": "on_open_composer_log_note",
},
@@ -717,6 +703,7 @@ var Chatter = form_common.AbstractField.extend({
this.followers = this.field_manager.fields.message_follower_ids;
if (this.followers) {
this.$('.o_chatter_topbar').append(this.followers.$el);
this.followers.on('redirect', this, this.on_redirect);
}
this.thread = new ChatThread(this, {
@@ -728,6 +715,8 @@ var Chatter = form_common.AbstractField.extend({
this.thread.on('toggle_star_status', this, function (message_id) {
chat_manager.toggle_star_status(message_id);
});
this.thread.on('redirect', this, this.on_redirect);
this.thread.on('redirect_to_channel', this, this.on_channel_redirect);
var def1 = this._super.apply(this, arguments);
var def2 = this.thread.appendTo(this.$el);
@@ -778,15 +767,31 @@ var Chatter = form_common.AbstractField.extend({
}
},
on_click_redirect: function (event) {
on_channel_redirect: function (channel_id) {
event.preventDefault();
var self = this;
var def;
var channel = chat_manager.get_channel(channel_id);
// If not registered to 'channel' yet, do it
if (channel.id !== channel_id) {
def = chat_manager.join_channel(channel_id);
}
$.when(def).then(function () {
// Execute Discuss client action with 'channel' as default channel
var discuss_ids = chat_manager.get_discuss_ids();
self.do_action(discuss_ids.action_id, {active_id: channel_id});
});
},
on_redirect: function (res_model, res_id) {
event.preventDefault();
this.do_action({
type:'ir.actions.act_window',
view_type: 'form',
view_mode: 'form',
res_model: $(event.target).data('oe-model'),
res_model: res_model,
views: [[false, 'form']],
res_id: $(event.target).data('oe-id'),
res_id: res_id,
});
},
+27 -23
View File
@@ -16,7 +16,6 @@ var Model = require('web.Model');
var pyeval = require('web.pyeval');
var SearchView = require('web.SearchView');
var session = require('web.session');
var Sidebar = require('web.Sidebar');
var Widget = require('web.Widget');
var QWeb = core.qweb;
@@ -119,11 +118,12 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
this.channels_scrolltop[this.channel.id] = this.thread.get_scrolltop();
},
init: function(parent, action) {
init: function(parent, action, options) {
this._super.apply(this, arguments);
this.action_manager = parent;
this.domain = [];
this.action = action;
this.options = options || {};
this.channels_scrolltop = {};
},
@@ -138,24 +138,13 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
};
var dataset = new data.DataSetSearch(this, 'mail.message');
var view_id = (this.action && this.action.search_view_id && this.action.search_view_id[0]) || false;
var default_channel_id = this.action.context.active_id ||
var default_channel_id = this.options.active_id ||
this.action.context.active_id ||
this.action.params.default_active_id ||
'channel_inbox';
this.searchview = new SearchView(this, dataset, view_id, {}, options);
this.searchview.on('search_data', this, this.on_search);
this.sidebar = new Sidebar(this, {
sections: [
{name: 'action', label: _t('Action')},
],
items: {
action: [
{label: _t('Unsubscribe'), classname: 'o_mail_chat_button_unsubscribe', callback: this.on_click_button_unsubscribe},
{label: _t('Settings'), classname: 'o_mail_chat_button_settings', callback: this.on_click_button_settings},
],
},
});
this.composer = new ChatComposer(this, {
get_channel_info: function () {
return { channel_id: self.channel.id };
@@ -169,13 +158,22 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
this.$buttons.find('button').css({display:"inline-block"});
this.$buttons.on('click', '.o_mail_chat_button_invite', this.on_click_button_invite);
this.$buttons.on('click', '.o_mail_chat_button_detach', this.on_click_button_detach);
this.$buttons.on('click', '.o_mail_chat_button_unsubscribe', this.on_click_button_unsubscribe);
this.$buttons.on('click', '.o_mail_chat_button_settings', this.on_click_button_settings);
this.$buttons.on('click', '.o_mail_toggle_channels', function () {
self.$('.o_mail_chat_sidebar').slideToggle(200);
});
this.$buttons.on('click', '.o_mail_chat_button_mark_read', chat_manager.mark_all_as_read);
this.thread.on('redirect', this, this.on_redirect);
this.thread.on('redirect_to_channel', this, function (channel_id) {
this.set_channel(chat_manager.get_channel(channel_id));
var channel = chat_manager.get_channel(channel_id);
if (channel.id === channel_id) {
this.set_channel(channel);
} else {
chat_manager.join_channel(channel_id);
}
});
this.thread.on('load_more_messages', this, this.load_more_messages);
this.thread.on('mark_as_read', this, function (message_id) {
@@ -189,11 +187,10 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
var def1 = this.thread.prependTo(this.$('.o_mail_chat_content'));
var def2 = this.composer.appendTo(this.$('.o_mail_chat_content'));
var def3 = this.searchview.appendTo($("<div>"));
var def4 = this.sidebar.appendTo($("<div>"));
this.render_sidebar();
return $.when(def1, def2, def3, def4)
return $.when(def1, def2, def3)
.then(this.set_channel.bind(this, chat_manager.get_channel(default_channel_id)))
.then(function () {
chat_manager.bus.on('new_message', self, self.on_new_message);
@@ -219,7 +216,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
self.last_search_val = _.escape(request.term);
self.do_search_channel(self.last_search_val).done(function(result){
result.push({
'label': _.str.sprintf('<strong>'+_t("Create %s")+'</strong>', '<em>"'+self.last_search_val+'"</em>'),
'label': _.str.sprintf('<strong>'+_t("Create %s")+'</strong>', '<em>"#'+self.last_search_val+'"</em>'),
'value': '_create',
});
response(result);
@@ -301,15 +298,24 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
}
var new_channel_scrolltop = this.channels_scrolltop[channel.id];
this.channel = channel;
// Update control panel
this.set("title", channel.name);
// Hide 'detach' button in static channels
this.$buttons
.find('.o_mail_chat_button_detach')
.toggle(channel.type !== "static");
// Hide 'invite', 'unsubscribe' and 'settings' buttons in static channels and DM
this.$buttons
.find('.o_mail_chat_button_invite')
.find('.o_mail_chat_button_invite, .o_mail_chat_button_unsubscribe, .o_mail_chat_button_settings')
.toggle(channel.type !== "dm" && channel.type !== 'static');
this.$buttons
.find('.o_mail_chat_button_mark_read')
.toggle(channel.id === "channel_inbox");
this.update_cp();
this.action.context.active_id = channel.id;
this.action.context.active_ids = [channel.id];
@@ -320,7 +326,6 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
.addClass('o_active');
this.$('.o_chat_composer').toggle(channel.type !== 'static');
this.sidebar.$el.toggle(channel.type !== 'static');
return this.fetch_and_render_thread().then(function () {
self.thread.scroll_to({offset: new_channel_scrolltop});
@@ -388,7 +393,6 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
$buttons: this.$buttons,
$searchview: this.searchview.$el,
$searchview_buttons: this.searchview.$buttons.contents(),
$sidebar: this.sidebar.$el,
},
searchview: this.searchview,
});
@@ -482,7 +486,7 @@ var ChatAction = Widget.extend(ControlPanelMixin, {
var current_channel_id = this.channel.id;
if ((current_channel_id === "channel_starred" && !message.is_starred) ||
(current_channel_id === "channel_inbox" && !message.is_needaction)) {
chat_manager.fetch(this.channel, this.domain).then(function (messages) {
chat_manager.get_messages({channel_id: this.channel.id, domain: this.domain}).then(function (messages) {
var options = self.get_thread_rendering_options();
self.thread.remove_message_and_render(message.id, messages, options);
});
+342 -233
View File
@@ -12,6 +12,301 @@ var Widget = require('web.Widget');
var QWeb = core.qweb;
var _t = core._t;
// The MentionManager allows the Composer to register listeners. For each
// listener, it detects if the user is currently typing a mention (starting by a
// given delimiter). If so, if fetches mention suggestions and renders them. On
// suggestion clicked, it updates the selection for the corresponding listener.
var MentionManager = Widget.extend({
className: 'dropup o_composer_mention_dropdown',
events: {
"mouseover .o_mention_proposition": "on_hover_mention_proposition",
"click .o_mention_proposition": "on_click_mention_item",
},
init: function (parent, options) {
this._super.apply(this, arguments);
this.composer = parent;
this.options = _.extend({}, options, {
min_length: 0,
typing_speed: 200,
});
this.open = false;
this.listeners = [];
this.set('mention_suggestions', []);
this.on('change:mention_suggestions', this, this._render_suggestions);
},
// Events
on_hover_mention_proposition: function (event) {
var $elem = $(event.currentTarget);
this.$('.o_mention_proposition').removeClass('active');
$elem.addClass('active');
},
on_click_mention_item: function (event) {
event.preventDefault();
var text_input = this.composer.$input.val();
var id = $(event.currentTarget).data('id');
var selected_suggestion = _.find(_.flatten(this.get('mention_suggestions')), function (s) {
return s.id === id;
});
var get_mention_index = function (matches, cursor_position) {
for (var i=0; i<matches.length; i++) {
if (cursor_position <= matches[i].index) {
return i;
}
}
return i;
};
// add the selected suggestion to the list
var selection = this.active_listener.selection;
if (selection.length && this.active_listener.sort_selection) {
// get mention matches (ordered by index in the text)
var matches = this._get_match(text_input, this.active_listener);
var index = get_mention_index(matches, this._get_selection_positions().start);
selection.splice(index, 0, selected_suggestion);
} else {
selection = selection.concat([selected_suggestion]);
}
this._update_listener_selection(this.active_listener, selection);
// update input text, and reset dropdown
var cursor_position = this._get_selection_positions().start;
var text_left = text_input.substring(0, cursor_position-(this.mention_word.length+1));
var text_right = text_input.substring(cursor_position, text_input.length);
var text_input_new = text_left + this.active_listener.delimiter + selected_suggestion.name + ' ' + text_right;
this.composer.$input.val(text_input_new);
this._set_cursor_position(text_left.length+selected_suggestion.name.length+2);
this.set('mention_suggestions', []);
},
// Public API
/**
* Registers a new listener, described by an object containing the following keys
* @param {char} [delimiter] the mention delimiter
* @param {function} [fetch_callback] the callback to fetch mention suggestions
* @param {string} [model] the model used for redirection
* @param {boolean} [notify_selection_changed] true to trigger an event when the selection changes
* @param {string} [redirect_classname] the classname of the <a> wrapping the mention
* @param {boolean} [sort_selection] true if the selection must be sorted
* @param {string} [suggestions_template] the QWeb template to render suggestions
*/
register: function (listener) {
this.listeners.push(_.extend(listener, {
selection: [],
}));
},
/**
* Returns true if the mention suggestions dropdown is open, false otherwise
*/
is_open: function () {
return this.open;
},
get_listener_selection: function (delimiter) {
var listener = _.findWhere(this.listeners, {delimiter: delimiter});
return listener ? listener.selection : [];
},
proposition_navigation: function (keycode) {
var $active = this.$('.o_mention_proposition.active');
if (keycode === $.ui.keyCode.ENTER) { // selecting proposition
$active.click();
} else { // navigation in propositions
var $to;
if (keycode === $.ui.keyCode.DOWN) {
$to = $active.nextAll('.o_mention_proposition').first();
} else {
$to = $active.prevAll('.o_mention_proposition').first();
}
if ($to.length) {
$active.removeClass('active');
$to.addClass('active');
}
}
},
/**
* Detects if the user is currently typing a mention word
* @return the search string if it is, false otherwise
*/
detect_delimiter: function () {
var self = this;
var options = this.options;
var text_val = this.composer.$input.val();
var cursor_position = this._get_selection_positions().start;
var left_string = text_val.substring(0, cursor_position);
function validate_keyword (delimiter) {
var search_str = text_val.substring(left_string.lastIndexOf(delimiter) - 1, cursor_position);
var pattern = "(^"+delimiter+"|(^\\s"+delimiter+"))";
var regex_start = new RegExp(pattern, "g");
search_str = search_str.replace(/^\s\s*|^[\n\r]/g, '');
if (regex_start.test(search_str) && search_str.length > options.min_length) {
search_str = search_str.replace(pattern, '');
return search_str.indexOf(' ') < 0 && !/[\r\n]/.test(search_str) ? search_str.replace(delimiter, '') : false;
}
return false;
}
this.active_listener = undefined;
for (var i=0; i<this.listeners.length; i++) {
var listener = this.listeners[i];
this.mention_word = validate_keyword(listener.delimiter);
if (this.mention_word !== false) {
this.active_listener = listener;
break;
}
}
if (this.active_listener) {
// start a timeout to fetch data with the current 'mention word'. The timer avoid to start
// an RPC for each pushed key when the user is still typing.
// The 'typing_speed' option should approach the time for a human to type a letter.
clearTimeout(this.mention_fetch_timer);
this.mention_fetch_timer = setTimeout(function () {
$.when(listener.fetch_callback(self.mention_word, self.active_listener)).then(function (suggestions) {
self.set('mention_suggestions', suggestions);
});
}, this.options.typing_speed);
} else {
this.set('mention_suggestions', []); // close the dropdown
}
},
/**
* Checks if a listener's selection should be updated after DELETE or BACKSPACE keypress
*/
check_remove: function () {
var self = this;
var to_remove = [];
var selection = this._get_selection_positions();
var deleted_binf = selection.start;
var deleted_bsup = selection.end;
_.each(this.listeners, function (listener) {
var mention_selection = listener.selection;
var matches = self._get_match(self.composer.$input.val(), listener);
for (var i=0; i<matches.length; i++) {
var m = matches[i];
var m1 = m.index;
var m2 = m.index + m[0].length;
if (deleted_binf <= m2 && m1 < deleted_bsup) {
to_remove.push(mention_selection[i]);
}
}
if (to_remove.length) {
self._update_listener_selection(listener, _.difference(mention_selection, to_remove));
}
});
},
/**
* Replaces mentions appearing in the string 's' by html links with proper redirection
*/
generate_links: function (s) {
var self = this;
var mention_link = "<a href='#' class='%s' data-oe-id='%s' data-oe-model='%s'>%s%s</a>";
_.each(this.listeners, function (listener) {
var selection = listener.selection;
if (selection.length) {
var matches = self._get_match(s, listener);
var substrings = [];
var start_index = 0;
for (var i=0; i<matches.length; i++) {
var match = matches[i];
var end_index = match.index + match[0].length;
var match_name = match[0].substring(1);
var processed_text = _.str.sprintf(mention_link, listener.redirect_classname, selection[i].id, listener.model, listener.delimiter, match_name);
var subtext = s.substring(start_index, end_index).replace(match[0], processed_text);
substrings.push(subtext);
start_index = end_index;
}
substrings.push(s.substring(start_index, s.length));
s = substrings.join('');
}
});
return s;
},
reset_selections: function () {
var self = this;
_.each(this.listeners, function (listener) {
self._update_listener_selection(listener, []);
});
},
// Private functions
/**
* Returns the matches (as RexExp.exec does) for the mention in the input text
* @param {String} input_text: the text to search matches
* @param {Object} listener: the listener for which we want to find a match
* @returns {Object[]} matches in the same format as RexExp.exec()
*/
_get_match: function (input_text, listener) {
// create the regex of all mention's names
var names = _.pluck(listener.selection, 'name');
var escaped_names = _.map(names, function (str) {
return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
});
var regex_str = escaped_names.join('|');
// extract matches
var result = [];
if (regex_str.length) {
var myRegexp = new RegExp(regex_str, 'g');
var match = myRegexp.exec(input_text);
while (match !== null) {
result.push(match);
match = myRegexp.exec(input_text);
}
}
return result;
},
_render_suggestions: function () {
if (_.flatten(this.get('mention_suggestions')).length) {
this.$el.html(QWeb.render(this.active_listener.suggestions_template, {
suggestions: this.get('mention_suggestions'),
}));
this.$el
.addClass('open')
.find('.o_mention_proposition').first().addClass('active');
this.open = true;
} else {
this.$el.removeClass('open');
this.$el.empty();
this.open = false;
}
},
_update_listener_selection: function (listener, selection) {
listener.selection = selection;
if (listener.notify_selection_changed) {
this.trigger('selection_changed', listener);
}
},
// Cursor position and selection utils
_get_selection_positions: function () {
var el = this.composer.$input.get(0);
return el ? {start: el.selectionStart, end: el.selectionEnd} : {start: 0, end: 0};
},
_set_cursor_position: function (pos) {
this.composer.$input.each(function (index, elem) {
if (elem.setSelectionRange){
elem.setSelectionRange(pos, pos);
}
else if (elem.createTextRange){
elem.createTextRange().collapse(true).moveEnd('character', pos).moveStart('character', pos).select();
}
});
},
});
var Composer = Widget.extend({
template: "mail.ChatComposer",
@@ -22,8 +317,6 @@ var Composer = Widget.extend({
"click .o_composer_button_send": "send_message",
"click .o_composer_button_add_attachment": "on_click_add_attachment",
"click .o_attachment_delete": "on_attachment_delete",
"click .o_mention_proposition": "on_click_mention_item",
"mouseover .o_mention_proposition": "on_hover_mention_proposition",
},
init: function (parent, options) {
@@ -33,9 +326,6 @@ var Composer = Widget.extend({
input_baseline: 18,
input_max_height: 150,
input_min_height: 28,
mention_delimiter: '@',
mention_min_length: 0,
mention_typing_speed: 200,
mention_fetch_limit: 8,
get_channel_info: function () {},
});
@@ -48,10 +338,26 @@ var Composer = Widget.extend({
this.set('attachment_ids', []);
// Mention
this.mention_manager = new MentionManager(this);
this.mention_manager.register({
delimiter: '@',
fetch_callback: this.mention_fetch_partners.bind(this),
model: 'res.partner',
notify_selection_changed: true,
redirect_classname: 'o_mail_redirect',
sort_selection: true,
suggestions_template: 'mail.ChatComposer.MentionPartnerSuggestions',
});
this.mention_manager.register({
delimiter: '#',
fetch_callback: this.mention_fetch_channels.bind(this),
model: 'mail.channel',
redirect_classname: 'o_channel_redirect',
suggestions_template: 'mail.ChatComposer.MentionChannelSuggestions'
});
this.mention_manager.on('selection_changed', this, this.mention_render_selection);
this.PartnerModel = new Model('res.partner');
this.mention_dropdown_open = false;
this.set('mention_partners', []); // proposition of not-mention partner matching the mention_word
this.set('mention_selected_partners', []); // contains the mention partners sorted as they appear in the input text
this.ChannelModel = new Model('mail.channel');
},
start: function () {
@@ -60,7 +366,6 @@ var Composer = Widget.extend({
this.$attachment_button = this.$(".o_composer_button_add_attachment");
this.$attachments_list = this.$('.o_composer_attachments_list');
this.$mention_partner_tags = this.$('.o_composer_mentioned_partners');
this.$mention_dropdown = this.$('.o_composer_mention_dropdown');
this.$input = this.$('.o_composer_input');
this.resize_input();
@@ -86,8 +391,8 @@ var Composer = Widget.extend({
});
// Mention
this.on('change:mention_partners', this, this.render_mention_partners);
this.on('change:mention_selected_partners', this, this.render_mention_selected_partners);
this.mention_manager.prependTo(this.$('.o_composer'));
return this._super();
},
@@ -96,9 +401,9 @@ var Composer = Widget.extend({
// behavior for the chatter composer
var value = this.$input.val().replace(/\n|\r/g, '<br/>');
return $.when({
content: this.mention_preprocess_message(value),
content: this.mention_manager.generate_links(value),
attachment_ids: _.pluck(this.get('attachment_ids'), 'id'),
partner_ids: _.pluck(this.get('mention_selected_partners'), 'id'),
partner_ids: _.pluck(this.mention_manager.get_listener_selection('@'), 'id'),
});
},
@@ -114,7 +419,7 @@ var Composer = Widget.extend({
// Empty input, selected partners and attachments
self.$input.val('');
self.resize_input();
self.set('mention_selected_partners', []);
self.mention_manager.reset_selections();
self.set('attachment_ids', []);
self.$input.focus();
@@ -157,18 +462,18 @@ var Composer = Widget.extend({
// UP, DOWN: prevent moving cursor if navigation in mention propositions
case $.ui.keyCode.UP:
case $.ui.keyCode.DOWN:
if (this.mention_dropdown_open) {
if (this.mention_manager.is_open()) {
event.preventDefault();
}
break;
// BACKSPACE, DELETE: check if need to remove a mention
case $.ui.keyCode.BACKSPACE:
case $.ui.keyCode.DELETE:
this.mention_check_remove();
this.mention_manager.check_remove();
break;
// ENTER: submit the message only if the dropdown mention proposition is not displayed
case $.ui.keyCode.ENTER:
if (this.mention_dropdown_open) {
if (this.mention_manager.is_open()) {
event.preventDefault();
} else if (!this.prevent_send(event)) {
event.preventDefault();
@@ -195,16 +500,11 @@ var Composer = Widget.extend({
case $.ui.keyCode.ENTER:
case $.ui.keyCode.UP:
case $.ui.keyCode.DOWN:
this.mention_proposition_navigation(event.which);
this.mention_manager.proposition_navigation(event.which);
break;
// Otherwise, check if a mention is typed
default:
this.mention_word = this.mention_detect_delimiter();
if (this.mention_word !== false) {
this.mention_word_changed();
} else {
this.set('mention_partners', []); // close the dropdown
}
this.mention_manager.detect_delimiter();
this.resize_input();
}
},
@@ -294,226 +594,35 @@ var Composer = Widget.extend({
},
// Mention
on_click_mention_item: function (event) {
event.preventDefault();
var text_input = this.$input.val();
var partner_id = $(event.currentTarget).data('partner-id');
var selected_partner = _.filter(_.flatten(this.get('mention_partners')), function (p) {
return p.id === partner_id;
})[0];
// add the mention partner to the list
var mention_selected_partners = this.get('mention_selected_partners');
if (mention_selected_partners.length) { // there are already mention partners
// get mention matches (ordered by index in the text)
var matches = this.mention_get_match(text_input);
var index = this.mention_get_index(matches, this.get_selection_positions().start);
mention_selected_partners.splice(index, 0, selected_partner);
mention_selected_partners = _.clone(mention_selected_partners);
} else { // this is the first mentionned partner
mention_selected_partners = mention_selected_partners.concat([selected_partner]);
}
this.set('mention_selected_partners', mention_selected_partners);
// update input text, and reset dropdown
var cursor_position = this.get_selection_positions().start;
var text_left = text_input.substring(0, cursor_position-(this.mention_word.length+1));
var text_right = text_input.substring(cursor_position, text_input.length);
var text_input_new = text_left + this.options.mention_delimiter + selected_partner.name + ' ' + text_right;
this.$input.val(text_input_new);
this.set_cursor_position(text_left.length+selected_partner.name.length+2);
this.set('mention_partners', []);
},
on_hover_mention_proposition: function (event) {
var $elem = $(event.currentTarget);
this.$('.o_mention_proposition').removeClass('active');
$elem.addClass('active');
},
mention_proposition_navigation: function (keycode) {
var $active = this.$('.o_mention_proposition.active');
if (keycode === $.ui.keyCode.ENTER) { // selecting proposition
$active.click();
} else { // navigation in propositions
var $to;
if (keycode === $.ui.keyCode.DOWN) {
$to = $active.nextAll('.o_mention_proposition').first();
} else {
$to = $active.prevAll('.o_mention_proposition').first();
}
if ($to.length) {
$active.removeClass('active');
$to.addClass('active');
}
}
},
/**
* Return the text attached to the mention delimiter
* @returns {String|false}: the text right after the delimiter or false
*/
mention_detect_delimiter: function () {
var options = this.options;
var delimiter = options.mention_delimiter;
var text_val = this.$input.val();
var cursor_position = this.get_selection_positions().start;
var left_string = text_val.substring(0, cursor_position);
var search_str = text_val.substring(left_string.lastIndexOf(delimiter) - 1, cursor_position);
return validate_keyword(search_str);
function validate_keyword (search_str) {
var pattern = "(^"+delimiter+"|(^\\s"+delimiter+"))";
var regex_start = new RegExp(pattern, "g");
search_str = search_str.replace(/^\s\s*|^[\n\r]/g, '');
if (regex_start.test(search_str) && search_str.length > options.mention_min_length) {
search_str = search_str.replace(pattern, '');
return search_str.indexOf(' ') < 0 && !/[\r\n]/.test(search_str) ? search_str.replace(delimiter, '') : false;
}
return false;
}
},
mention_word_changed: function () {
var self = this;
// start a timeout to fetch partner with the current 'mention word'. The timer avoid to start
// an RPC for each pushed key when the user is typing the partner name.
// The 'mention_typing_speed' option should approach the time for a human to type a letter.
clearTimeout(this.mention_fetch_timer);
this.mention_fetch_timer = setTimeout(function () {
self.mention_fetch_partner(self.mention_word);
}, this.options.mention_typing_speed);
},
mention_fetch_partner: function (search) {
var self = this;
mention_fetch_channels: function (search) {
var kwargs = {
channel: this.get_channel_info(),
exclude: _.pluck(this.get('mention_selected_partners'), 'id'),
limit: this.options.mention_fetch_limit,
search: search,
};
this.PartnerModel.call('get_mention_suggestions', kwargs).then(function (suggestions) {
self.set('mention_partners', suggestions);
});
return this.ChannelModel
.call('get_mention_suggestions', kwargs)
.then(function (suggestions) {
return _.partition(suggestions, function (suggestion) {
return _.contains(['public', 'groups'], suggestion.public);
});
});
},
mention_check_remove: function () {
var mention_selected_partners = this.get('mention_selected_partners');
var partners_to_remove = [];
var selection = this.get_selection_positions();
var deleted_binf = selection.start;
var deleted_bsup = selection.end;
var matches = this.mention_get_match(this.$input.val());
for (var i=0; i<matches.length; i++) {
var m = matches[i];
var m1 = m.index;
var m2 = m.index + m[0].length;
if (deleted_binf <= m2 && m1 < deleted_bsup) {
partners_to_remove.push(mention_selected_partners[i]);
}
}
this.set('mention_selected_partners', _.difference(mention_selected_partners, partners_to_remove));
mention_fetch_partners: function (search, listener) {
var kwargs = {
channel: this.get_channel_info(),
exclude: listener ? _.pluck(listener.selection, 'id') : [],
limit: this.options.mention_fetch_limit,
search: search,
};
return this.PartnerModel.call('get_mention_suggestions', kwargs);
},
mention_preprocess_message: function (message) {
var partners = this.get('mention_selected_partners');
if (partners.length) {
var matches = this.mention_get_match(message);
var substrings = [];
var start_index = 0;
for (var i=0; i<matches.length; i++) {
var match = matches[i];
var end_index = match.index + match[0].length;
var partner_name = match[0].substring(1);
var processed_text = _.str.sprintf("<a href='#' class='o_mail_redirect' data-oe-model='res.partner' data-oe-id='%s'>@%s</a>", partners[i].id, partner_name);
var subtext = message.substring(start_index, end_index).replace(match[0], processed_text);
substrings.push(subtext);
start_index = end_index;
}
substrings.push(message.substring(start_index, message.length));
return substrings.join('');
}
return message;
},
render_mention_partners: function () {
if (_.flatten(this.get('mention_partners')).length) {
this.$mention_dropdown.html(QWeb.render('mail.ChatComposer.MentionMenu', {
suggestions: this.get('mention_partners'),
}));
this.$mention_dropdown
.addClass('open')
.find('.o_mention_proposition').first().addClass('active');
this.mention_dropdown_open = true;
} else {
this.$mention_dropdown.removeClass('open');
this.$mention_dropdown.empty();
this.mention_dropdown_open = false;
}
},
render_mention_selected_partners: function () {
mention_render_selection: function (listener) {
this.$mention_partner_tags.html(QWeb.render('mail.ChatComposer.MentionTags', {
partners: this.get('mention_selected_partners'),
partners: listener.selection,
}));
},
/**
* Return the matches (as RexExp.exec does) for the partner mention in the input text
* @param {String} input_text: the text to search matches
* @returns {Object[]} matches in the same format as RexExp.exec()
*/
mention_get_match: function (input_text) {
var self = this;
// create the regex of all mention partner name
var partner_names = _.pluck(this.get('mention_selected_partners'), 'name');
var escaped_partner_names = _.map(partner_names, function (str) {
return "("+_.str.escapeRegExp(self.options.mention_delimiter+str)+")";
});
var regex_str = escaped_partner_names.join('|');
// extract matches
var result = [];
if(regex_str.length){
var myRegexp = new RegExp(regex_str, 'g');
var match = myRegexp.exec(input_text);
while (match !== null) {
result.push(match);
match = myRegexp.exec(input_text);
}
}
return result;
},
mention_get_index: function (matches, cursor_position) {
for (var i=0; i<matches.length; i++) {
if (cursor_position <= matches[i].index) {
return i;
}
}
return i;
},
// Others
get_selection_positions: function () {
var el = this.$input.get(0);
return el ? {start: el.selectionStart, end: el.selectionEnd} : {start: 0, end: 0};
},
set_cursor_position: function (pos) {
this.$input.each(function (index, elem) {
if (elem.setSelectionRange){
elem.setSelectionRange(pos, pos);
}
else if (elem.createTextRange){
elem.createTextRange().collapse(true).moveEnd('character', pos).moveStart('character', pos).select();
}
});
},
is_empty: function () {
return !this.$input.val().trim() && !this.$('.o_attachments').children().length;
},
+3 -14
View File
@@ -2,7 +2,6 @@ odoo.define('mail.systray', function (require) {
"use strict";
var core = require('web.core');
var Model = require('web.Model');
var SystrayMenu = require('web.SystrayMenu');
var web_client = require('web.web_client');
var Widget = require('web.Widget');
@@ -20,16 +19,6 @@ var NotificationTopButton = Widget.extend({
events: {
"click": "on_click",
},
willStart: function () {
var self = this;
var ir_model = new Model("ir.model.data");
var def1 = ir_model.call("xmlid_to_res_id", ["mail.mail_channel_menu_root_chat"]);
var def2 = ir_model.call("xmlid_to_res_id", ["mail.mail_channel_action_client_chat"]);
return $.when(def1, def2, this._super()).then(function (menu_id, action_id) {
self.discuss_menu_id = menu_id;
self.client_action_id = action_id;
});
},
start: function () {
chat_manager.bus.on("update_needaction", this, this.update_counter);
this.update_counter(chat_manager.get_needaction_counter());
@@ -39,10 +28,10 @@ var NotificationTopButton = Widget.extend({
this.$('.o_notification_counter').html(counter);
},
on_click: function (event) {
var self = this;
event.preventDefault();
this.do_action(this.client_action_id, {clear_breadcrumbs: true}).then(function () {
core.bus.trigger('change_menu_section', self.discuss_menu_id);
var discuss_ids = chat_manager.get_discuss_ids();
this.do_action(discuss_ids.action_id, {clear_breadcrumbs: true}).then(function () {
core.bus.trigger('change_menu_section', discuss_ids.menu_id);
});
},
});
+3 -2
View File
@@ -83,7 +83,7 @@ var Thread = Widget.extend({
on_channel_redirect: function (event) {
event.preventDefault();
var channel_id = $(event.target).data('channel-id');
var channel_id = $(event.target).data('oe-id');
this.trigger('redirect_to_channel', channel_id);
},
@@ -132,7 +132,8 @@ var Thread = Widget.extend({
remove_message_and_render: function (message_id, messages, options) {
var self = this;
this.$('.o_thread_message[data-message-id=' + message_id + ']').fadeOut({
done: function () { self.render(messages, options); }
done: function () { self.render(messages, options); },
duration: 200,
});
},
+22 -12
View File
@@ -15,9 +15,10 @@
.o-flex-flow(row, nowrap);
.o_mail_chat_sidebar {
.o-flex(0, 0, 200px);
.o-flex(0, 0, 220px);
overflow: auto;
padding: 8px 0;
font-weight: 300;
@media (max-width: @screen-xs-max) {
flex: none;
@@ -25,34 +26,40 @@
}
.o_mail_sidebar_title {
.o-webclient-padding(@top: 5px, @bottom: 5px);
.o-flex-display();
padding: 5px 8px 5px @odoo-horizontal-padding;
align-items: center;
justify-content: space-between;
h4 {
text-transform: uppercase;
}
.o_add {
padding: 8px;
padding: 6px;
}
}
.o_mail_chat_channel_item {
width: 100%;
.o-webclient-padding(@top: 5px, @bottom: 5px);
padding-left: 1.5*@odoo-horizontal-padding; // larger padding than titles
padding: 5px 8px 5px 1.5*@odoo-horizontal-padding; // larger padding-left than titles
> .badge {
margin: 0;
}
.o_mail_partner_unpin {
padding: 2px 8px;
padding: 2px 6px;
}
}
.o_mail_add_channel {
width: 100%;
.o-webclient-padding(@top: 5px, @bottom: 5px);
padding-left: 1.5*@odoo-horizontal-padding; // larger padding than titles
padding: 5px 8px 5px 1.5*@odoo-horizontal-padding; // larger padding-left than titles
position: relative;
> input {
padding: 2px 5px;
padding-left: 12px;
}
> span {
position: absolute;
top: 7px;
left: 27px;
}
}
}
@@ -101,8 +108,8 @@
@sidebar-icon-opacity: 0.6;
.o_mail_chat {
.o_mail_chat_sidebar {
background-color: @gray;
color: @gray-lighter;
background-color: darken(@gray, 8%);
color: lighten(@gray, 50%);
.o_mail_chat_channel_item {
overflow: hidden;
@@ -110,13 +117,13 @@
cursor: pointer;
&:hover {
background-color: darken(@gray, 8%);;
background-color: darken(@gray, 12%);;
.o_mail_partner_unpin {
opacity: @sidebar-icon-opacity;
}
}
&.o_active {
background-color: @gray-dark;
background-color: darken(@gray, 15%);
}
.o_mail_partner_unpin {
opacity: 0;
@@ -150,6 +157,9 @@
> input {
color: black;
}
> span {
color: gray;
}
}
.o_mail_sidebar_title {
+1 -8
View File
@@ -73,14 +73,12 @@
.o_mail_mailto, .o_mail_mailto:hover {
.text-muted();
}
.o_channel_redirect {
color: gray;
}
}
.o_thread_message_star, .o_thread_message_needaction {
cursor: pointer;
opacity: 0;
padding: 4px;
&.fa-star {
opacity: @mail-thread-icon-opacity;
color: gold;
@@ -97,12 +95,7 @@
}
}
border-bottom: 1px solid transparent;
border-top: 1px solid transparent;
&.o_thread_selected_message {
border-bottom: 1px solid @gray-lighter;
border-top: 1px solid @gray-lighter;
.o_thread_message_side_date {
opacity: @mail-thread-side-date-opacity;
}
+8 -2
View File
@@ -23,6 +23,7 @@
<t t-set="channel_title">Channels</t>
</t>
<t t-call="mail.chat.SidebarItems">
<t t-set="display_hash">true</t>
<t t-set="input_placeholder">Add a channel</t>
</t>
@@ -41,6 +42,7 @@
</t>
<t t-call="mail.chat.SidebarItems">
<t t-set="input_placeholder">Add a channel</t>
<t t-set="display_hash">true</t>
</t>
</div>
</t>
@@ -76,6 +78,7 @@
</div>
</t>
<div t-if="!disable_add_channel" class="o_mail_add_channel" t-attf-data-type="#{channel_type}">
<span t-if="display_hash">#</span>
<input type="text" t-attf-placeholder="#{input_placeholder}"/>
</div>
</t>
@@ -84,8 +87,11 @@
<t t-name="mail.chat.ControlButtons">
<div>
<button type="button" class="btn btn-primary btn-sm o_mail_toggle_channels visible-xs-inline" t-att-title='_t("Toggle Channels")'>Channels</button>
<button type="button" class="btn btn-default btn-sm o_mail_chat_button_invite" t-att-title='_t("Invite people")'>Invite</button>
<button type="button" class="btn btn-default btn-sm o_mail_chat_button_detach hidden-xs" t-att-title='_t("Detach the conversation")'>Detach</button>
<button type="button" class="btn btn-primary btn-sm o_mail_chat_button_invite" t-att-title='_t("Invite people")'>Invite</button>
<button type="button" class="btn btn-primary btn-sm o_mail_chat_button_detach hidden-xs" t-att-title='_t("Detach the conversation")'>Detach</button>
<button type="button" class="btn btn-primary btn-sm o_mail_chat_button_mark_read" t-att-title='_t("Mark all as read")'>Mark all read</button>
<button type="button" class="btn btn-default btn-sm o_mail_chat_button_unsubscribe" t-att-title='_t("Unsubscribe from channel")'>Unsubscribe</button>
<button type="button" class="btn btn-default btn-sm o_mail_chat_button_settings" t-att-title='_t("Open channel settings")'>Settings</button>
</div>
</t>
+17 -3
View File
@@ -3,7 +3,6 @@
<div t-name="mail.ChatComposer" class="o_chat_composer">
<div class="o_composer">
<div class="dropup o_composer_mention_dropdown"/>
<textarea class="o_composer_input" placeholder="Write something"/>
<div class="btn-group o_composer_buttons">
<button class="btn btn-sm btn-primary o_composer_button_send" type="button">Send</button>
@@ -34,14 +33,14 @@
</t>
</t>
<t t-name="mail.ChatComposer.MentionMenu">
<t t-name="mail.ChatComposer.MentionPartnerSuggestions">
<ul class="dropdown-menu" aria-labelledby="dropdownMailMentionMenu">
<t t-set="show_divider" t-value="false"/>
<t t-foreach="suggestions" t-as="group">
<t t-foreach="group" t-as="partner">
<li t-if="show_divider &amp;&amp; (partner_index === 0)" class="divider"/>
<t t-set="show_divider" t-value="true"/>
<li class="o_mention_proposition" t-att-data-partner-id="partner.id">
<li class="o_mention_proposition" t-att-data-id="partner.id">
<a href="#">
<t t-esc="partner.name"/> <t t-if="partner.email">(<t t-esc="partner.email"/>)</t>
</a>
@@ -51,6 +50,21 @@
</ul>
</t>
<t t-name="mail.ChatComposer.MentionChannelSuggestions">
<ul class="dropdown-menu" aria-labelledby="dropdownMailMentionMenu">
<t t-set="show_divider" t-value="false"/>
<t t-foreach="suggestions" t-as="group">
<t t-foreach="group" t-as="channel">
<li t-if="show_divider &amp;&amp; (channel_index === 0)" class="divider"/>
<t t-set="show_divider" t-value="true"/>
<li class="o_mention_proposition" t-att-data-id="channel.id">
<a href="#"><t t-esc="channel.name"/></a>
</li>
</t>
</t>
</ul>
</t>
<t t-name="mail.ChatComposer.MentionTags">
<t t-if="partners.length">Mention(s): </t>
<t t-foreach="partners" t-as="partner">
+2 -2
View File
@@ -84,7 +84,7 @@
on <a class="o_mail_redirect" href="#" t-att-data-oe-model="message.model" t-att-data-oe-id="message.res_id"><t t-esc="message.record_name"/></a>
</t>
<t t-if="message.origin_id &amp;&amp; (message.origin_id !== options.channel_id)">
(in channel <a class="o_channel_redirect" t-att-data-channel-id="message.origin_id" href="#"><t t-esc="message.origin_name"/>)</a>
(from <a class="o_channel_redirect" t-att-data-oe-id="message.origin_id" href="#"><t t-esc="message.origin_name"/>)</a>
</t>
</p>
<t t-if="message.tracking_value_ids &amp;&amp; message.tracking_value_ids.length &gt; 0">
@@ -144,7 +144,7 @@
<t t-name="mail.ChatThread.LoadMore">
<div t-if="!all_messages_loaded" class="o_thread_show_more">
<button class="btn btn-sm btn-link">-------- Show more messages --------</button>
<button class="btn btn-sm btn-link">-------- Show older messages --------</button>
</div>
</t>
+2 -2
View File
@@ -23,7 +23,7 @@ class sale_order(osv.Model):
document = self.browse(cr, uid, ids[0], context=context)
contex_signup = dict(context, signup_valid=True)
return self.pool['res.partner']._get_signup_url_for_action(
cr, uid, [document.partner_id.id], action='mail.action_mail_redirect',
cr, uid, [document.partner_id.id], action='/mail/view',
model=self._name, res_id=document.id, context=contex_signup,
)[document.partner_id.id]
@@ -52,7 +52,7 @@ class account_invoice(osv.Model):
document = self.browse(cr, uid, ids[0], context=context)
contex_signup = dict(context, signup_valid=True)
return self.pool['res.partner']._get_signup_url_for_action(
cr, uid, [document.partner_id.id], action='mail.action_mail_redirect',
cr, uid, [document.partner_id.id], action='/mail/view',
model=self._name, res_id=document.id, context=contex_signup,
)[document.partner_id.id]
+41
View File
@@ -13,6 +13,47 @@
</field>
</record>
<record id="view_order_form_portal_sale" model="ir.ui.view">
<field name="name">sale.order.form.portal_sale</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="groups_id" eval="[(4, ref('base.group_portal'))]"/>
<field name="arch" type="xml">
<data>
<xpath expr='//field[@name="order_line"]/form//field[@name="procurement_ids"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/form//field[@name="product_id"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/form//field[@name="qty_delivered_updateable"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/form//label[@for="qty_delivered"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/form//field[@name="qty_delivered"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/form//label[@for="qty_invoiced"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/form//field[@name="qty_invoiced"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/form//field[@name="price_unit"]' position="attributes">
<attribute name="attrs">{}</attribute>
</xpath>
<xpath expr='//field[@name="order_line"]/form//field[@name="tax_id"]' position="attributes">
<attribute name="attrs">{}</attribute>
</xpath>
<xpath expr='//field[@name="order_line"]/tree//field[@name="procurement_ids"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/tree//field[@name="product_id"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/tree//field[@name="qty_delivered_updateable"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/tree//field[@name="qty_delivered"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/tree//field[@name="qty_invoiced"]' position="replace"/>
<xpath expr='//field[@name="order_line"]/tree//field[@name="price_unit"]' position="attributes">
<attribute name="attrs">{}</attribute>
</xpath>
<xpath expr='//field[@name="order_line"]/tree//field[@name="tax_id"]' position="attributes">
<attribute name="attrs">{}</attribute>
</xpath>
<xpath expr='//field[@name="order_line"]/kanban//field[@name="product_id"]' position="replace">
<field name="name"/>
</xpath>
<xpath expr='//field[@name="order_line"]/kanban/templates/t/div//span/t' position="replace">
<t t-esc="record.name.value"/>
</xpath>
</data>
</field>
</record>
<!--
Override the original action to set another help field and/or
another context field, more suited for portal members
+24 -18
View File
@@ -556,29 +556,12 @@ class PurchaseOrderLine(models.Model):
def onchange_product_id(self):
result = {}
if not self.product_id:
return {}
return result
if self.product_id.uom_id.category_id.id != self.product_uom.category_id.id:
self.product_uom = self.product_id.uom_po_id
result['domain'] = {'product_uom': [('category_id', '=', self.product_id.uom_id.category_id.id)]}
seller = self.product_id._select_seller(
self.product_id,
partner_id=self.partner_id,
quantity=self.product_qty,
date=self.order_id.date_order and self.order_id.date_order[:10],
uom_id=self.product_uom)
fpos = self.order_id.fiscal_position_id
self.taxes_id = fpos.map_tax(self.product_id.supplier_taxes_id)
price_unit = self.env['account.tax']._fix_tax_included_price(seller.price, self.product_id.supplier_taxes_id, self.taxes_id) if seller else 0.0
if price_unit and seller and self.order_id.currency_id and seller.currency_id != self.order_id.currency_id:
price_unit = seller.currency_id.compute(price_unit, self.order_id.currency_id)
self.price_unit = price_unit
if seller or not self.date_planned:
self.date_planned = self._get_date_planned(seller).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
product_lang = self.product_id.with_context({
'lang': self.partner_id.lang,
'partner_id': self.partner_id.id,
@@ -587,6 +570,29 @@ class PurchaseOrderLine(models.Model):
if product_lang.description_purchase:
self.name += '\n' + product_lang.description_purchase
seller = self.product_id._select_seller(
self.product_id,
partner_id=self.partner_id,
quantity=self.product_qty,
date=self.order_id.date_order and self.order_id.date_order[:10],
uom_id=self.product_uom)
if seller or not self.date_planned:
self.date_planned = self._get_date_planned(seller).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
if not seller:
return result
fpos = self.order_id.fiscal_position_id
self.taxes_id = fpos.map_tax(self.product_id.supplier_taxes_id)
price_unit = self.env['account.tax']._fix_tax_included_price(seller.price, self.product_id.supplier_taxes_id, self.taxes_id) if seller else 0.0
if price_unit and seller and self.order_id.currency_id and seller.currency_id != self.order_id.currency_id:
price_unit = seller.currency_id.compute(price_unit, self.order_id.currency_id)
self.price_unit = price_unit
return result
class ProcurementRule(models.Model):
_inherit = 'procurement.rule'
-1
View File
@@ -15,7 +15,6 @@
</group>
<group string="Quotations &amp; Sales" id="sale">
<field name="group_sale_delivery_address" widget="radio"/>
<field name="group_display_incoterm" widget="radio"/>
<field name="group_discount_per_so_line" widget="radio"/>
<field name="module_website_quote" widget="radio"/>
<field name="module_sale_margin" widget="radio"/>
+4
View File
@@ -303,6 +303,8 @@ class SaleOrder(models.Model):
inv_data = order._prepare_invoice()
invoice = inv_obj.create(inv_data)
invoices[group_key] = invoice
elif group_key in invoices and order.name not in invoices[group_key].origin.split(', '):
invoices[group_key].write({'origin': invoices[group_key].origin + ', ' + order.name})
if line.qty_to_invoice > 0:
line.invoice_line_create(invoices[group_key].id, line.qty_to_invoice)
elif line.qty_to_invoice < 0 and final:
@@ -385,6 +387,8 @@ class SaleOrder(models.Model):
def action_confirm(self):
for order in self:
order.state = 'sale'
if self.env.context.get('send_email'):
self.force_quotation_send()
order.order_line._action_procurement_create()
if not order.project_id:
for line in order.order_line:
+1
View File
@@ -7,6 +7,7 @@ access_account_invoice_salesman,account_invoice salesman,account.model_account_i
access_account_invoice_manager,account_invoice manager,account.model_account_invoice,base.group_sale_manager,1,1,1,1
access_account_invoice_line_salesman,account_invoice.line salesman,account.model_account_invoice_line,base.group_sale_salesman,1,1,1,0
access_account_payment_term_salesman,account_payment_term salesman,account.model_account_payment_term,base.group_sale_salesman,1,0,0,0
access_account_analytic_tag_sale_salesman,account.analytic.tag.sale.salesman,analytic.model_account_analytic_tag,base.group_sale_salesman,1,0,0,0
access_account_analytic_account_salesman,account_analytic_account salesman,analytic.model_account_analytic_account,base.group_sale_salesman,1,1,1,0
access_sale_order_manager,sale.order.manager,model_sale_order,base.group_sale_manager,1,1,1,1
access_sale_order_accountant,sale.order.accountant,model_sale_order,account.group_account_user,1,1,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_account_invoice_manager account_invoice manager account.model_account_invoice base.group_sale_manager 1 1 1 1
8 access_account_invoice_line_salesman account_invoice.line salesman account.model_account_invoice_line base.group_sale_salesman 1 1 1 0
9 access_account_payment_term_salesman account_payment_term salesman account.model_account_payment_term base.group_sale_salesman 1 0 0 0
10 access_account_analytic_tag_sale_salesman account.analytic.tag.sale.salesman analytic.model_account_analytic_tag base.group_sale_salesman 1 0 0 0
11 access_account_analytic_account_salesman account_analytic_account salesman analytic.model_account_analytic_account base.group_sale_salesman 1 1 1 0
12 access_sale_order_manager sale.order.manager model_sale_order base.group_sale_manager 1 1 1 1
13 access_sale_order_accountant sale.order.accountant model_sale_order account.group_account_user 1 1 0 0
+1 -1
View File
@@ -73,7 +73,7 @@ class StockMove(models.Model):
def _prepare_procurement_from_move(self, move):
res = super(StockMove, self)._prepare_procurement_from_move(move)
if res and move.procurement_id and move.procurement_id.property_ids:
res['property_ids'] = [(6, 0, self.property_ids.ids)]
res['property_ids'] = [(6, 0, move.procurement_id.property_ids.ids)]
return res
@api.model
+3
View File
@@ -15,6 +15,9 @@
<field name="group_route_so_lines" widget="radio"/>
<field name="group_mrp_properties" widget="radio" groups="base.group_no_one"/>
</xpath>
<field name="group_sale_delivery_address" position="after">
<field name="group_display_incoterm" widget="radio"/>
</field>
</field>
</record>
</odoo>
+1 -2
View File
@@ -92,7 +92,7 @@ class SaleOrderLine(models.Model):
product_packaging = fields.Many2one('product.packaging', string='Packaging', default=False)
route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)])
product_tmpl_id = fields.Many2one('product.template', related='product_id.product_tmpl_id', string='Product Template')
product_tmpl_id = fields.Many2one('product.template', related='product_id.product_tmpl_id', string='Product Template', readonly=True)
@api.multi
@api.depends('product_id')
@@ -119,7 +119,6 @@ class SaleOrderLine(models.Model):
self.product_packaging = False
return {}
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
self.product_tmpl_id = self.product_id.product_tmpl_id
if self.product_id.type == 'product':
product = self.product_id.with_context(
lang=self.order_id.partner_id.lang,
+59 -50
View File
@@ -9,7 +9,7 @@
<t t-set="hide_mark_as_done" t-value="True"/>
<div class="o_planner_intro">
<p>
This guide will help you get started with Odoo Inventory.
This guide helps getting started with Odoo Inventory.
Once you are done, you will benefit from:
</p>
<ul>
@@ -18,7 +18,7 @@
<li>Accurate visibility on all your operations</li>
</ul>
<p>
In short, you will get a more efficient warehouse management that will lead
In short, you will get a more efficient warehouse management that leads
to inventory reduction and better efficiencies in your daily operations.
</p><p>
If you need help, do not hesitate to contact our experts
@@ -54,7 +54,7 @@
<t t-call="web_planner.page">
<t t-set="page_title">Your Vendors</t>
<p class="text-muted">
Unless you are starting a new business, you probably have a list of vendors you'd like to import.
Unless you are starting a new business, you probably have a list of vendors you would like to import.
</p>
<t t-if="not is_module_installed('account') and not is_module_installed('purchase')">
<div class="alert alert-warning">
@@ -73,7 +73,7 @@
</div>
<div class="panel-body">
<p>
If you have less than 50 vendors, we recommand you
If you have less than 50 vendors, we recommend you
to create them manually.
</p>
<ul><li>
@@ -94,7 +94,7 @@
</div>
<div class="panel-body">
<p>
We can handle the whole import process
We handle the whole import process
for you: simply send your Odoo project
manager a CSV file containing all your
data.
@@ -110,9 +110,9 @@
</div>
</div>
<div class="alert alert-info">
<span>For developers, <strong>you can use our
<span>If you are a developer <strong>you can use our
API</strong> to load data automatically through
scripts: take a look at our <a href="https://www.odoo.com/documentation/8.0/api_integration.html" target="_blank">API Documentation</a> for more
scripts: take a look at our <a href="https://www.odoo.com/documentation/9.0/api_integration.html" target="_blank">API Documentation</a> for more
information.</span>
</div>
</t>
@@ -135,6 +135,7 @@
<div class="panel-body">
<li><strong>Stockable products</strong> are subject to the full inventory management system: minimum stock rules, automatic procurement, etc.</li>
<li><strong>Consumable products</strong> are always assumed to be in sufficient quantity in your stock, therefore their available quantities are not tracked</li>
<li><strong>Service products</strong> are non-material products provided by a company or an individual</li>
</div>
</div>
</div>
@@ -150,11 +151,11 @@
<div class="panel-body">
<ul>
<li><strong>Manufacture:</strong> the product is manufactured internally or the service is supplied from internal resources</li>
<li><strong>Buy:</strong> the product is bought from a vendor, through a Purchase Order</li>
<li><strong>Buy:</strong> the product is bought from a vendor through a Purchase Order</li>
</ul>
<ul>
<li><strong>Make to Stock:</strong> your customers are supplied from available stock. If the quantities in stock are too low to fulfill the order, a Purchase Order (according the minimum stock rules) will be generated in order to get the products required</li>
<li><strong>Make to Order:</strong> the product is acquired only as demand requires (meaning each time a Sales Order is confirmed). This will not modify stock in the medium term because you restock with the exact amount that was ordered</li>
<li><strong>Make to Stock:</strong> your customers are supplied from available stock. If the quantities in stock are too low to fulfill the order, a Purchase Order is generated according the minimum stock rules in order to get the products required</li>
<li><strong>Make to Order:</strong> the product is acquired only as demand requires, each time a Sales Order is confirmed. This does not modify stock in the medium term because you restock with the exact amount that was ordered</li>
</ul>
</div>
</div>
@@ -184,10 +185,10 @@
</div>
<div id="o_concept_desc4" class="panel-collapse collapse" role="tabpanel" aria-labelledby="o_concept4">
<div class="panel-body">
<li>By default, Odoo measures products by 'units', which are a generic and can represent just about anything</li>
<li>By default, Odoo measures products by 'units', which are generic and represent just about anything</li>
<li>To use more precise units like pounds or kilograms, activate<i> Some products may be sold/purchased in different unit of measures (advanced)</i> in the <a t-att-href="prepare_backend_url('stock.action_stock_config_settings')">Inventory Settings</a></li>
<li>Your can review and edit the pre-defined units via the <a t-attf-href="/web#view_type=list&amp;model=product.uom&amp;menu_id=#{uom_menu_id}">Units of Measures</a> configuration menu</li>
<li>Of course, feel free to add your own; just remember that Odoo can convert units within the same category (for example, liters to gallons in the volume category)</li>
<li>You can review and edit the predefined units via the <a t-att-href="prepare_backend_url('product.product_uom_form_action')">Units of Measures</a> configuration menu</li>
<li>Of course, feel free to add your own. Please note that Odoo is able to convert units within the same category, for example, liters to gallons in the volume category</li>
</div>
</div>
</div>
@@ -220,7 +221,7 @@
</div>
<div class="panel-body">
<p>
We can handle the whole import process
We handle the whole import process
for you: simply send your Odoo project
manager a CSV file containing all your
products.
@@ -243,18 +244,18 @@
<t t-call="web_planner.page">
<t t-set="page_title">Initial Inventory</t>
<p>
It's time to make your initial Inventory. To do so:
It is time to make your initial Inventory. In order to do so:
</p>
<div>
<ol>
<li><a t-att-href="prepare_backend_url('stock.action_inventory_form')" >Create an Inventory Adjustment</a></li>
<li>When everyting is set, click on <i>Start Inventory</i></li>
<li>When everything is set, click on <i>Start Inventory</i></li>
<li>Set the <i>Real Quantity</i> for each Product and <i>Validate the Inventory</i></li>
</ol>
</div>
<p>
Now, all your products have their quantity set correctly.
Now, all your product quantities are correctly set.
</p>
<div class="alert alert-info">
<strong>Import data</strong>
@@ -269,14 +270,14 @@
<t t-call="web_planner.page">
<t t-set="page_title">Sales Flow</t>
<p class="text-muted">
In Odoo, inventory management is fully integrated with the sales and
invoicing process. That means you can automate everything from the initial
quotation, to the goods delivery and final invoice.
Odoo inventory management is fully integrated with sales and
invoicing process. Everything is automated from the initial
quotation to the delivery and the final invoice.
</p>
<div t-if="not is_module_installed('sale')" class="alert alert-warning">
<span>You need to install the Sales Management app for this flow.</span>
</div>
<p>To better understand a complete sales flow, follow these steps:</p>
<p>A classic sales flow looks like the following:</p>
<div class="row">
<div class="col-md-6 text-center">
@@ -287,11 +288,11 @@
</span>
</div>
<div class="panel-body">
<div><span class="label label-default text-center odoo_purple"><a t-att-href="prepare_backend_url('sale.action_quotations')"> Create a Quotation</a></span><div> for a customer and add some products</div></div>
<div><span class="label label-default text-center odoo_purple"><a t-att-href="prepare_backend_url('sale.action_quotations')"> Create a Quotation</a></span><div> for a customer and add products</div></div>
<span class="fa fa-angle-down fa-2x"></span>
<div><span class="label label-default text-center odoo_purple">Confirm Sale</span><div>a Sale Order and a Transfer Order have been created</div></div>
<div><span class="label label-default text-center odoo_purple">Confirm Sale</span><div>The quotation becomes a Sale Order and a Transfer Order is created</div></div>
<span class="fa fa-angle-down fa-2x"></span>
<div><span class="label label-default text-center odoo_purple">Click on <span class="fa fa-truck"/> Delivery</span><div> on the form to get the Transfer Order</div></div>
<div><span class="label label-default text-center odoo_purple">Click on <span class="fa fa-truck"/> Delivery</span><div> on the sale order form to see Transfer Order</div></div>
</div>
</div>
</div>
@@ -303,9 +304,11 @@
</span>
</div>
<div class="panel-body">
<div><span class="label label-default text-center odoo_purple">'Ready to Transfer'</span><div>shoud be displayed on the transfer if your products supply chain is properly configured. Otherwise, <strong>Check the availability</strong> manually</div></div>
<div><span class="label label-default text-center odoo_purple">'Available'</span><div>is displayed on the transfer if your products supply chain is properly configured. Otherwise, <strong>Check the availability</strong> manually</div></div>
<span class="fa fa-angle-down fa-2x"></span>
<div><span class="label label-default text-center odoo_purple">Validate the Delivery</span><div>either by processing it one line at a time by clicking on the <span class="fa fa-gears"/> button, or as a whole with the <i>Validate</i> button</div></div>
<div><span class="label label-default text-center odoo_purple">Process the products</span><div>either by manually updating the Done quantity on the product lines, or let Odoo do it automatically while validating</div></div>
<span class="fa fa-angle-down fa-2x"></span>
<div><span class="label label-default text-center odoo_purple">Validate the Delivery</span><div>with the <i>Validate</i> button</div></div>
</div>
</div>
</div>
@@ -314,23 +317,29 @@
<t t-call="web_planner.page">
<t t-set="page_title">Purchase Flow</t>
<p class="text-muted">
Like with the sales flow, Odoo inventory management is
fully integrated with the purchase app.
</p>
<div t-if="not is_module_installed('purchase')" class="alert alert-warning">
<span>You need to install the Purchases Management app for this flow.</span>
</div>
<p>Now a similar exercise but for a purchase flow.</p>
<p>A classic purchase flow looks like the following:</p>
<div class="row">
<div class="col-md-6 text-center">
<div class="panel panel-success">
<div class="panel-heading">
<span class="panel-title">
<strong>From the Purchases application</strong>
<strong>From the Purchase application</strong>
</span>
</div>
<div class="panel-body">
<div><span class="label label-default text-center odoo_purple"><a t-att-href="prepare_backend_url('purchase.purchase_rfq')">Create a RFQ</a></span><div> with the products and their desired quantities to your vendor</div></div>
<div><span class="label label-default text-center odoo_purple"><a t-att-href="prepare_backend_url('purchase.purchase_rfq')">Create a RFQ</a></span><div> from your vendor with the products and the requested quantities</div></div>
<span class="fa fa-angle-down fa-2x"></span>
<div><span class="label label-default text-center odoo_purple">Confirm Order</span><div>a Purchase Order and a Transfer Order have been created</div></div>
<div><span class="label label-default text-center odoo_purple">Confirm Order</span><div>The RFQ becomes a Purchase Order and a Transfer Order is created</div></div>
<span class="fa fa-angle-down fa-2x"></span>
<div><span class="label label-default text-center odoo_purple">Click on <span class="fa fa-truck"/> Shipment</span><div> on the purchase order form or click on <i>Receive Products</i> to see the Transfer Order</div></div>
</div>
</div>
</div>
@@ -344,16 +353,13 @@
<div class="panel-body">
<div><span class="label label-default text-center odoo_purple">Find Incoming Shipments</span><div>via the <a t-att-href="prepare_backend_url('stock.stock_picking_type_action')">Dashboard</a> when you receive the ordered products</div></div>
<span class="fa fa-angle-down fa-2x"></span>
<div><span class="label label-default text-center odoo_purple">Process the products</span><div>either manually by clicking on the <span class="fa fa-gears"/> button on the product lines, or scan them with the Odoo Barcode app</div></div>
<div><span class="label label-default text-center odoo_purple">Process the products</span><div>either by manually updating the Done quantity on the product lines, or scan them with the Odoo Barcode app, or let Odoo do it automatically while validating</div></div>
<span class="fa fa-angle-down fa-2x"></span>
<div><span class="label label-default text-center odoo_purple">Validate the Receipt Order</span><div>to mark the products as transferred to your stock location</div></div>
</div>
</div>
</div>
</div>
<p>Back to the Purchases application, find the <strong>original Purchase Order</strong> <a t-att-href="prepare_backend_url('purchase.purchase_form_action')">in the list</a> and print the invoice.</p>
</t>
</t>
<t t-call="web_planner.category">
@@ -368,7 +374,9 @@
space and may loose value).
</p>
<p>
In Odoo, <strong>Reordering Rules</strong> can be used to replenish your products: Odoo will automatically propose a procurement to buy new products if you are running out of stock.
In Odoo, <strong>Reordering Rules</strong> are used to replenish your products.
Odoo will automatically propose a procurement to buy new products if you are
running out of stock.
</p>
<p>
To create them, click on <strong><span class="fa fa-refresh"/> Reordering</strong> on <a t-att-href="prepare_backend_url('product.product_template_action_product')">a stockable Product</a> and simply enter a minimum and maximum quantity.
@@ -379,14 +387,14 @@
<t t-set="page_title">Resolve Procurement Exceptions</t>
<t t-set="page_menutitle">Procurement Exceptions</t>
<p class="text-muted">
If you configured automatic procurement, Odoo will automatically generate
If you configured automatic procurement, Odoo automatically generates
Procurements Orders. You usually don't need to worry about them, but
sometimes, the system can remain blocked without generating a
sometimes the system can remain blocked without generating a
corresponding document, usually due to a configuration problem.
</p>
<p class="text-muted">
It is therefore a good idea to check and try to resolve those procurement
exceptions, accessible from the Schedulers menu (you need the Stock
exceptions. These are accessible from the Schedulers menu (you need the Stock
Manager role to see it).
</p>
<h4>Here are some usual problems and their solutions:</h4>
@@ -399,40 +407,41 @@
</t>
</t>
<t t-call="web_planner.category">
<t t-set="menu_categorytitle">Advanced'</t>
<t t-set="menu_categorytitle">Advanced</t>
<t t-set="menu_categoryclasses" t-value="'fa-bolt'"/>
<t t-call="web_planner.page">
<t t-set="page_title">Traceability</t>
<p>
Odoo can run advanced traceability by using Product Lots and Serial
Odoo is able to run advanced traceability by using Product Lots and Serial
Numbers, usually identified by bar codes stuck on the products.
</p>
<p>
Lots can be encoded on incoming shipments, internal transfers and
outgoing deliveries according to the settings in the picking type.
For every product, you can define if it does not do any lot tracking at all,
if it tracks by lot, or by unique serial number.
The tracking can be configured on every product: not any tracing at
all, tracking by lot, or tracking by unique serial number.
</p>
<p>
In case of unique serial numbers, each serial number will correspond
In case of unique serial numbers, each serial number corresponds
to exactly one piece. In case of lots, you need to supply the quantity
for each lot when you move that product.
</p>
<h4>To use Lot Tracking:</h4>
<h4>How to use Lot Tracking:</h4>
<ul>
<li><strong>Activate <i>Track lots or serial numbers</i></strong> in your <a t-att-href="prepare_backend_url('stock.action_stock_config_settings')">Inventory Settings</a>.</li>
<li>Set the products you want to track with lots or serial numbers by setting the Tracking field on the product form</li>
<li><strong>When you process an incoming shipment, internal transfer or delivery</strong>, assign a lot number or different lot numbers or serial numbers to a product by clicking on the <span class="fa fa-list"/> icon</li>
</ul>
<p>To check the trajectory of a lot, you can find it back in <strong><i>Inventory Control > Serial Numbers</i></strong>
and then Traceability or you can filter the Quantitative Valuation of a product with a certain lot. </p>
<p>To check the trajectory of a lot, find it back in <strong><i>Inventory Control > Serial Numbers / lots</i></strong>.
Choose a Lo t in the list and click on <i>Traceability</i>i>. You may also
filter the Quantitative Valuation of a product with a certain lot.</p>
</t>
<t t-call="web_planner.page">
<t t-set="page_title">Routes Management</t>
<p>
Odoo can manage <strong>advanced push/pull routes configuration</strong>, for example:
Odoo handles <strong>advanced push/pull routes configuration</strong>, for example:
</p>
<ul>
<li>Manage product manufacturing chains</li>
@@ -440,7 +449,7 @@
<li>Define routes within your warehouse according to business needs, such as Quality Control, After Sales Services or Supplier Returns</li>
<li>Help rental management, by generating automated return moves for rented products</li>
</ul>
<p>This is quite complex to set up, so <strong>contact your Project Manager</strong> to address this</p>
<p>This is quite complex to set up, so <strong>contact your Project Manager</strong> to address this.</p>
</t>
</t>
<t t-call="web_planner.page">
@@ -452,8 +461,8 @@
<div class="text-center">
<h1>Congratulations!</h1>
<h4>We hope this guide helped you implement Odoo Inventory.</h4>
<p>Don't hesitate to send us an email to describe your experience or to suggest improvements!</p>
<p>Once it's fully working, give us some feedback: we love to hear from our customer. It would be great if you can send a photo of your warehouse to <a href="mailto:feedback@mail.odoo.com?subject=Inventory%20Planner" target="_blank">feedback@mail.odoo.com</a></p>
<p>Do not hesitate to send us an email to describe your experience or to suggest improvements!</p>
<p>Once it's fully working, give us some feedback: we love to hear from our customer. It would be great if you could send us a photo of your warehouse to <a href="mailto:feedback@mail.odoo.com?subject=Inventory%20Planner" target="_blank">feedback@mail.odoo.com</a></p>
<p><strong>Enjoy your Inventory management with Odoo!</strong></p>
<p>- The Odoo Team</p>
<img src="/web_planner/static/src/img/odoo_logo.png"/>
+1 -1
View File
@@ -167,7 +167,7 @@ class stock_config_settings(osv.osv_memory):
def onchange_adv_location(self, cr, uid, ids, group_stock_adv_location, context=None):
if group_stock_adv_location:
return {'value': {'group_stock_multiple_locations': True}}
return {'value': {'group_stock_multiple_locations': 1}}
return {}
def _default_company(self, cr, uid, context=None):
+1 -1
View File
@@ -64,7 +64,7 @@
<label for="group_stock_adv_location"/>
<div>
<field name="group_stock_adv_location" widget="radio"/>
<field name="group_stock_adv_location" widget="radio" on_change="onchange_adv_location(group_stock_adv_location)"/>
<div attrs="{'invisible': [('group_stock_adv_location', '=', 0)]}">
<label for="propagation_minimum_delta"/>
<field name="propagation_minimum_delta" class="oe_inline"/>
+2 -2
View File
@@ -23,8 +23,8 @@ access_stock_location_sale_manager,stock.location sale manager,model_stock_locat
access_stock_location_stock_manager,stock.location stock manager,model_stock_location,stock.group_stock_manager,1,0,0,0
access_stock_lines_date_user,report.stock.lines.date user,model_report_stock_lines_date,stock.group_stock_user,1,0,0,0
access_stock_lines_date_manager,report.stock.lines.date manager,model_report_stock_lines_date,stock.group_stock_manager,1,1,1,1
access_product_product_stock_user,product_product_stock_user,product.model_product_product,stock.group_stock_user,1,1,0,0
access_product_template_stock_user,product.template stock user,product.model_product_template,stock.group_stock_user,1,1,0,0
access_product_product_stock_user,product_product_stock_user,product.model_product_product,stock.group_stock_user,1,1,1,0
access_product_template_stock_user,product.template stock user,product.model_product_template,stock.group_stock_user,1,1,1,0
access_product_uom_categ_stock_manager,product.uom.categ stock_manager,product.model_product_uom_categ,stock.group_stock_manager,1,1,1,1
access_product_uom_stock_manager,product.uom stock_manager,product.model_product_uom,stock.group_stock_manager,1,1,1,1
access_product_category_stock_manager,product.category stock_manager,product.model_product_category,stock.group_stock_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
23 access_stock_location_stock_manager stock.location stock manager model_stock_location stock.group_stock_manager 1 0 0 0
24 access_stock_lines_date_user report.stock.lines.date user model_report_stock_lines_date stock.group_stock_user 1 0 0 0
25 access_stock_lines_date_manager report.stock.lines.date manager model_report_stock_lines_date stock.group_stock_manager 1 1 1 1
26 access_product_product_stock_user product_product_stock_user product.model_product_product stock.group_stock_user 1 1 0 1 0
27 access_product_template_stock_user product.template stock user product.model_product_template stock.group_stock_user 1 1 0 1 0
28 access_product_uom_categ_stock_manager product.uom.categ stock_manager product.model_product_uom_categ stock.group_stock_manager 1 1 1 1
29 access_product_uom_stock_manager product.uom stock_manager product.model_product_uom stock.group_stock_manager 1 1 1 1
30 access_product_category_stock_manager product.category stock_manager product.model_product_category stock.group_stock_manager 1 1 1 1
+1 -1
View File
@@ -2548,7 +2548,7 @@ class stock_move(osv.osv):
dest_package_id=quant_dest_package_id, context=context)
if redo_false_quants:
move_rec = self.pool['stock.move'].browse(cr, uid, move, context=context)
false_quants_move = [(x, x.qty) for x in move_rec.reserved_quant_ids if not x.lot_id]
false_quants_move = [x for x in move_rec.reserved_quant_ids if not x.lot_id]
def action_done(self, cr, uid, ids, context=None):
""" Process completely the moves given as ids and if all moves are done, it will finish the picking.
+3 -3
View File
@@ -208,7 +208,7 @@
<tree string="Stock Moves">
<field name="name"/>
<field name="product_id"/>
<field name="qty" on_change="onchange_quantity(product_id, product_qty, product_uom)"/>
<field name="qty"/>
<field name="location_id" groups="stock.group_locations"/>
</tree>
</field>
@@ -2042,8 +2042,8 @@
</record>
<!-- Procurements are located in Warehouse menu hierarchy, MRP users should come to Stock application to use it. -->
<menuitem id="menu_stock_sched" name="Schedulers" parent="stock.menu_stock_root" sequence="50" groups="base.group_no_one"/>
<menuitem action="action_procurement_compute" id="menu_procurement_compute" parent="menu_stock_sched" groups="base.group_no_one" sequence="2"/>
<menuitem id="menu_stock_sched" name="Schedulers" parent="stock.menu_stock_root" groups="group_stock_manager" sequence="50"/>
<menuitem action="action_procurement_compute" id="menu_procurement_compute" parent="menu_stock_sched" sequence="2"/>
<menuitem action="procurement.action_compute_schedulers" id="menu_stock_proc_schedulers" parent="menu_stock_sched" sequence="1" groups="stock.group_stock_manager"/>
<menuitem id="menu_stock_procurement" name="Automatic Procurements" parent="stock.menu_stock_config_settings" sequence="5"/>
<menuitem id="stock.menu_warehouse_report" name="Reports" sequence="99" parent="stock.menu_stock_root" groups="group_stock_manager"/>
+1 -1
View File
@@ -12,6 +12,6 @@ class PlannerInventory(models.Model):
return planner
def _prepare_planner_inventory_data(self):
# TDE note: void method kept (stable version), probably to remove in master
return {
'uom_menu_id': self.env.ref('stock.menu_stock_uom_form_action').id
}
+1 -1
View File
@@ -723,7 +723,7 @@ var ActionManager = Widget.extend({
return this.ir_actions_common({
widget: function () {
return new ClientWidget(self, action);
return new ClientWidget(self, action, options);
},
action: action,
klass: 'oe_act_client',
@@ -1068,6 +1068,10 @@ ListView.List = Class.extend( /** @lends instance.web.ListView.List# */{
e.stopPropagation();
var $row = $(e.target).closest('tr');
$(self).trigger('deleted', [[self.row_id($row)]]);
// IE Edge go crazy when we use confirm dialog and remove the focused element
if(document.hasFocus && !document.hasFocus()) {
$('<input />').appendTo('body').focus().remove();
}
})
.delegate('td.oe_list_field_cell button', 'click', function (e) {
e.stopPropagation();
-200
View File
@@ -791,202 +791,6 @@
display: inline-block;
}
/* }}} */
/* ---- Default values ---- {{{ */
.tooltip {
position: absolute;
z-index: 1070;
display: block;
visibility: visible;
font-size: 12px;
line-height: 1.4;
opacity: 0;
filter: alpha(opacity=0);
}
.tooltip.in {
opacity: 0.9;
filter: alpha(opacity=90);
}
.tooltip.bottom {
margin-top: 3px;
padding: 5px 0;
}
.tooltip.bottom .tooltip-arrow {
top: 0;
left: 50%;
margin-left: -5px;
border-width: 0 5px 5px;
border-bottom-color: #000;
}
.tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}
.tooltip-inner {
max-width: 200px;
padding: 3px 8px;
color: #fff;
text-align: center;
text-decoration: none;
background-color: #000;
border-radius: 4px;
}
.fade:not(.modal-backdrop) {
opacity: 0;
-webkit-transition: opacity 0.15s linear;
-o-transition: opacity 0.15s linear;
transition: opacity 0.15s linear;
}
.fade.in:not(.modal-backdrop) {
opacity: 1;
}
.pull-right {
float: right;
}
.center-block {
display: block;
margin-left: auto;
margin-right: auto;
}
.pull-left {
float: left;
}
.img-responsive {
display: block;
width: 100% \9;
max-width: 100%;
height: auto;
}
.img-rounded {
border-radius: 6px;
}
.img-thumbnail {
padding: 4px;
line-height: 1.42857;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 4px;
-webkit-transition: all 0.2s ease-in-out;
-o-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
display: inline-block;
width: 100% \9;
max-width: 100%;
height: auto;
}
.img-circle {
border-radius: 50%;
}
.img-responsive {
text-align: center;
}
img.shadow {
-moz-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
-webkit-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
}
.fa-1x {
font-size: 1em;
}
.fa-2x {
font-size: 2em;
}
.fa-3x {
font-size: 3em;
}
.fa-4x {
font-size: 4em;
}
.fa-5x {
font-size: 5em;
}
.fa-6x {
font-size: 6em;
}
.fa-7x {
font-size: 7em;
}
.fa-8x {
font-size: 8em;
}
.fa-9x {
font-size: 9em;
}
.fa-10x {
font-size: 10em;
}
.text-primary {
color: #428bca;
}
.text-success {
color: #3c763d;
}
.text-info {
color: #31708f;
}
.text-warning {
color: #8a6d3b;
}
.text-danger {
color: #a94442;
}
.bg-primary {
color: #fff;
background-color: #428bca;
}
.bg-success {
background-color: #dff0d8;
}
.bg-info {
background-color: #d9edf7;
}
.bg-warning {
background-color: #fcf8e3;
}
.bg-danger {
background-color: #f2dede;
}
.hidden {
display: none;
}
/* }}} */
/* ---- COLOR-PICKER ---- {{{ */
.colorpicker {
@@ -1086,10 +890,6 @@ html[data-translatable] .oe_field_translate {
cursor: pointer;
}
.img-responsive {
text-align: center;
}
.css_editable_display {
display: block !important;
}
@@ -683,132 +683,6 @@ $attachment-border-color: #848490
/* }}} */
/* ---- Default values ---- {{{ */
.tooltip
position: absolute
z-index: 1070
display: block
visibility: visible
font-size: 12px
line-height: 1.4
opacity: 0
filter: alpha(opacity=0)
&.in
opacity: .9
filter: alpha(opacity=90)
&.bottom
margin-top: 3px
padding: 5px 0
.tooltip-arrow
top: 0
left: 50%
margin-left: -5px
border-width: 0 5px 5px
border-bottom-color: #000
.tooltip-arrow
position: absolute
width: 0
height: 0
border-color: transparent
border-style: solid
.tooltip-inner
max-width: 200px
padding: 3px 8px
color: #fff
text-align: center
text-decoration: none
background-color: #000
border-radius: 4px
.fade:not(.modal-backdrop)
opacity: 0
-webkit-transition: opacity .15s linear
-o-transition: opacity .15s linear
transition: opacity .15s linear
.fade.in:not(.modal-backdrop)
opacity: 1
.pull-right
float: right
.center-block
display: block
margin-left: auto
margin-right: auto
.pull-left
float: left
.img-responsive
display: block
width: 100% \9
max-width: 100%
height: auto
.img-rounded
border-radius: 6px
.img-thumbnail
padding: 4px
line-height: 1.42857143
background-color: #fff
border: 1px solid #ddd
border-radius: 4px
-webkit-transition: all .2s ease-in-out
-o-transition: all .2s ease-in-out
transition: all .2s ease-in-out
display: inline-block
width: 100% \9
max-width: 100%
height: auto
.img-circle
border-radius: 50%
.img-responsive
text-align: center
img.shadow
-moz-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2)
-webkit-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2)
box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2)
.fa-1x
font-size: 1em
.fa-2x
font-size: 2em
.fa-3x
font-size: 3em
.fa-4x
font-size: 4em
.fa-5x
font-size: 5em
.fa-6x
font-size: 6em
.fa-7x
font-size: 7em
.fa-8x
font-size: 8em
.fa-9x
font-size: 9em
.fa-10x
font-size: 10em
.text-primary
color: #428bca
.text-success
color: #3c763d
.text-info
color: #31708f
.text-warning
color: #8a6d3b
.text-danger
color: #a94442
.bg-primary
color: #fff
background-color: #428bca
.bg-success
background-color: #dff0d8
.bg-info
background-color: #d9edf7
.bg-warning
background-color: #fcf8e3
.bg-danger
background-color: #f2dede
.hidden
display: none
/* }}} */
/* ---- COLOR-PICKER ---- {{{ */
.colorpicker
@@ -897,9 +771,6 @@ html[data-translatable] .oe_field_translate
.o_editable .fa
cursor: pointer
.img-responsive
text-align: center
.css_editable_display
display: block !important
+201
View File
@@ -62,3 +62,204 @@ body {
.note-color-palette .colorpicker {
display: none;
}
/* ---- Default bootstrap values ---- {{{ */
.tooltip {
position: absolute;
z-index: 1070;
display: block;
visibility: visible;
font-size: 12px;
line-height: 1.4;
opacity: 0;
filter: alpha(opacity=0);
}
.tooltip.in {
opacity: 0.9;
filter: alpha(opacity=90);
}
.tooltip.bottom {
margin-top: 3px;
padding: 5px 0;
}
.tooltip.bottom .tooltip-arrow {
top: 0;
left: 50%;
margin-left: -5px;
border-width: 0 5px 5px;
border-bottom-color: #000;
}
.tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}
.tooltip-inner {
max-width: 200px;
padding: 3px 8px;
color: #fff;
text-align: center;
text-decoration: none;
background-color: #000;
border-radius: 4px;
}
.fade:not(.modal-backdrop) {
opacity: 0;
-webkit-transition: opacity 0.15s linear;
-o-transition: opacity 0.15s linear;
transition: opacity 0.15s linear;
}
.fade.in:not(.modal-backdrop) {
opacity: 1;
}
.pull-right {
float: right;
}
.center-block {
display: block;
margin-left: auto;
margin-right: auto;
}
.pull-left {
float: left;
}
.img-responsive {
display: block;
width: 100% \9;
max-width: 100%;
height: auto;
}
.img-rounded {
border-radius: 6px;
}
.img-thumbnail {
padding: 4px;
line-height: 1.42857;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 4px;
-webkit-transition: all 0.2s ease-in-out;
-o-transition: all 0.2s ease-in-out;
transition: all 0.2s ease-in-out;
display: inline-block;
width: 100% \9;
max-width: 100%;
height: auto;
}
.img-circle {
border-radius: 50%;
}
.img-responsive {
text-align: center;
}
img.shadow {
-moz-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
-webkit-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.2);
}
.fa-1x {
font-size: 1em;
}
.fa-2x {
font-size: 2em;
}
.fa-3x {
font-size: 3em;
}
.fa-4x {
font-size: 4em;
}
.fa-5x {
font-size: 5em;
}
.fa-6x {
font-size: 6em;
}
.fa-7x {
font-size: 7em;
}
.fa-8x {
font-size: 8em;
}
.fa-9x {
font-size: 9em;
}
.fa-10x {
font-size: 10em;
}
.text-primary {
color: #428bca;
}
.text-success {
color: #3c763d;
}
.text-info {
color: #31708f;
}
.text-warning {
color: #8a6d3b;
}
.text-danger {
color: #a94442;
}
.bg-primary {
color: #fff;
background-color: #428bca;
}
.bg-success {
background-color: #dff0d8;
}
.bg-info {
background-color: #d9edf7;
}
.bg-warning {
background-color: #fcf8e3;
}
.bg-danger {
background-color: #f2dede;
}
.hidden {
display: none;
}
.img-responsive {
text-align: center;
}
/* }}} */
+2 -2
View File
@@ -93,6 +93,7 @@ editor.Class = Widget.extend({
return _t('This document is not saved!');
}
};
this.$('button[data-action="save"]').prop('disabled', true);
return this._super();
},
display_placeholder: function () {
@@ -119,8 +120,7 @@ editor.Class = Widget.extend({
});
},
rte_changed: function () {
// todo: use !rte.history.getEditableHasUndo().length (but phantomjs unknow error)
this.$('button[data-action=save]').prop('disabled', !$('.o_dirty').length);
this.$('button[data-action=save]').prop('disabled', !rte.history.getEditableHasUndo().length);
},
save: function () {
return this.rte.save().then(function () {
+43 -34
View File
@@ -51,10 +51,6 @@ var History = function History ($editable) {
$editable.trigger("content_changed");
if (!oSnap.bookmark || oSnap.event === "blur") {
return;
}
try {
var r = oSnap.editable.innerHTML === "" ? range.create(oSnap.editable, 0) : range.createFromBookmark(oSnap.editable, oSnap.bookmark);
r.select();
@@ -105,7 +101,7 @@ var History = function History ($editable) {
pos--;
}
this.applySnap(aUndo[Math.max(--pos,0)]);
while (pos && (aUndo[pos].event === "blur" || aUndo[pos].event === "activate")) {
while (pos && (aUndo[pos].event === "blur" || (aUndo[pos+1].editable === aUndo[pos].editable && aUndo[pos+1].contents === aUndo[pos].contents))) {
this.applySnap(aUndo[--pos]);
}
};
@@ -132,8 +128,8 @@ var History = function History ($editable) {
this.redo = function () {
if (!aUndo[pos+1]) { return; }
this.applySnap(aUndo[++pos]);
while (aUndo[pos+1] && (aUndo[pos].event === "blur" || aUndo[pos].event === "activate" || aUndo[pos].event === "undo")) {
this.applySnap(aUndo[Math.max(++pos,aUndo.length-1)]);
while (aUndo[pos+1] && aUndo[pos].event === "active") {
this.applySnap(aUndo[pos++]);
}
};
this.hasRedo = function () {
@@ -300,9 +296,12 @@ var RTE = Widget.extend({
$.fn.carousel = this.edit_bootstrap_carousel;
$(document).on('keydown', this, this.onKeydown);
$(document).on('mousedown activate', this, this.onMousedown);
$(document).on('mouseup', this, this.onMouseup);
this._onKeydown = _.bind(this.onKeydown, this);
$(document).on('keydown', this, this._onKeydown);
this._onMousedown = _.bind(this.onMousedown, this);
$(document).on('mousedown activate', this, this._onMousedown);
this._onMouseup = _.bind(this.onMouseup, this);
$(document).on('mouseup', this, this._onMouseup);
$('.o_not_editable').attr("contentEditable", false);
@@ -329,7 +328,8 @@ var RTE = Widget.extend({
}
});
$('#wrapwrap, .o_editable').on('click', '*', this, this.onClick);
this._onClick = _.bind(this.onClick, this);
$('#wrapwrap, .o_editable').on('click', '*', this, this._onClick);
$('body').addClass("editor_enable");
@@ -420,14 +420,10 @@ var RTE = Widget.extend({
});
},
saveElement: function ($el, context) {
// remove multi edition
if ($el.data('oe-model')) {
var key = $el.data('oe-model')+":"+$el.data('oe-id')+":"+$el.data('oe-field')+":"+$el.data('oe-type')+":"+$el.data('oe-expression');
if (this.__saved[key]) return true;
this.__saved[key] = true;
}
// escape text nodes for xml saving
/**
* Get HTML cloned element with text nodes escaped for XML storage
*/
getEscapedElement: function($el) {
var escaped_el = $el.clone();
var to_escape = escaped_el.find('*').addBack();
to_escape = to_escape.not(to_escape.filter('object,iframe,script,style,[data-oe-model][data-oe-model!="ir.ui.view"]').find('*').addBack());
@@ -436,7 +432,17 @@ var RTE = Widget.extend({
this.nodeValue = $('<div />').text(this.nodeValue).html();
}
});
var markup = escaped_el.prop('outerHTML');
return escaped_el;
},
saveElement: function ($el, context) {
// remove multi edition
if ($el.data('oe-model')) {
var key = $el.data('oe-model')+":"+$el.data('oe-id')+":"+$el.data('oe-field')+":"+$el.data('oe-type')+":"+$el.data('oe-expression');
if (this.__saved[key]) return true;
this.__saved[key] = true;
}
var markup = this.getEscapedElement($el).prop('outerHTML');
return ajax.jsonRpc('/web/dataset/call', 'call', {
model: 'ir.ui.view',
@@ -458,12 +464,12 @@ var RTE = Widget.extend({
$.fn.carousel = this.init_bootstrap_carousel;
$(document).off('keydown', this.onKeydown);
$(document).off('mousedown applySnap', this.onMousedown);
$(document).off('mouseup', this.onMouseup);
$(document).off('keydown', this._onKeydown);
$(document).off('mousedown applySnap', this._onMousedown);
$(document).off('mouseup', this._onMouseup);
$('.o_not_editable').removeAttr("contentEditable");
$(document).off('content_changed').removeClass('o_is_inline_editable').removeData('rte');
$('#wrapwrap, .o_editable').off('click', this.onClick);
$('#wrapwrap, .o_editable').off('click', this._onClick);
$(document).tooltip('destroy');
$('body').removeClass("editor_enable");
this.trigger('rte:stop');
@@ -506,10 +512,8 @@ var RTE = Widget.extend({
return;
}
var rte = $editable.data('rte') || event.data;
if (rte && rte.$last && (!$editable.size() || rte.$last[0] != $editable[0])) {
var $destroy = rte.$last;
if (this && this.$last && (!$editable.size() || this.$last[0] != $editable[0])) {
var $destroy = this.$last;
history.splitNext();
setTimeout(function () {
@@ -517,15 +521,15 @@ var RTE = Widget.extend({
$destroy.destroy().removeData('note-id').removeAttr('data-note-id');
$('#note-popover-'+id+', #note-handle-'+id+', #note-dialog-'+id+'').remove();
},150); // setTimeout to remove flickering when change to editable zone (re-create an editor)
rte.$last = null;
this.$last = null;
}
if ($editable.size() && (!rte.$last || rte.$last[0] != $editable[0]) &&
if ($editable.size() && (!this.$last || this.$last[0] != $editable[0]) &&
($target.closest('[contenteditable]').attr('contenteditable') || "").toLowerCase() !== 'false') {
$editable.summernote(rte.config($editable));
$editable.summernote(this.config($editable));
$editable.data('NoteHistory', history);
rte.$last = $editable;
this.$last = $editable;
// firefox & IE fix
try {
@@ -544,9 +548,14 @@ var RTE = Widget.extend({
if (dom.isImg($target[0])) {
$target.trigger('mousedown'); // for activate selection on picture
}
this.onEnableEditableArea($editable);
}
},
onEnableEditableArea: function ($editable) {
},
onMouseup: function (event) {
var $target = $(event.target);
var $editable = $target.closest('.o_editable');
@@ -555,9 +564,9 @@ var RTE = Widget.extend({
return;
}
var rte = event.data;
var self = this;
setTimeout(function () {
rte.historyRecordUndo($target, 'activate', true);
self.historyRecordUndo($target, 'activate', true);
},0);
},
@@ -258,13 +258,6 @@ eventHandler.modules.popover.update = function ($popover, oStyle, isAirMode) {
fn_popover_update.call(this, $popover, oStyle, isAirMode);
if (!isAirMode || (oStyle.range && $(oStyle.range.sc).closest('[data-oe-model]:not([data-oe-model="ir.ui.view"]):not([data-oe-type="html"])').length)) {
$imagePopover.hide();
$linkPopover.hide();
$airPopover.hide();
return;
}
if (oStyle.image) {
if (oStyle.image.parentNode.className.match(/(^|\s)media_iframe_video(\s|$)/i)) {
oStyle.image = oStyle.image.parentNode;
@@ -461,7 +461,7 @@ var many2one = Option.extend({
return;
}
if (isNaN(+name)) {
if (this.Model === "res.partner") {
if (this.Model !== "res.partner") {
domain.push(['name', 'ilike', name]);
} else {
domain.push('|', ['name', 'ilike', name], ['email', 'ilike', name]);
@@ -504,6 +504,7 @@ var many2one = Option.extend({
this.$target.attr('data-oe-many2one-id', this.ID).data('oe-many2one-id', this.ID);
this.buildingBlock.parent.rte.historyRecordUndo(this.$target);
this.$target.trigger('content_changed');
if (self.$target.data('oe-type') === "contact") {
$('[data-oe-contact-options]')
@@ -41,13 +41,14 @@ var RTE_Translate = rte.Class.extend({
var key = 'translation:'+$el.data('oe-translation-id');
if (this.__saved[key]) return true;
this.__saved[key] = true;
var translation_content = this.getEscapedElement($el).html();
return ajax.jsonRpc('/web/dataset/call', 'call', {
model: 'ir.translation',
method: 'write',
args: [
[+$el.data('oe-translation-id')],
{value: $el.html(), state: 'translated'},
{value: translation_content, state: 'translated'},
context || base.get_context()
],
});
+11 -1
View File
@@ -268,6 +268,7 @@ var ImageDialog = Widget.extend({
this.accept = this.options.accept || this.options.document ? "*/*" : "image/*";
this.domain = this.options.domain || ['|', ['mimetype', '=', false], ['mimetype', this.options.document ? 'not in' : 'in', ['image/gif', 'image/jpe', 'image/jpeg', 'image/jpg', 'image/gif', 'image/png']]];
this.parent = parent;
this.old_media = media;
this.media = media;
this.images = [];
this.page = 0;
@@ -597,7 +598,16 @@ rte.Class.include({
init: function (EditorBar) {
this._super.apply(this, arguments);
computeFonts();
}
},
onEnableEditableArea: function ($editable) {
if ($editable.data('oe-type') === "monetary") {
$editable.attr('contenteditable', false);
$editable.find('.oe_currency_value').attr('contenteditable', true);
}
if ($editable.is('[data-oe-model]') && !$editable.is('[data-oe-model="ir.ui.view"]') && !$editable.is('[data-oe-type="html"]')) {
$editable.data('layoutInfo').popover().find('.btn-group:not(.note-history)').remove();
}
},
});
/* list of font icons to load by editor. The icons are displayed in the media editor and
@@ -403,6 +403,10 @@ var PlannerDialog = Widget.extend({
ev.preventDefault();
this.$el.modal('hide');
this.$el.detach();
},
destroy: function() {
this.$el.modal('hide');
return this._super.apply(this, arguments);
}
});
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from openerp import http
from openerp.exceptions import AccessError
from openerp.http import request
@@ -8,21 +9,39 @@ class WebSettingsDashboard(http.Controller):
@http.route('/web_settings_dashboard/data', type='json', auth='user')
def web_settings_dashboard_data(self, **kw):
if not request.env.user.has_group('base.group_erp_manager'):
raise AccessError("Access Denied")
installed_apps = request.env['ir.module.module'].search_count([
('application', '=', True),
('application', '=', True),
('state', 'in', ['installed', 'to upgrade', 'to remove'])
])
active_users = request.env['res.users'].search_count([('active', '=', True), ('log_ids', '!=', False)])
pending_users = request.env['res.users'].search([('log_ids', '=', False)], order="create_date desc")
cr = request.cr
cr.execute("""
SELECT exists(SELECT 1 FROM res_users_log WHERE create_uid=u.id), count(1)
FROM res_users u
WHERE active=true
GROUP BY 1
""")
counts = dict(cr.fetchall())
cr.execute("""
SELECT id, login
FROM res_users u
WHERE active=true
AND NOT exists(SELECT 1 FROM res_users_log WHERE create_uid=u.id)
ORDER BY id desc
LIMIT 10
""")
pending_users = cr.fetchall()
return {
'apps': {
'installed_apps': installed_apps
},
'users_info': {
'active_users': active_users,
'pending_users': zip(pending_users.mapped('id'), pending_users.mapped('login')),
'active_users': counts.get(True, 0),
'pending_count': counts.get(False, 0),
'pending_users': pending_users,
'user_form_view_id': request.env['ir.model.data'].xmlid_to_res_id("base.view_users_form"),
},
}
@@ -72,6 +72,7 @@ var DashboardInvitations = Widget.extend({
'click .o_web_settings_dashboard_invitations': 'send_invitations',
'click .o_web_settings_dashboard_access_rights': 'on_access_rights_clicked',
'click .o_web_settings_dashboard_user': 'on_user_clicked',
'click .o_web_settings_dashboard_more': 'on_more',
},
init: function(parent, data){
this.data = data;
@@ -132,6 +133,20 @@ var DashboardInvitations = Widget.extend({
on_reverse_breadcrumb: function(){ return self.reload();}
});
},
on_more: function(e) {
e.preventDefault();
var action = {
type: 'ir.actions.act_window',
view_type: 'form',
view_mode: 'tree,form',
res_model: 'res.users',
domain: [['log_ids', '=', false]],
views: [[false, 'list'], [false, 'form']],
};
this.do_action(action,{
on_reverse_breadcrumb: function(){ return self.reload();}
});
},
reload:function(){
return this.parent.load(['invitations']);
},
@@ -66,6 +66,10 @@
<t t-foreach="pending_users" t-as="pending">
<a href="#"><div class="o_web_settings_dashboard_user" t-att-data-user-id="pending[0]"> <t t-esc="pending[1]"/></div></a>
</t>
<t t-if="pending_users.length &lt; widget.data.pending_count">
<br/>
<a href="#"><div class="o_web_settings_dashboard_more"><t t-esc="widget.data.pending_count - pending_users.length"/> more</div></a>
</t>
</t>
</small>
</div>
@@ -58,7 +58,8 @@ Tour.register({
onload: function () {
$("#wrap p:first").replaceWith('<p>Write one or <font style="background-color: yellow;">two paragraphs <b>describing</b></font> your product or\
<font style="color: rgb(255, 0, 0);">services</font>. To be successful your content needs to be\
useful to your <a href="/999">readers</a>.</p> <input placeholder="test translate placeholder"/>');
useful to your <a href="/999">readers</a>.</p> <input placeholder="test translate placeholder"/>\
<p>&lt;b&gt;&lt;/b&gt; is an HTML&nbsp;tag &amp; is empty</p>');
$("#wrap img").attr("title", "test translate image title");
}
},
@@ -99,6 +100,13 @@ Tour.register({
waitFor: '#wrap .o_dirty font:first:contains(translated french text)',
element: 'input:first',
},
{
title: "translate text with special char",
onload: function () {
$('#wrap input + p').find(':last').prepend('&lt;{translated}&gt;')
.closest('[data-oe-translation-id]').addClass('o_dirty').trigger('keyup');
},
},
{
title: "translate placeholder",
element: '.modal.web_editor-dialog input:first',
@@ -119,6 +127,10 @@ Tour.register({
waitNot: 'button[data-action=save]',
waitFor: '#wrap p font:first:contains(translated french text)',
},
{
title: "check: content with special char is translated",
waitFor: "#wrap input + p:contains(<{translated}><b></b> is an HTML\xa0tag & )",
},
{
title: "check: placeholder translation",
waitFor: 'input[placeholder="test french placeholder"]',
+3 -4
View File
@@ -187,6 +187,7 @@ class website_sale(http.Controller):
attrib_list = request.httprequest.args.getlist('attrib')
attrib_values = [map(int, v.split("-")) for v in attrib_list if v]
attributes_ids = set([v[0] for v in attrib_values])
attrib_set = set([v[1] for v in attrib_values])
domain = self._get_search_domain(search, category, attrib_values)
@@ -231,7 +232,8 @@ class website_sale(http.Controller):
products = product_obj.browse(cr, uid, product_ids, context=context)
attributes_obj = request.registry['product.attribute']
attributes_ids = attributes_obj.search(cr, uid, [('attribute_line_ids.product_tmpl_id', 'in', product_ids)], context=context)
if product_ids:
attributes_ids = attributes_obj.search(cr, uid, [('attribute_line_ids.product_tmpl_id', 'in', product_ids)], context=context)
attributes = attributes_obj.browse(cr, uid, attributes_ids, context=context)
from_currency = pool['res.users'].browse(cr, uid, uid, context=context).company_id.currency_id
@@ -615,9 +617,6 @@ class website_sale(http.Controller):
partner_id = order.partner_id.id
# save partner informations
if billing_info.get('country_id'):
billing_info['property_account_position_id'] = request.registry['account.fiscal.position']._get_fpos_by_region(
cr, SUPERUSER_ID, billing_info['country_id'], billing_info.get('state_id') or False, billing_info.get('zip'), billing_info.get('vat') and True or False)
if partner_id and request.website.partner_id.id != partner_id:
orm_partner.write(cr, SUPERUSER_ID, [partner_id], billing_info, context=context)
else:
+2 -1
View File
@@ -185,8 +185,9 @@ class website(orm.Model):
:returns: pricelist recordset
"""
isocountry = request.session.geoip and request.session.geoip.get('country_code') or False
user_id = self.pool['res.users'].browse(cr, uid, request.uid or uid, context=context)
pl_ids = self._get_pl(cr, uid, isocountry, show_visible,
request.website.user_id.partner_id.property_product_pricelist.id,
user_id.partner_id.property_product_pricelist.id,
request.session.get('website_sale_current_pl'),
request.website.website_pricelist_ids)
return self.pool['product.pricelist'].browse(cr, uid, pl_ids, context=context)
+2 -2
View File
@@ -121,10 +121,10 @@
<template id="products_add_to_cart" inherit_id="website_sale.products_item" active="False" customize_show="True" name="Add to Cart">
<xpath expr="//div[@class='product_price']" position="inside">
<input name="product_id" t-att-value="product.product_variant_ids[0].id" type="hidden"/>
<t t-if="len(product.product_variant_ids) == 1">
<t t-if="len(product.product_variant_ids) == 1 and not len(product.optional_product_ids) &gt;= 1">
<a class="btn btn-default btn-xs a-submit"><span class="fa fa-shopping-cart"/></a>
</t>
<t t-if="len(product.product_variant_ids) &gt; 1">
<t t-if="len(product.product_variant_ids) &gt; 1 or len(product.optional_product_ids) &gt;= 1">
<a class="btn btn-default btn-xs" t-att-href="keep('/shop/product/%s' % slug(product), page=(pager['page']['num'] if pager['page']['num']>1 else None))"><span class="fa fa-shopping-cart"/></a>
</t>
</xpath>
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from lxml.etree import XMLSyntaxError
import unittest
from openerp.tools.translate import quote, unquote, xml_translate, html_translate
@@ -41,6 +42,29 @@ class TranslationToolsTestCase(unittest.TestCase):
self.assertItemsEqual(terms,
['Form stuff', 'Blah blah blah', 'Put some more text here'])
def test_translate_xml_text(self):
""" Test xml_translate() on plain text. """
terms = []
source = "Blah blah blah"
result = xml_translate(terms.append, source)
self.assertEquals(result, source)
self.assertItemsEqual(terms, [source])
def test_translate_xml_text_entity_unescaped(self):
""" Test xml_translate() on plain text with HTML entities. """
terms = []
source = "Blah&nbsp;blah&nbsp;blah"
with self.assertRaises(XMLSyntaxError):
xml_translate(terms.append, source)
def test_translate_xml_text_entity(self):
""" Test xml_translate() on plain text with HTML escaped entities. """
terms = []
source = "Blah&amp;nbsp;blah&amp;nbsp;blah"
result = xml_translate(terms.append, source)
self.assertEquals(result, source)
self.assertItemsEqual(terms, [source])
def test_translate_xml_inline1(self):
""" Test xml_translate() with formatting elements. """
terms = []
+1 -2
View File
@@ -286,8 +286,7 @@ def html_translate(callback, value):
trans = XMLTranslator(callback, 'html')
wrapped = "<div>%s</div>" % encode(value)
root = etree.fromstring(wrapped, etree.HTMLParser(encoding='utf-8'))
# html > body > div
trans.process(root[0][0])
trans.process(root[0][0]) # html > body > div
return trans.get_done()[5:-6] # remove tags <div> and </div>