[MERGE] forward port of branch 8.0 up to 591e329
[odoo/odoo.git] / addons / im_chat / im_chat.py
1 # -*- coding: utf-8 -*-
2 import base64
3 import datetime
4 import logging
5 import time
6 import uuid
7 import random
8 import re
9 import simplejson
10 import openerp
11 import cgi
12
13 from openerp.http import request
14 from openerp.osv import osv, fields
15 from openerp.tools.misc import DEFAULT_SERVER_DATETIME_FORMAT
16 from openerp.addons.bus.bus import TIMEOUT
17
18 _logger = logging.getLogger(__name__)
19
20 DISCONNECTION_TIMER = TIMEOUT + 5
21 AWAY_TIMER = 600 # 10 minutes
22
23 #----------------------------------------------------------
24 # Models
25 #----------------------------------------------------------
26 class im_chat_conversation_state(osv.Model):
27     """ Adds a state on the m2m between user and session.  """
28     _name = 'im_chat.conversation_state'
29     _table = "im_chat_session_res_users_rel"
30
31     _columns = {
32         "state" : fields.selection([('open', 'Open'), ('folded', 'Folded'), ('closed', 'Closed')]),
33         "session_id" : fields.many2one('im_chat.session', 'Session', required=True, ondelete="cascade"),
34         "user_id" : fields.many2one('res.users', 'Users', required=True, ondelete="cascade"),
35     }
36     _defaults = {
37         "state" : 'open'
38     }
39
40 class im_chat_session(osv.Model):
41     """ Conversations."""
42     _order = 'id desc'
43     _name = 'im_chat.session'
44     _rec_name = 'uuid'
45
46     _columns = {
47         'uuid': fields.char('UUID', size=50, select=True),
48         'message_ids': fields.one2many('im_chat.message', 'to_id', 'Messages'),
49         'user_ids': fields.many2many('res.users', 'im_chat_session_res_users_rel', 'session_id', 'user_id', "Session Users"),
50         'session_res_users_rel': fields.one2many('im_chat.conversation_state', 'session_id', 'Relation Session Users'),
51     }
52     _defaults = {
53         'uuid': lambda *args: '%s' % uuid.uuid4(),
54     }
55
56     def is_in_session(self, cr, uid, uuid, user_id, context=None):
57         """ return if the given user_id is in the session """
58         sids = self.search(cr, uid, [('uuid', '=', uuid)], context=context, limit=1)
59         for session in self.browse(cr, uid, sids, context=context):
60                 return user_id and user_id in [u.id for u in session.user_ids]
61         return False
62
63     def users_infos(self, cr, uid, ids, context=None):
64         """ get the user infos for all the user in the session """
65         for session in self.pool["im_chat.session"].browse(cr, uid, ids, context=context):
66             users_infos = self.pool["res.users"].read(cr, uid, [u.id for u in session.user_ids], ['id','name', 'im_status'], context=context)
67             return users_infos
68
69     def is_private(self, cr, uid, ids, context=None):
70         for session_id in ids:
71             """ return true if the session is private between users no external messages """
72             mess_ids = self.pool["im_chat.message"].search(cr, uid, [('to_id','=',session_id),('from_id','=',None)], context=context)
73             return len(mess_ids) == 0
74
75     def session_info(self, cr, uid, ids, context=None):
76         """ get the session info/header of a given session """
77         for session in self.browse(cr, uid, ids, context=context):
78             info = {
79                 'uuid': session.uuid,
80                 'users': session.users_infos(),
81                 'state': 'open',
82             }
83             # add uid_state if available
84             if uid:
85                 domain = [('user_id','=',uid), ('session_id','=',session.id)]
86                 uid_state = self.pool['im_chat.conversation_state'].search_read(cr, uid, domain, ['state'], context=context)
87                 if uid_state:
88                     info['state'] = uid_state[0]['state']
89             return info
90
91     def session_get(self, cr, uid, user_to, context=None):
92         """ returns the canonical session between 2 users, create it if needed """
93         session_id = False
94         if user_to:
95             sids = self.search(cr, uid, [('user_ids','in', user_to),('user_ids', 'in', [uid])], context=context, limit=1)
96             for sess in self.browse(cr, uid, sids, context=context):
97                 if len(sess.user_ids) == 2 and sess.is_private():
98                     session_id = sess.id
99                     break
100             else:
101                 session_id = self.create(cr, uid, { 'user_ids': [(6,0, (user_to, uid))] }, context=context)
102         return self.session_info(cr, uid, [session_id], context=context)
103
104     def update_state(self, cr, uid, uuid, state=None, context=None):
105         """ modify the fold_state of the given session, and broadcast to himself (e.i. : to sync multiple tabs) """
106         domain = [('user_id','=',uid), ('session_id.uuid','=',uuid)]
107         ids = self.pool['im_chat.conversation_state'].search(cr, uid, domain, context=context)
108         for sr in self.pool['im_chat.conversation_state'].browse(cr, uid, ids, context=context):
109             if not state:
110                 state = sr.state
111                 if sr.state == 'open':
112                     state = 'folded'
113                 else:
114                     state = 'open'
115             self.pool['im_chat.conversation_state'].write(cr, uid, ids, {'state': state}, context=context)
116             self.pool['bus.bus'].sendone(cr, uid, (cr.dbname, 'im_chat.session', uid), sr.session_id.session_info())
117
118     def add_user(self, cr, uid, uuid, user_id, context=None):
119         """ add the given user to the given session """
120         sids = self.search(cr, uid, [('uuid', '=', uuid)], context=context, limit=1)
121         for session in self.browse(cr, uid, sids, context=context):
122             if user_id not in [u.id for u in session.user_ids]:
123                 self.write(cr, uid, [session.id], {'user_ids': [(4, user_id)]}, context=context)
124                 # notify the all the channel users and anonymous channel
125                 notifications = []
126                 for channel_user_id in session.user_ids:
127                     info = self.session_info(cr, channel_user_id.id, [session.id], context=context)
128                     notifications.append([(cr.dbname, 'im_chat.session', channel_user_id.id), info])
129                 # Anonymous are not notified when a new user is added : cannot exec session_info as uid = None
130                 info = self.session_info(cr, openerp.SUPERUSER_ID, [session.id], context=context)
131                 notifications.append([session.uuid, info])
132                 self.pool['bus.bus'].sendmany(cr, uid, notifications)
133                 # send a message to the conversation
134                 user = self.pool['res.users'].read(cr, uid, user_id, ['name'], context=context)
135                 self.pool["im_chat.message"].post(cr, uid, uid, session.uuid, "meta", user['name'] + " joined the conversation.", context=context)
136
137     def remove_user(self, cr, uid, session_id, context=None):
138         """ private implementation of removing a user from a given session (and notify the other people) """
139         session = self.browse(cr, openerp.SUPERUSER_ID, session_id, context=context)
140         # send a message to the conversation
141         user = self.pool['res.users'].read(cr, uid, uid, ['name'], context=context)
142         self.pool["im_chat.message"].post(cr, uid, uid, session.uuid, "meta", user['name'] + " left the conversation.", context=context)
143         # close his session state, and remove the user from session
144         self.update_state(cr, uid, session.uuid, 'closed', context=None)
145         self.write(cr, uid, [session.id], {"user_ids": [(3, uid)]}, context=context)
146         # notify the all the channel users and anonymous channel
147         notifications = []
148         for channel_user_id in session.user_ids:
149             info = self.session_info(cr, channel_user_id.id, [session.id], context=context)
150             notifications.append([(cr.dbname, 'im_chat.session', channel_user_id.id), info])
151         # anonymous are not notified when a new user left : cannot exec session_info as uid = None
152         info = self.session_info(cr, openerp.SUPERUSER_ID, [session.id], context=context)
153         notifications.append([session.uuid, info])
154         self.pool['bus.bus'].sendmany(cr, uid, notifications)
155
156     def quit_user(self, cr, uid, uuid, context=None):
157         """ action of leaving a given session """
158         sids = self.search(cr, uid, [('uuid', '=', uuid)], context=context, limit=1)
159         for session in self.browse(cr, openerp.SUPERUSER_ID, sids, context=context):
160             if uid and uid in [u.id for u in session.user_ids] and len(session.user_ids) > 2:
161                 self.remove_user(cr, uid, session.id, context=context)
162                 return True
163             return False
164
165     def get_image(self, cr, uid, uuid, user_id, context=None):
166         """ get the avatar of a user in the given session """
167         #default image
168         image_b64 = 'R0lGODlhAQABAIABAP///wAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='
169         # get the session
170         if user_id:
171             session_id = self.pool["im_chat.session"].search(cr, uid, [('uuid','=',uuid), ('user_ids','in', user_id)])
172             if session_id:
173                 # get the image of the user
174                 res = self.pool["res.users"].read(cr, uid, [user_id], ["image_small"])[0]
175                 if res["image_small"]:
176                     image_b64 = res["image_small"]
177         return image_b64
178
179 class im_chat_message(osv.Model):
180     """ Sessions messsages type can be 'message' or 'meta'.
181         For anonymous message, the from_id is False.
182         Messages are sent to a session not to users.
183     """
184     _name = 'im_chat.message'
185     _order = "id desc"
186     _columns = {
187         'create_date': fields.datetime('Create Date', required=True, select=True),
188         'from_id': fields.many2one('res.users', 'Author'),
189         'to_id': fields.many2one('im_chat.session', 'Session To', required=True, select=True, ondelete='cascade'),
190         'type': fields.selection([('message','Message'), ('meta','Meta')], 'Type'),
191         'message': fields.char('Message'),
192     }
193     _defaults = {
194         'type' : 'message',
195     }
196
197     def _escape_keep_url(self, message):
198         """ escape the message and transform the url into clickable link """
199         safe_message = ""
200         first = 0
201         last = 0
202         for m in re.finditer('(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?', message):
203             last = m.start()
204             safe_message += cgi.escape(message[first:last])
205             safe_message += '<a href="%s" target="_blank">%s</a>' % (cgi.escape(m.group(0)), m.group(0))
206             first = m.end()
207             last = m.end()
208         safe_message += cgi.escape(message[last:])
209         return safe_message
210
211     def init_messages(self, cr, uid, context=None):
212         """ get unread messages and old messages received less than AWAY_TIMER
213             ago and the session_info for open or folded window
214         """
215         # get the message since the AWAY_TIMER
216         threshold = datetime.datetime.now() - datetime.timedelta(seconds=AWAY_TIMER)
217         threshold = threshold.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
218         domain = [('to_id.user_ids', 'in', [uid]), ('create_date','>',threshold)]
219
220         # get the message since the last poll of the user
221         presence_ids = self.pool['im_chat.presence'].search(cr, uid, [('user_id', '=', uid)], context=context)
222         if presence_ids:
223             presence = self.pool['im_chat.presence'].browse(cr, uid, presence_ids, context=context)[0]
224             threshold = presence.last_poll
225             domain.append(('create_date','>',threshold))
226         messages = self.search_read(cr, uid, domain, ['from_id','to_id','create_date','type','message'], order='id asc', context=context)
227
228         # get the session of the messages and the not-closed ones
229         session_ids = map(lambda m: m['to_id'][0], messages)
230         domain = [('user_id','=',uid), '|', ('state','!=','closed'), ('session_id', 'in', session_ids)]
231         session_rels_ids = self.pool['im_chat.conversation_state'].search(cr, uid, domain, context=context)
232         # re-open the session where a message have been recieve recently
233         session_rels = self.pool['im_chat.conversation_state'].browse(cr, uid, session_rels_ids, context=context)
234
235         reopening_session = []
236         notifications = []
237         for sr in session_rels:
238             si = sr.session_id.session_info()
239             si['state'] = sr.state
240             if sr.state == 'closed':
241                 si['state'] = 'folded'
242                 reopening_session.append(sr.id)
243             notifications.append([(cr.dbname,'im_chat.session', uid), si])
244         for m in messages:
245             notifications.append([(cr.dbname,'im_chat.session', uid), m])
246         self.pool['im_chat.conversation_state'].write(cr, uid, reopening_session, {'state': 'folded'}, context=context)
247         return notifications
248
249     def post(self, cr, uid, from_uid, uuid, message_type, message_content, context=None):
250         """ post and broadcast a message, return the message id """
251         message_id = False
252         Session = self.pool['im_chat.session']
253         session_ids = Session.search(cr, uid, [('uuid','=',uuid)], context=context)
254         notifications = []
255         for session in Session.browse(cr, uid, session_ids, context=context):
256             # build and escape the new message
257             message_content = self._escape_keep_url(message_content)
258             message_content = self.pool['im_chat.shortcode'].replace_shortcode(cr, uid, message_content, context=context)
259             vals = {
260                 "from_id": from_uid,
261                 "to_id": session.id,
262                 "type": message_type,
263                 "message": message_content,
264             }
265             # save it
266             message_id = self.create(cr, uid, vals, context=context)
267             # broadcast it to channel (anonymous users) and users_ids
268             data = self.read(cr, uid, [message_id], ['from_id','to_id','create_date','type','message'], context=context)[0]
269             notifications.append([uuid, data])
270             for user in session.user_ids:
271                 notifications.append([(cr.dbname, 'im_chat.session', user.id), data])
272             self.pool['bus.bus'].sendmany(cr, uid, notifications)
273         return message_id
274
275     def get_messages(self, cr, uid, uuid, last_id=False, limit=20, context=None):
276         """ get messages (id desc) from given last_id in the given session """
277         Session = self.pool['im_chat.session']
278         if Session.is_in_session(cr, uid, uuid, uid, context=context):
279             domain = [("to_id.uuid", "=", uuid)]
280             if last_id:
281                 domain.append(("id", "<", last_id));
282             return self.search_read(cr, uid, domain, ['id', 'create_date','to_id','from_id', 'type', 'message'], limit=limit, context=context)
283         return False
284
285
286 class im_chat_shortcode(osv.Model):
287     """ Message shortcuts """
288     _name = "im_chat.shortcode"
289
290     _columns = {
291         'source' : fields.char('Shortcut', required=True, select=True, help="The shortcut which must be replace in the Chat Messages"),
292         'substitution' : fields.char('Substitution', required=True, select=True, help="The html code replacing the shortcut"),
293         'description' : fields.char('Description'),
294     }
295
296     def replace_shortcode(self, cr, uid, message, context=None):
297         ids = self.search(cr, uid, [], context=context)
298         for shortcode in self.browse(cr, uid, ids, context=context):
299             regex = "(?:^|\s)(%s)(?:\s|$)" % re.escape(shortcode.source)
300             message = re.sub(regex, " " + shortcode.substitution + " ", message)
301         return message
302
303
304 class im_chat_presence(osv.Model):
305     """ im_chat_presence status can be: online, away or offline.
306         This model is a one2one, but is not attached to res_users to avoid database concurrence errors
307     """
308     _name = 'im_chat.presence'
309
310     _columns = {
311         'user_id' : fields.many2one('res.users', 'Users', required=True, select=True, ondelete="cascade"),
312         'last_poll': fields.datetime('Last Poll'),
313         'last_presence': fields.datetime('Last Presence'),
314         'status' : fields.selection([('online','Online'), ('away','Away'), ('offline','Offline')], 'IM Status'),
315     }
316     _defaults = {
317         'last_poll' : fields.datetime.now,
318         'last_presence' : fields.datetime.now,
319         'status' : 'offline'
320     }
321     _sql_constraints = [('im_chat_user_status_unique','unique(user_id)', 'A user can only have one IM status.')]
322
323     def update(self, cr, uid, presence=True, context=None):
324         """ register the poll, and change its im status if necessary. It also notify the Bus if the status has changed. """
325         presence_ids = self.search(cr, uid, [('user_id', '=', uid)], context=context)
326         presences = self.browse(cr, uid, presence_ids, context=context)
327         # set the default values
328         send_notification = True
329         vals = {
330             'last_poll': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT),
331             'status' : presences and presences[0].status or 'offline'
332         }
333         # update the user or a create a new one
334         if not presences:
335             vals['status'] = 'online'
336             vals['user_id'] = uid
337             self.create(cr, uid, vals, context=context)
338         else:
339             if presence:
340                 vals['last_presence'] = time.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
341                 vals['status'] = 'online'
342             else:
343                 threshold = datetime.datetime.now() - datetime.timedelta(seconds=AWAY_TIMER)
344                 if datetime.datetime.strptime(presences[0].last_presence, DEFAULT_SERVER_DATETIME_FORMAT) < threshold:
345                     vals['status'] = 'away'
346             send_notification = presences[0].status != vals['status']
347             # write only if the last_poll is passed TIMEOUT, or if the status has changed
348             delta = datetime.datetime.now() - datetime.datetime.strptime(presences[0].last_poll, DEFAULT_SERVER_DATETIME_FORMAT)
349             if (delta > datetime.timedelta(seconds=TIMEOUT) or send_notification):
350                 self.write(cr, uid, presence_ids, vals, context=context)
351         # avoid TransactionRollbackError
352         cr.commit()
353         # notify if the status has changed
354         if send_notification:
355             self.pool['bus.bus'].sendone(cr, uid, (cr.dbname,'im_chat.presence'), {'id': uid, 'im_status': vals['status']})
356         # gc : disconnect the users having a too old last_poll. 1 on 100 chance to do it.
357         if random.random() < 0.01:
358             self.check_users_disconnection(cr, uid, context=context)
359         return True
360
361     def check_users_disconnection(self, cr, uid, context=None):
362         """ disconnect the users having a too old last_poll """
363         dt = (datetime.datetime.now() - datetime.timedelta(0, DISCONNECTION_TIMER)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
364         presence_ids = self.search(cr, uid, [('last_poll', '<', dt), ('status' , '!=', 'offline')], context=context)
365         self.write(cr, uid, presence_ids, {'status': 'offline'}, context=context)
366         presences = self.browse(cr, uid, presence_ids, context=context)
367         notifications = []
368         for presence in presences:
369             notifications.append([(cr.dbname,'im_chat.presence'), {'id': presence.user_id.id, 'im_status': presence.status}])
370         self.pool['bus.bus'].sendmany(cr, uid, notifications)
371         return True
372
373 class res_users(osv.Model):
374     _inherit = "res.users"
375
376     def _get_im_status(self, cr, uid, ids, fields, arg, context=None):
377         """ function computing the im_status field of the users """
378         r = dict((i, 'offline') for i in ids)
379         status_ids = self.pool['im_chat.presence'].search(cr, uid, [('user_id', 'in', ids)], context=context)
380         status =  self.pool['im_chat.presence'].browse(cr, uid, status_ids, context=context)
381         for s in status:
382             r[s.user_id.id] = s.status
383         return r
384
385     _columns = {
386         'im_status' : fields.function(_get_im_status, type="char", string="IM Status"),
387     }
388
389     def im_search(self, cr, uid, name, limit=20, context=None):
390         """ search users with a name and return its id, name and im_status """
391         result = [];
392         # find the employee group
393         group_employee = self.pool['ir.model.data'].get_object_reference(cr, uid, 'base', 'group_user')[1]
394
395         where_clause_base = " U.active = 't' "
396         query_params = ()
397         if name:
398             where_clause_base += " AND P.name ILIKE %s "
399             query_params = query_params + ('%'+name+'%',)
400
401         # first query to find online employee
402         cr.execute('''SELECT U.id as id, P.name as name, COALESCE(S.status, 'offline') as im_status
403                 FROM im_chat_presence S
404                     JOIN res_users U ON S.user_id = U.id
405                     JOIN res_partner P ON P.id = U.partner_id
406                 WHERE   '''+where_clause_base+'''
407                         AND U.id != %s
408                         AND EXISTS (SELECT 1 FROM res_groups_users_rel G WHERE G.gid = %s AND G.uid = U.id)
409                         AND S.status = 'online'
410                 ORDER BY P.name
411                 LIMIT %s
412         ''', query_params + (uid, group_employee, limit))
413         result = result + cr.dictfetchall()
414
415         # second query to find other online people
416         if(len(result) < limit):
417             cr.execute('''SELECT U.id as id, P.name as name, COALESCE(S.status, 'offline') as im_status
418                 FROM im_chat_presence S
419                     JOIN res_users U ON S.user_id = U.id
420                     JOIN res_partner P ON P.id = U.partner_id
421                 WHERE   '''+where_clause_base+'''
422                         AND U.id NOT IN %s
423                         AND S.status = 'online'
424                 ORDER BY P.name
425                 LIMIT %s
426             ''', query_params + (tuple([u["id"] for u in result]) + (uid,), limit-len(result)))
427             result = result + cr.dictfetchall()
428
429         # third query to find all other people
430         if(len(result) < limit):
431             cr.execute('''SELECT U.id as id, P.name as name, COALESCE(S.status, 'offline') as im_status
432                 FROM res_users U
433                     LEFT JOIN im_chat_presence S ON S.user_id = U.id
434                     LEFT JOIN res_partner P ON P.id = U.partner_id
435                 WHERE   '''+where_clause_base+'''
436                         AND U.id NOT IN %s
437                 ORDER BY P.name
438                 LIMIT %s
439             ''', query_params + (tuple([u["id"] for u in result]) + (uid,), limit-len(result)))
440             result = result + cr.dictfetchall()
441         return result
442
443 #----------------------------------------------------------
444 # Controllers
445 #----------------------------------------------------------
446 class Controller(openerp.addons.bus.bus.Controller):
447     def _poll(self, dbname, channels, last, options):
448         if request.session.uid:
449             registry, cr, uid, context = request.registry, request.cr, request.session.uid, request.context
450             registry.get('im_chat.presence').update(cr, uid, options.get('im_presence', False), context=context)
451             ## For performance issue, the real time status notification is disabled. This means a change of status are still braoadcasted
452             ## but not received by anyone. Otherwise, all listening user restart their longpolling at the same time and cause a 'ConnectionPool Full Error'
453             ## since there is not enought cursors for everyone. Now, when a user open his list of users, an RPC call is made to update his user status list.
454             ##channels.append((request.db,'im_chat.presence'))
455             # channel to receive message
456             channels.append((request.db,'im_chat.session', request.uid))
457         return super(Controller, self)._poll(dbname, channels, last, options)
458
459     @openerp.http.route('/im_chat/init', type="json", auth="none")
460     def init(self):
461         registry, cr, uid, context = request.registry, request.cr, request.session.uid, request.context
462         notifications = registry['im_chat.message'].init_messages(cr, uid, context=context)
463         return notifications
464
465     @openerp.http.route('/im_chat/post', type="json", auth="none")
466     def post(self, uuid, message_type, message_content):
467         registry, cr, uid, context = request.registry, request.cr, request.session.uid, request.context
468         # execute the post method as SUPERUSER_ID
469         message_id = registry["im_chat.message"].post(cr, openerp.SUPERUSER_ID, uid, uuid, message_type, message_content, context=context)
470         return message_id
471
472     @openerp.http.route(['/im_chat/image/<string:uuid>/<string:user_id>'], type='http', auth="none")
473     def image(self, uuid, user_id):
474         registry, cr, context, uid = request.registry, request.cr, request.context, request.session.uid
475         # get the image
476         Session = registry.get("im_chat.session")
477         image_b64 = Session.get_image(cr, openerp.SUPERUSER_ID, uuid, simplejson.loads(user_id), context)
478         # built the response
479         image_data = base64.b64decode(image_b64)
480         headers = [('Content-Type', 'image/png')]
481         headers.append(('Content-Length', len(image_data)))
482         return request.make_response(image_data, headers)
483
484     @openerp.http.route(['/im_chat/history'], type="json", auth="none")
485     def history(self, uuid, last_id=False, limit=20):
486         registry, cr, uid, context = request.registry, request.cr, request.session.uid or openerp.SUPERUSER_ID, request.context
487         return registry["im_chat.message"].get_messages(cr, uid, uuid, last_id, limit, context=context)
488
489 # vim:et: