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