[MOV] mail, im_chat : move files and code from im_chat to mail and bus
- im_chat.session will be replaced by mail.channel - im_chat.shortode is renamed into mail.shortcode - im_chat.presence is moved to bus module - js and controller code is moved from im_chat to mail module This commit only move files, and modify manifests, bundles, ... The code will be adapt in the next commits.
@@ -20,6 +20,9 @@ class BusController(openerp.http.Controller):
|
||||
|
||||
# override to add channels
|
||||
def _poll(self, dbname, channels, last, options):
|
||||
# update the user presence
|
||||
if request.session.uid and 'im_presence' in options:
|
||||
request.env['bus.presence'].update(options.get('im_presence'))
|
||||
request.cr.close()
|
||||
request._cr = None
|
||||
return dispatch.poll(dbname, channels, last)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import bus
|
||||
import bus_presence
|
||||
import res_users
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
import random
|
||||
import time
|
||||
|
||||
from openerp import api, fields, models
|
||||
from openerp import tools
|
||||
from openerp.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
|
||||
|
||||
from openerp.addons.bus.models.bus import TIMEOUT
|
||||
|
||||
DISCONNECTION_TIMER = TIMEOUT + 5
|
||||
AWAY_TIMER = 600 # 10 minutes
|
||||
|
||||
|
||||
class ImChatPresence(models.Model):
|
||||
""" User Chat Presence
|
||||
Its status is 'online', 'away' or 'offline'. This model should be a one2one, but is not
|
||||
attached to res_users to avoid database concurrence errors. Since the 'update' method is executed
|
||||
at each poll, if the user have multiple opened tabs, concurrence errors can happend, but are 'muted-logged'.
|
||||
"""
|
||||
|
||||
_name = 'im_chat.presence'
|
||||
_description = 'User Chat Presence'
|
||||
|
||||
_sql_constraints = [('im_chat_user_status_unique', 'unique(user_id)', 'A user can only have one IM status.')]
|
||||
|
||||
user_id = fields.Many2one('res.users', 'Users', required=True, index=True, ondelete='cascade')
|
||||
last_poll = fields.Datetime('Last Poll', default=lambda self: fields.Datetime.now())
|
||||
last_presence = fields.Datetime('Last Presence', default=lambda self: fields.Datetime.now())
|
||||
status = fields.Selection([('online', 'Online'), ('away', 'Away'), ('offline', 'Offline')], 'IM Status', default='offline')
|
||||
|
||||
|
||||
@api.model
|
||||
def update(self, user_presence=True):
|
||||
""" Register the given presence of the current user, and trigger a im_status change if necessary.
|
||||
The status will not be written or sent if not necessary.
|
||||
:param user_presence : True, if the user (self._uid) is still detected using its browser.
|
||||
:type user_presence : boolean
|
||||
"""
|
||||
presence = self.search([('user_id', '=', self._uid)], limit=1)
|
||||
# set the default values
|
||||
send_notification = True
|
||||
values = {
|
||||
'last_poll': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
|
||||
'status' : presence and presence.status or 'offline'
|
||||
}
|
||||
# update the user or a create a new one
|
||||
if not presence: # create a new presence for the user
|
||||
values['status'] = 'online'
|
||||
values['user_id'] = self._uid
|
||||
self.create(values)
|
||||
else: # write the user presence if necessary
|
||||
if user_presence:
|
||||
values['last_presence'] = time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
|
||||
values['status'] = 'online'
|
||||
else:
|
||||
threshold = datetime.datetime.now() - datetime.timedelta(seconds=AWAY_TIMER)
|
||||
if datetime.datetime.strptime(presence.last_presence, DEFAULT_SERVER_DATETIME_FORMAT) < threshold:
|
||||
values['status'] = 'away'
|
||||
send_notification = presence.status != values['status']
|
||||
# write only if the last_poll is passed TIMEOUT, or if the status has changed
|
||||
delta = datetime.datetime.utcnow() - datetime.datetime.strptime(presence.last_poll, DEFAULT_SERVER_DATETIME_FORMAT)
|
||||
if delta > datetime.timedelta(seconds=TIMEOUT) or send_notification:
|
||||
# Hide transaction serialization errors, which can be ignored, the presence update is not essential
|
||||
with tools.mute_logger('openerp.sql_db'):
|
||||
presence.write(values)
|
||||
# avoid TransactionRollbackError
|
||||
self.env.cr.commit() # TODO : check if still necessary
|
||||
# notify if the status has changed
|
||||
if send_notification: # TODO : add user_id to the channel tuple to allow using user_watch in controller presence
|
||||
self.env['bus.bus'].sendone((self._cr.dbname, 'im_chat.presence'), {'id': self._uid, 'im_status': values['status']})
|
||||
# gc : disconnect the users having a too old last_poll. 1 on 100 chance to do it.
|
||||
if random.random() < 0.01:
|
||||
self.check_users_disconnection()
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def check_users_disconnection(self):
|
||||
""" Disconnect the users having a too old last_poll """
|
||||
limit_date = (datetime.datetime.utcnow() - datetime.timedelta(0, DISCONNECTION_TIMER)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
|
||||
presences = self.search([('last_poll', '<', limit_date), ('status', '!=', 'offline')])
|
||||
presences.write({'status': 'offline'})
|
||||
notifications = []
|
||||
for presence in presences:
|
||||
notifications.append([(self._cr.dbname, 'im_chat.presence'), {'id': presence.user_id.id, 'im_status': presence.status}])
|
||||
self.env['bus.bus'].sendmany(notifications)
|
||||
@@ -1,2 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_bus_bus,bus.bus public,model_bus_bus,,0,0,0,0
|
||||
access_bus_bus,bus.bus public,model_bus_bus,,0,0,0,0
|
||||
access_bus_presence,bus.presence,model_bus_presence,base.group_user,1,1,1,1
|
||||
access_bus_presence_portal,bus.presence,model_bus_presence,base.group_portal,1,1,1,1
|
||||
|
||||
|
@@ -9,12 +9,21 @@ bus.ERROR_DELAY = 10000;
|
||||
|
||||
bus.Bus = Widget.extend({
|
||||
init: function(){
|
||||
var self = this;
|
||||
this._super();
|
||||
this.options = {};
|
||||
this.activated = false;
|
||||
this.channels = [];
|
||||
this.last = 0;
|
||||
this.stop = false;
|
||||
|
||||
// bus presence
|
||||
this.set("window_focus", true);
|
||||
this.on("change:window_focus", self, function(e) {
|
||||
self.options.im_presence = self.get("window_focus");
|
||||
});
|
||||
$(window).on("focus", _.bind(this.window_focus, this));
|
||||
$(window).on("blur", _.bind(this.window_blur, this));
|
||||
},
|
||||
start_polling: function(){
|
||||
if(!this.activated){
|
||||
@@ -57,6 +66,13 @@ bus.Bus = Widget.extend({
|
||||
delete_channel: function(channel){
|
||||
this.channels = _.without(this.channels, channel);
|
||||
},
|
||||
// bus presence : window focus/unfocus
|
||||
window_focus: function() {
|
||||
this.set("window_focus", true);
|
||||
},
|
||||
window_blur: function() {
|
||||
this.set("window_focus", false);
|
||||
},
|
||||
});
|
||||
|
||||
// singleton
|
||||
|
||||
@@ -36,9 +36,11 @@ Main Features
|
||||
'views/mail_followers_views.xml',
|
||||
'views/mail_thread_views.xml',
|
||||
'views/mail_channel_views.xml',
|
||||
'views/mail_shortcode_views.xml',
|
||||
'views/res_config_views.xml',
|
||||
'data/mail_data.xml',
|
||||
'data/mail_channel_data.xml',
|
||||
'data/mail_shortcode.xml',
|
||||
'security/mail_security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/mail_alias_views.xml',
|
||||
@@ -58,6 +60,7 @@ Main Features
|
||||
'application': True,
|
||||
'qweb': [
|
||||
'static/src/xml/mail.xml',
|
||||
'static/src/xml/mail_chat_common.xml',
|
||||
'static/src/xml/mail_followers.xml',
|
||||
'static/src/xml/announcement.xml',
|
||||
],
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
# -*- coding: utf-8 -*
|
||||
import main
|
||||
import bus
|
||||
|
||||
@@ -9,9 +9,10 @@ import mail_mail
|
||||
import mail_thread
|
||||
import mail_channel
|
||||
import mail_template
|
||||
import mail_shortcode
|
||||
import res_partner
|
||||
import res_users
|
||||
import res_config
|
||||
import update
|
||||
import ir_actions
|
||||
import html2text
|
||||
import html2text
|
||||
|
||||
@@ -14,6 +14,8 @@ class ChannelPartner(models.Model):
|
||||
partner_id = fields.Many2one('res.partner', string='Recipient')
|
||||
channel_id = fields.Many2one('mail.channel', string='Channel')
|
||||
seen_message_id = fields.Many2one('mail.message', string='Last Seen')
|
||||
state = fields.Selection([('open', 'Open'), ('folded', 'Folded'), ('closed', 'Closed')], string='Status', default='open')
|
||||
seen_datetime = fields.Datetime('Last Seen Datetime')
|
||||
|
||||
|
||||
class Channel(models.Model):
|
||||
@@ -161,3 +163,139 @@ class Channel(models.Model):
|
||||
headers['X-Forge-To'] = list_to
|
||||
res['headers'] = repr(headers)
|
||||
return res
|
||||
|
||||
|
||||
# --------------------------
|
||||
# Session Methods
|
||||
# --------------------------
|
||||
@api.multi
|
||||
def session_info(self):
|
||||
""" Get the header of the current session
|
||||
:returns the values of the session
|
||||
:rtype : dict
|
||||
"""
|
||||
self.ensure_one()
|
||||
info = {
|
||||
'uuid': self.uuid,
|
||||
'users': self.session_user_info(),
|
||||
'state': 'open',
|
||||
}
|
||||
# add user session state, if available and if user is logged
|
||||
if self._uid:
|
||||
domain = [('user_id', '=', self._uid), ('session_id', 'in', self.ids)]
|
||||
session_state = self.env['im_chat.conversation_state'].search(domain, limit=1)
|
||||
if session_state:
|
||||
info['state'] = session_state.state
|
||||
return info
|
||||
|
||||
@api.multi
|
||||
def session_user_info(self):
|
||||
""" Get the user infos for all the identified user in the session
|
||||
:returns a list of user infos
|
||||
:rtype : list(dict)
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.env['res.users'].browse(self.user_ids.ids).read(['id', 'name', 'im_status'])
|
||||
|
||||
@api.model
|
||||
def session_user_image(self, uuid, user_id):
|
||||
""" Get the avatar of a user in the given session
|
||||
:param uuid : the uuid of the session
|
||||
:param user_id : the user identifier
|
||||
"""
|
||||
# get the session
|
||||
image_b64 = False
|
||||
if user_id:
|
||||
session = self.env["im_chat.session"].search([('uuid', '=', uuid)], limit=1)
|
||||
if session and session.sudo(user_id).is_in_session():
|
||||
image_b64 = self.env["res.users"].sudo().browse(user_id).image_small
|
||||
# set default image if not found
|
||||
if not image_b64:
|
||||
image_b64 = 'R0lGODlhAQABAIABAP///wAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
|
||||
return image_b64
|
||||
|
||||
@api.model
|
||||
def session_get(self, user_to):
|
||||
""" Get the canonical session between 2 users, create it if needed.
|
||||
To reuse an old session, this one must be private, and contains only 2 users.
|
||||
:param user_to : the identifier of the user
|
||||
:returns a session header, or False if the user_to was False
|
||||
:rtype : dict
|
||||
"""
|
||||
if user_to:
|
||||
session = self.search([('user_ids', 'in', user_to), ('user_ids', 'in', [self._uid])], limit=1)
|
||||
if not (session and len(session.user_ids) == 2 and session.is_private()):
|
||||
session = self.create({'user_ids': [(6, 0, (user_to, self._uid))]})
|
||||
return session.session_info()
|
||||
|
||||
# --------------------------
|
||||
# Utils Methods
|
||||
# --------------------------
|
||||
@api.multi
|
||||
def is_private(self):
|
||||
""" Return true if the session is private between identified users (no external messages).
|
||||
The only way to do that is to check if there is a message without author (from_id is False).
|
||||
"""
|
||||
self.ensure_one()
|
||||
message_ids = self.env["im_chat.message"].search([('to_id', 'in', self.ids), ('from_id', '=', None)])
|
||||
return len(message_ids) == 0
|
||||
|
||||
@api.multi
|
||||
def is_in_session(self):
|
||||
""" Return True if the current user is in the user_ids of the session. False otherwise.
|
||||
Note : the user need to be logged
|
||||
"""
|
||||
self.ensure_one()
|
||||
user_id = self._uid
|
||||
return user_id and user_id in [u.id for u in self.user_ids]
|
||||
|
||||
@api.model
|
||||
def update_state(self, uuid, state=None):
|
||||
""" Update the fold_state of the given session. In order to syncronize web browser
|
||||
tabs, the change will be broadcast to himself (the current user channel).
|
||||
Note : the user need to be logged
|
||||
:param status : the new status of the session for the current user.
|
||||
"""
|
||||
domain = [('user_id', '=', self._uid), ('session_id.uuid', '=', uuid)]
|
||||
for session_state in self.env['im_chat.conversation_state'].search(domain):
|
||||
if not state:
|
||||
state = session_state.state
|
||||
if session_state.state == 'open':
|
||||
state = 'folded'
|
||||
else:
|
||||
state = 'open'
|
||||
session_state.write({'state': state})
|
||||
self.env['bus.bus'].sendone((self._cr.dbname, 'im_chat.session', self._uid), session_state.session_id.session_info())
|
||||
|
||||
@api.model
|
||||
def get_init_notifications(self):
|
||||
""" Get unread messages and old messages received less than AWAY_TIMER
|
||||
ago and the session_info for open or folded window
|
||||
Note : the user need to be logged
|
||||
"""
|
||||
# get the message since the AWAY_TIMER
|
||||
threshold = datetime.datetime.now() - datetime.timedelta(seconds=AWAY_TIMER)
|
||||
threshold = threshold.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
|
||||
domain = [('to_id.user_ids', 'in', [self._uid]), ('create_date', '>', threshold)]
|
||||
|
||||
# get the message since the last poll of the user
|
||||
presence = self.env['im_chat.presence'].search([('user_id', '=', self._uid)], limit=1)
|
||||
if presence:
|
||||
domain.append(('create_date', '>', presence.last_poll))
|
||||
messages = self.search_read(domain, ['from_id', 'to_id', 'create_date', 'type', 'message'], order='id asc')
|
||||
|
||||
# get the session of the messages and the not-closed ones
|
||||
session_ids = [m['to_id'][0] for m in messages]
|
||||
domain = [('user_id', '=', self._uid), '|', ('state', '!=', 'closed'), ('session_id', 'in', session_ids)]
|
||||
session_states = self.env['im_chat.conversation_state'].search(domain)
|
||||
# re-open the session where a message have been recieved recently
|
||||
session_states.filtered(lambda r: r.state == 'closed').write({'state': 'folded'})
|
||||
|
||||
# create the notifications (session infos first, then messages)
|
||||
notifications = []
|
||||
for state in session_states:
|
||||
session_infos = state.session_id.session_info()
|
||||
notifications.append([(self._cr.dbname, 'im_chat.session', self._uid), session_infos])
|
||||
for message in messages:
|
||||
notifications.append([(self._cr.dbname, 'im_chat.session', self._uid), message])
|
||||
return notifications
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import cgi
|
||||
import re
|
||||
|
||||
from openerp import api, fields, models
|
||||
|
||||
|
||||
class ImChatShortcode(models.Model):
|
||||
""" Shortcode
|
||||
Canned Responses, allowing the user to defined shortcuts in its chat message.
|
||||
These shortcode are globals and are available for every user. Smiley use this mecanism.
|
||||
"""
|
||||
|
||||
_name = 'im_chat.shortcode'
|
||||
_description = 'Canned Response / Shortcode'
|
||||
|
||||
source = fields.Char('Shortcut', required=True, index=True, help="The shortcut which must be replace in the Chat Messages")
|
||||
substitution = fields.Char('Substitution', required=True, index=True, help="The excaped html code replacing the shortcut")
|
||||
description = fields.Char('Description')
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
if values.get('substitution'):
|
||||
values['substitution'] = self._sanitize_shorcode(values['substitution'])
|
||||
return super(ImChatShortcode, self).create(values)
|
||||
|
||||
@api.multi
|
||||
def write(self, values):
|
||||
if values.get('substitution'):
|
||||
values['substitution'] = self._sanitize_shorcode(values['substitution'])
|
||||
return super(ImChatShortcode, self).write(values)
|
||||
|
||||
def _sanitize_shorcode(self, substitution):
|
||||
""" Sanitize the shortcode substitution :
|
||||
- HTML substitution : only allow the img tag (smiley)
|
||||
- escape other substitutions to avoid XSS
|
||||
"""
|
||||
is_img_tag = re.match(r'''^<img\s+src=('|")([^'"]*)\1\s*/?>$''', substitution, re.M|re.I)
|
||||
if is_img_tag:
|
||||
return substitution
|
||||
return cgi.escape(substitution)
|
||||
|
||||
@api.model
|
||||
def replace_shortcode(self, message):
|
||||
for shortcode in self.search([]):
|
||||
regex = '(?:^|\s)(%s)(?:\s|$)' % re.escape(shortcode.source)
|
||||
message = re.sub(regex, " " + shortcode.substitution + " ", message)
|
||||
return message
|
||||
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 1017 B After Width: | Height: | Size: 1017 B |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -11,6 +11,11 @@
|
||||
<script type="text/javascript" src="/mail/static/src/js/mail_followers.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/many2many_tags_email.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/announcement.js"></script>
|
||||
|
||||
<link rel="stylesheet" href="/mail/static/src/css/mail_chat_common.css"/>
|
||||
<link rel="stylesheet" href="/mail/static/src/css/mail_chat_backend.css"/>
|
||||
<script type="text/javascript" src="/mail/static/src/js/mail_chat_common.js"></script>
|
||||
<script type="text/javascript" src="/mail/static/src/js/mail_chat_backend.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</data>
|
||||
|
||||
@@ -514,6 +514,24 @@ def html2plaintext(html, body_id=None, encoding='utf-8'):
|
||||
|
||||
return html
|
||||
|
||||
def html_escape_keep_url(self, message):
|
||||
""" Escape the message and transform the url into clickable link
|
||||
:param string message: the message to escape url and transform them into clickable link
|
||||
:returns the escaped message
|
||||
:rtype : string
|
||||
"""
|
||||
safe_message = ""
|
||||
first = 0
|
||||
last = 0
|
||||
for m in re.finditer('(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?', message):
|
||||
last = m.start()
|
||||
safe_message += cgi.escape(message[first:last])
|
||||
safe_message += '<a href="%s" target="_blank">%s</a>' % (cgi.escape(m.group(0)), m.group(0))
|
||||
first = m.end()
|
||||
last = m.end()
|
||||
safe_message += cgi.escape(message[last:])
|
||||
return safe_message
|
||||
|
||||
def plaintext2html(text, container_tag=False):
|
||||
""" Convert plaintext into html. Content of the text is escaped to manage
|
||||
html entities, using cgi.escape().
|
||||
@@ -587,7 +605,7 @@ def append_content_to_html(html, content, plaintext=True, preserve=False, contai
|
||||
#----------------------------------------------------------
|
||||
|
||||
# matches any email in a body of text
|
||||
email_re = re.compile(r"""([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})""", re.VERBOSE)
|
||||
email_re = re.compile(r"""([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})""", re.VERBOSE)
|
||||
|
||||
# matches a string containing only one email
|
||||
single_email_re = re.compile(r"""^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$""", re.VERBOSE)
|
||||
|
||||