Removing a fast threaded calls notification to not annoy the user
[gc-dialer] / src / session.py
1 from __future__ import with_statement
2
3 import os
4 import time
5 import datetime
6 import contextlib
7 import logging
8
9 try:
10         import cPickle
11         pickle = cPickle
12 except ImportError:
13         import pickle
14
15 from PyQt4 import QtCore
16
17 from util import qore_utils
18 from util import concurrent
19 from util import misc as misc_utils
20
21 import constants
22
23
24 _moduleLogger = logging.getLogger(__name__)
25
26
27 @contextlib.contextmanager
28 def notify_busy(log, message):
29         log.push_busy(message)
30         try:
31                 yield
32         finally:
33                 log.pop(message)
34
35
36 class _DraftContact(object):
37
38         def __init__(self, title, description, numbersWithDescriptions):
39                 self.title = title
40                 self.description = description
41                 self.numbers = numbersWithDescriptions
42                 self.selectedNumber = numbersWithDescriptions[0][0]
43
44
45 class Draft(QtCore.QObject):
46
47         sendingMessage = QtCore.pyqtSignal()
48         sentMessage = QtCore.pyqtSignal()
49         calling = QtCore.pyqtSignal()
50         called = QtCore.pyqtSignal()
51         cancelling = QtCore.pyqtSignal()
52         cancelled = QtCore.pyqtSignal()
53         error = QtCore.pyqtSignal(str)
54
55         recipientsChanged = QtCore.pyqtSignal()
56
57         def __init__(self, pool, backend, errorLog):
58                 QtCore.QObject.__init__(self)
59                 self._errorLog = errorLog
60                 self._contacts = {}
61                 self._pool = pool
62                 self._backend = backend
63
64         def send(self, text):
65                 assert 0 < len(self._contacts), "No contacts selected"
66                 numbers = [misc_utils.make_ugly(contact.selectedNumber) for contact in self._contacts.itervalues()]
67                 le = concurrent.AsyncLinearExecution(self._pool, self._send)
68                 le.start(numbers, text)
69
70         def call(self):
71                 assert len(self._contacts) == 1, "Must select 1 and only 1 contact"
72                 (contact, ) = self._contacts.itervalues()
73                 number = misc_utils.make_ugly(contact.selectedNumber)
74                 le = concurrent.AsyncLinearExecution(self._pool, self._call)
75                 le.start(number)
76
77         def cancel(self):
78                 le = concurrent.AsyncLinearExecution(self._pool, self._cancel)
79                 le.start()
80
81         def add_contact(self, contactId, title, description, numbersWithDescriptions):
82                 if contactId in self._contacts:
83                         _moduleLogger.info("Adding duplicate contact %r" % contactId)
84                         # @todo Remove this evil hack to re-popup the dialog
85                         self.recipientsChanged.emit()
86                         return
87                 contactDetails = _DraftContact(title, description, numbersWithDescriptions)
88                 self._contacts[contactId] = contactDetails
89                 self.recipientsChanged.emit()
90
91         def remove_contact(self, contactId):
92                 assert contactId in self._contacts, "Contact missing"
93                 del self._contacts[contactId]
94                 self.recipientsChanged.emit()
95
96         def get_contacts(self):
97                 return self._contacts.iterkeys()
98
99         def get_num_contacts(self):
100                 return len(self._contacts)
101
102         def get_title(self, cid):
103                 return self._contacts[cid].title
104
105         def get_description(self, cid):
106                 return self._contacts[cid].description
107
108         def get_numbers(self, cid):
109                 return self._contacts[cid].numbers
110
111         def get_selected_number(self, cid):
112                 return self._contacts[cid].selectedNumber
113
114         def set_selected_number(self, cid, number):
115                 # @note I'm lazy, this isn't firing any kind of signal since only one
116                 # controller right now and that is the viewer
117                 assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
118                 self._contacts[cid].selectedNumber = number
119
120         def clear(self):
121                 oldContacts = self._contacts
122                 self._contacts = {}
123                 if oldContacts:
124                         self.recipientsChanged.emit()
125
126         def _send(self, numbers, text):
127                 self.sendingMessage.emit()
128                 try:
129                         with notify_busy(self._errorLog, "Sending Text"):
130                                 yield (
131                                         self._backend[0].send_sms,
132                                         (numbers, text),
133                                         {},
134                                 )
135                         self.sentMessage.emit()
136                         self.clear()
137                 except Exception, e:
138                         self.error.emit(str(e))
139
140         def _call(self, number):
141                 self.calling.emit()
142                 try:
143                         with notify_busy(self._errorLog, "Calling"):
144                                 yield (
145                                         self._backend[0].call,
146                                         (number, ),
147                                         {},
148                                 )
149                         self.called.emit()
150                         self.clear()
151                 except Exception, e:
152                         self.error.emit(str(e))
153
154         def _cancel(self):
155                 self.cancelling.emit()
156                 try:
157                         with notify_busy(self._errorLog, "Cancelling"):
158                                 yield (
159                                         self._backend[0].cancel,
160                                         (),
161                                         {},
162                                 )
163                         self.cancelled.emit()
164                 except Exception, e:
165                         self.error.emit(str(e))
166
167
168 class Session(QtCore.QObject):
169
170         # @todo Somehow add support for csv contacts
171
172         stateChange = QtCore.pyqtSignal(str)
173         loggedOut = QtCore.pyqtSignal()
174         loggedIn = QtCore.pyqtSignal()
175         callbackNumberChanged = QtCore.pyqtSignal(str)
176
177         contactsUpdated = QtCore.pyqtSignal()
178         messagesUpdated = QtCore.pyqtSignal()
179         historyUpdated = QtCore.pyqtSignal()
180         dndStateChange = QtCore.pyqtSignal(bool)
181
182         error = QtCore.pyqtSignal(str)
183
184         LOGGEDOUT_STATE = "logged out"
185         LOGGINGIN_STATE = "logging in"
186         LOGGEDIN_STATE = "logged in"
187
188         _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.1.90")
189
190         _LOGGEDOUT_TIME = -1
191         _LOGGINGIN_TIME = 0
192
193         def __init__(self, errorLog, cachePath = None):
194                 QtCore.QObject.__init__(self)
195                 self._errorLog = errorLog
196                 self._pool = qore_utils.AsyncPool()
197                 self._backend = []
198                 self._loggedInTime = self._LOGGEDOUT_TIME
199                 self._loginOps = []
200                 self._cachePath = cachePath
201                 self._username = None
202                 self._draft = Draft(self._pool, self._backend, self._errorLog)
203
204                 self._contacts = {}
205                 self._contactUpdateTime = datetime.datetime(1971, 1, 1)
206                 self._messages = []
207                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
208                 self._history = []
209                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
210                 self._dnd = False
211                 self._callback = ""
212
213         @property
214         def state(self):
215                 return {
216                         self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
217                         self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
218                 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
219
220         @property
221         def draft(self):
222                 return self._draft
223
224         def login(self, username, password):
225                 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
226                 assert username != "", "No username specified"
227                 if self._cachePath is not None:
228                         cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
229                 else:
230                         cookiePath = None
231
232                 if self._username != username or not self._backend:
233                         from backends import gv_backend
234                         del self._backend[:]
235                         self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
236
237                 self._pool.start()
238                 le = concurrent.AsyncLinearExecution(self._pool, self._login)
239                 le.start(username, password)
240
241         def logout(self):
242                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
243                 self._pool.stop()
244                 self._loggedInTime = self._LOGGEDOUT_TIME
245                 self._backend[0].persist()
246                 self._save_to_cache()
247
248         def clear(self):
249                 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
250                 self._backend[0].logout()
251                 del self._backend[0]
252                 self._clear_cache()
253                 self._draft.clear()
254
255         def logout_and_clear(self):
256                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
257                 self._pool.stop()
258                 self._loggedInTime = self._LOGGEDOUT_TIME
259                 self.clear()
260
261         def update_contacts(self, force = True):
262                 if not force and self._contacts:
263                         return
264                 le = concurrent.AsyncLinearExecution(self._pool, self._update_contacts)
265                 self._perform_op_while_loggedin(le)
266
267         def get_contacts(self):
268                 return self._contacts
269
270         def get_when_contacts_updated(self):
271                 return self._contactUpdateTime
272
273         def update_messages(self, force = True):
274                 if not force and self._messages:
275                         return
276                 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages)
277                 self._perform_op_while_loggedin(le)
278
279         def get_messages(self):
280                 return self._messages
281
282         def get_when_messages_updated(self):
283                 return self._messageUpdateTime
284
285         def update_history(self, force = True):
286                 if not force and self._history:
287                         return
288                 le = concurrent.AsyncLinearExecution(self._pool, self._update_history)
289                 self._perform_op_while_loggedin(le)
290
291         def get_history(self):
292                 return self._history
293
294         def get_when_history_updated(self):
295                 return self._historyUpdateTime
296
297         def update_dnd(self):
298                 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd)
299                 self._perform_op_while_loggedin(le)
300
301         def set_dnd(self, dnd):
302                 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
303                 le.start(dnd)
304
305         def _set_dnd(self, dnd):
306                 # I'm paranoid about our state geting out of sync so we set no matter
307                 # what but act as if we have the cannonical state
308                 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
309                 oldDnd = self._dnd
310                 try:
311                         with notify_busy(self._errorLog, "Setting DND Status"):
312                                 yield (
313                                         self._backend[0].set_dnd,
314                                         (dnd, ),
315                                         {},
316                                 )
317                 except Exception, e:
318                         self.error.emit(str(e))
319                         return
320                 self._dnd = dnd
321                 if oldDnd != self._dnd:
322                         self.dndStateChange.emit(self._dnd)
323
324         def get_dnd(self):
325                 return self._dnd
326
327         def get_account_number(self):
328                 return self._backend[0].get_account_number()
329
330         def get_callback_numbers(self):
331                 # @todo Remove evilness (might call is_authed which can block)
332                 return self._backend[0].get_callback_numbers()
333
334         def get_callback_number(self):
335                 return self._callback
336
337         def set_callback_number(self, callback):
338                 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
339                 le.start(callback)
340
341         def _set_callback_number(self, callback):
342                 # I'm paranoid about our state geting out of sync so we set no matter
343                 # what but act as if we have the cannonical state
344                 assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
345                 oldCallback = self._callback
346                 try:
347                         yield (
348                                 self._backend[0].set_callback_number,
349                                 (callback, ),
350                                 {},
351                         )
352                 except Exception, e:
353                         self.error.emit(str(e))
354                         return
355                 self._callback = callback
356                 if oldCallback != self._callback:
357                         self.callbackNumberChanged.emit(self._callback)
358
359         def _login(self, username, password):
360                 with notify_busy(self._errorLog, "Logging In"):
361                         self._loggedInTime = self._LOGGINGIN_TIME
362                         self.stateChange.emit(self.LOGGINGIN_STATE)
363                         finalState = self.LOGGEDOUT_STATE
364                         isLoggedIn = False
365                         try:
366                                 if not isLoggedIn and self._backend[0].is_quick_login_possible():
367                                         isLoggedIn = yield (
368                                                 self._backend[0].is_authed,
369                                                 (),
370                                                 {},
371                                         )
372                                         if isLoggedIn:
373                                                 _moduleLogger.info("Logged in through cookies")
374                                         else:
375                                                 # Force a clearing of the cookies
376                                                 yield (
377                                                         self._backend[0].logout,
378                                                         (),
379                                                         {},
380                                                 )
381
382                                 if not isLoggedIn:
383                                         isLoggedIn = yield (
384                                                 self._backend[0].login,
385                                                 (username, password),
386                                                 {},
387                                         )
388                                         if isLoggedIn:
389                                                 _moduleLogger.info("Logged in through credentials")
390
391                                 if isLoggedIn:
392                                         self._loggedInTime = int(time.time())
393                                         oldUsername = self._username
394                                         self._username = username
395                                         finalState = self.LOGGEDIN_STATE
396                                         if oldUsername != self._username:
397                                                 needOps = not self._load()
398                                         else:
399                                                 needOps = True
400
401                                         self.loggedIn.emit()
402
403                                         if needOps:
404                                                 loginOps = self._loginOps[:]
405                                         else:
406                                                 loginOps = []
407                                         del self._loginOps[:]
408                                         for asyncOp in loginOps:
409                                                 asyncOp.start()
410                                 else:
411                                         self._loggedInTime = self._LOGGEDOUT_TIME
412                                         self.error.emit("Error logging in")
413                         except Exception, e:
414                                 self._loggedInTime = self._LOGGEDOUT_TIME
415                                 self.error.emit(str(e))
416                         finally:
417                                 self.stateChange.emit(finalState)
418                         if isLoggedIn and self._callback:
419                                 self.set_callback_number(self._callback)
420
421         def _load(self):
422                 updateContacts = len(self._contacts) != 0
423                 updateMessages = len(self._messages) != 0
424                 updateHistory = len(self._history) != 0
425                 oldDnd = self._dnd
426                 oldCallback = self._callback
427
428                 self._contacts = {}
429                 self._messages = []
430                 self._history = []
431                 self._dnd = False
432                 self._callback = ""
433
434                 loadedFromCache = self._load_from_cache()
435                 if loadedFromCache:
436                         updateContacts = True
437                         updateMessages = True
438                         updateHistory = True
439
440                 if updateContacts:
441                         self.contactsUpdated.emit()
442                 if updateMessages:
443                         self.messagesUpdated.emit()
444                 if updateHistory:
445                         self.historyUpdated.emit()
446                 if oldDnd != self._dnd:
447                         self.dndStateChange.emit(self._dnd)
448                 if oldCallback != self._callback:
449                         self.callbackNumberChanged.emit(self._callback)
450
451                 return loadedFromCache
452
453         def _load_from_cache(self):
454                 if self._cachePath is None:
455                         return False
456                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
457
458                 try:
459                         with open(cachePath, "rb") as f:
460                                 dumpedData = pickle.load(f)
461                 except (pickle.PickleError, IOError, EOFError, ValueError):
462                         _moduleLogger.exception("Pickle fun loading")
463                         return False
464                 except:
465                         _moduleLogger.exception("Weirdness loading")
466                         return False
467
468                 (
469                         version, build,
470                         contacts, contactUpdateTime,
471                         messages, messageUpdateTime,
472                         history, historyUpdateTime,
473                         dnd, callback
474                 ) = dumpedData
475
476                 if misc_utils.compare_versions(
477                         self._OLDEST_COMPATIBLE_FORMAT_VERSION,
478                         misc_utils.parse_version(version),
479                 ) <= 0:
480                         _moduleLogger.info("Loaded cache")
481                         self._contacts = contacts
482                         self._contactUpdateTime = contactUpdateTime
483                         self._messages = messages
484                         self._messageUpdateTime = messageUpdateTime
485                         self._history = history
486                         self._historyUpdateTime = historyUpdateTime
487                         self._dnd = dnd
488                         self._callback = callback
489                         return True
490                 else:
491                         _moduleLogger.debug(
492                                 "Skipping cache due to version mismatch (%s-%s)" % (
493                                         version, build
494                                 )
495                         )
496                         return False
497
498         def _save_to_cache(self):
499                 _moduleLogger.info("Saving cache")
500                 if self._cachePath is None:
501                         return
502                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
503
504                 try:
505                         dataToDump = (
506                                 constants.__version__, constants.__build__,
507                                 self._contacts, self._contactUpdateTime,
508                                 self._messages, self._messageUpdateTime,
509                                 self._history, self._historyUpdateTime,
510                                 self._dnd, self._callback
511                         )
512                         with open(cachePath, "wb") as f:
513                                 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
514                         _moduleLogger.info("Cache saved")
515                 except (pickle.PickleError, IOError):
516                         _moduleLogger.exception("While saving")
517
518         def _clear_cache(self):
519                 updateContacts = len(self._contacts) != 0
520                 updateMessages = len(self._messages) != 0
521                 updateHistory = len(self._history) != 0
522                 oldDnd = self._dnd
523                 oldCallback = self._callback
524
525                 self._contacts = {}
526                 self._contactUpdateTime = datetime.datetime(1, 1, 1)
527                 self._messages = []
528                 self._messageUpdateTime = datetime.datetime(1, 1, 1)
529                 self._history = []
530                 self._historyUpdateTime = datetime.datetime(1, 1, 1)
531                 self._dnd = False
532                 self._callback = ""
533
534                 if updateContacts:
535                         self.contactsUpdated.emit()
536                 if updateMessages:
537                         self.messagesUpdated.emit()
538                 if updateHistory:
539                         self.historyUpdated.emit()
540                 if oldDnd != self._dnd:
541                         self.dndStateChange.emit(self._dnd)
542                 if oldCallback != self._callback:
543                         self.callbackNumberChanged.emit(self._callback)
544
545                 self._save_to_cache()
546
547         def _update_contacts(self):
548                 try:
549                         with notify_busy(self._errorLog, "Updating Contacts"):
550                                 self._contacts = yield (
551                                         self._backend[0].get_contacts,
552                                         (),
553                                         {},
554                                 )
555                 except Exception, e:
556                         self.error.emit(str(e))
557                         return
558                 self._contactUpdateTime = datetime.datetime.now()
559                 self.contactsUpdated.emit()
560
561         def _update_messages(self):
562                 try:
563                         with notify_busy(self._errorLog, "Updating Messages"):
564                                 self._messages = yield (
565                                         self._backend[0].get_messages,
566                                         (),
567                                         {},
568                                 )
569                 except Exception, e:
570                         self.error.emit(str(e))
571                         return
572                 self._messageUpdateTime = datetime.datetime.now()
573                 self.messagesUpdated.emit()
574
575         def _update_history(self):
576                 try:
577                         with notify_busy(self._errorLog, "Updating History"):
578                                 self._history = yield (
579                                         self._backend[0].get_recent,
580                                         (),
581                                         {},
582                                 )
583                 except Exception, e:
584                         self.error.emit(str(e))
585                         return
586                 self._historyUpdateTime = datetime.datetime.now()
587                 self.historyUpdated.emit()
588
589         def _update_dnd(self):
590                 oldDnd = self._dnd
591                 try:
592                         self._dnd = yield (
593                                 self._backend[0].is_dnd,
594                                 (),
595                                 {},
596                         )
597                 except Exception, e:
598                         self.error.emit(str(e))
599                         return
600                 if oldDnd != self._dnd:
601                         self.dndStateChange(self._dnd)
602
603         def _perform_op_while_loggedin(self, op):
604                 if self.state == self.LOGGEDIN_STATE:
605                         op.start()
606                 else:
607                         self._push_login_op(op)
608
609         def _push_login_op(self, asyncOp):
610                 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
611                 if asyncOp in self._loginOps:
612                         _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
613                         return
614                 self._loginOps.append(asyncOp)