Emitting logged out
[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                 self.loggedOut.emit()
275
276         def clear(self):
277                 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
278                 self._backend[0].logout()
279                 del self._backend[0]
280                 self._clear_cache()
281                 self._draft.clear()
282
283         def logout_and_clear(self):
284                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
285                 self._pool.stop()
286                 self._loggedInTime = self._LOGGEDOUT_TIME
287                 self.clear()
288                 self.stateChange.emit(self.LOGGEDOUT_STATE)
289                 self.loggedOut.emit()
290
291         def update_contacts(self, force = True):
292                 if not force and self._contacts:
293                         return
294                 le = concurrent.AsyncLinearExecution(self._pool, self._update_contacts)
295                 self._perform_op_while_loggedin(le)
296
297         def get_contacts(self):
298                 return self._contacts
299
300         def get_when_contacts_updated(self):
301                 return self._contactUpdateTime
302
303         def update_messages(self, force = True):
304                 if not force and self._messages:
305                         return
306                 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages)
307                 self._perform_op_while_loggedin(le)
308
309         def get_messages(self):
310                 return self._messages
311
312         def get_when_messages_updated(self):
313                 return self._messageUpdateTime
314
315         def update_history(self, force = True):
316                 if not force and self._history:
317                         return
318                 le = concurrent.AsyncLinearExecution(self._pool, self._update_history)
319                 self._perform_op_while_loggedin(le)
320
321         def get_history(self):
322                 return self._history
323
324         def get_when_history_updated(self):
325                 return self._historyUpdateTime
326
327         def update_dnd(self):
328                 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd)
329                 self._perform_op_while_loggedin(le)
330
331         def set_dnd(self, dnd):
332                 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
333                 le.start(dnd)
334
335         def _set_dnd(self, dnd):
336                 # I'm paranoid about our state geting out of sync so we set no matter
337                 # what but act as if we have the cannonical state
338                 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
339                 oldDnd = self._dnd
340                 try:
341                         with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
342                                 yield (
343                                         self._backend[0].set_dnd,
344                                         (dnd, ),
345                                         {},
346                                 )
347                 except Exception, e:
348                         self.error.emit(str(e))
349                         return
350                 self._dnd = dnd
351                 if oldDnd != self._dnd:
352                         self.dndStateChange.emit(self._dnd)
353
354         def get_dnd(self):
355                 return self._dnd
356
357         def get_account_number(self):
358                 return self._backend[0].get_account_number()
359
360         def get_callback_numbers(self):
361                 # @todo Remove evilness (might call is_authed which can block)
362                 return self._backend[0].get_callback_numbers()
363
364         def get_callback_number(self):
365                 return self._callback
366
367         def set_callback_number(self, callback):
368                 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
369                 le.start(callback)
370
371         def _set_callback_number(self, callback):
372                 # I'm paranoid about our state geting out of sync so we set no matter
373                 # what but act as if we have the cannonical state
374                 assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
375                 oldCallback = self._callback
376                 try:
377                         yield (
378                                 self._backend[0].set_callback_number,
379                                 (callback, ),
380                                 {},
381                         )
382                 except Exception, e:
383                         self.error.emit(str(e))
384                         return
385                 self._callback = callback
386                 if oldCallback != self._callback:
387                         self.callbackNumberChanged.emit(self._callback)
388
389         def _login(self, username, password):
390                 with qui_utils.notify_busy(self._errorLog, "Logging In"):
391                         self._loggedInTime = self._LOGGINGIN_TIME
392                         self.stateChange.emit(self.LOGGINGIN_STATE)
393                         finalState = self.LOGGEDOUT_STATE
394                         isLoggedIn = False
395                         try:
396                                 if not isLoggedIn and self._backend[0].is_quick_login_possible():
397                                         isLoggedIn = yield (
398                                                 self._backend[0].is_authed,
399                                                 (),
400                                                 {},
401                                         )
402                                         if isLoggedIn:
403                                                 _moduleLogger.info("Logged in through cookies")
404                                         else:
405                                                 # Force a clearing of the cookies
406                                                 yield (
407                                                         self._backend[0].logout,
408                                                         (),
409                                                         {},
410                                                 )
411
412                                 if not isLoggedIn:
413                                         isLoggedIn = yield (
414                                                 self._backend[0].login,
415                                                 (username, password),
416                                                 {},
417                                         )
418                                         if isLoggedIn:
419                                                 _moduleLogger.info("Logged in through credentials")
420
421                                 if isLoggedIn:
422                                         self._loggedInTime = int(time.time())
423                                         oldUsername = self._username
424                                         self._username = username
425                                         finalState = self.LOGGEDIN_STATE
426                                         if oldUsername != self._username:
427                                                 needOps = not self._load()
428                                         else:
429                                                 needOps = True
430
431                                         self.loggedIn.emit()
432
433                                         if needOps:
434                                                 loginOps = self._loginOps[:]
435                                         else:
436                                                 loginOps = []
437                                         del self._loginOps[:]
438                                         for asyncOp in loginOps:
439                                                 asyncOp.start()
440                                 else:
441                                         self._loggedInTime = self._LOGGEDOUT_TIME
442                                         self.error.emit("Error logging in")
443                         except Exception, e:
444                                 self._loggedInTime = self._LOGGEDOUT_TIME
445                                 self.error.emit(str(e))
446                         finally:
447                                 self.stateChange.emit(finalState)
448                         if isLoggedIn and self._callback:
449                                 self.set_callback_number(self._callback)
450
451         def _load(self):
452                 updateContacts = len(self._contacts) != 0
453                 updateMessages = len(self._messages) != 0
454                 updateHistory = len(self._history) != 0
455                 oldDnd = self._dnd
456                 oldCallback = self._callback
457
458                 self._contacts = {}
459                 self._messages = []
460                 self._history = []
461                 self._dnd = False
462                 self._callback = ""
463
464                 loadedFromCache = self._load_from_cache()
465                 if loadedFromCache:
466                         updateContacts = True
467                         updateMessages = True
468                         updateHistory = True
469
470                 if updateContacts:
471                         self.contactsUpdated.emit()
472                 if updateMessages:
473                         self.messagesUpdated.emit()
474                 if updateHistory:
475                         self.historyUpdated.emit()
476                 if oldDnd != self._dnd:
477                         self.dndStateChange.emit(self._dnd)
478                 if oldCallback != self._callback:
479                         self.callbackNumberChanged.emit(self._callback)
480
481                 return loadedFromCache
482
483         def _load_from_cache(self):
484                 if self._cachePath is None:
485                         return False
486                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
487
488                 try:
489                         with open(cachePath, "rb") as f:
490                                 dumpedData = pickle.load(f)
491                 except (pickle.PickleError, IOError, EOFError, ValueError):
492                         _moduleLogger.exception("Pickle fun loading")
493                         return False
494                 except:
495                         _moduleLogger.exception("Weirdness loading")
496                         return False
497
498                 (
499                         version, build,
500                         contacts, contactUpdateTime,
501                         messages, messageUpdateTime,
502                         history, historyUpdateTime,
503                         dnd, callback
504                 ) = dumpedData
505
506                 if misc_utils.compare_versions(
507                         self._OLDEST_COMPATIBLE_FORMAT_VERSION,
508                         misc_utils.parse_version(version),
509                 ) <= 0:
510                         _moduleLogger.info("Loaded cache")
511                         self._contacts = contacts
512                         self._contactUpdateTime = contactUpdateTime
513                         self._messages = messages
514                         self._messageUpdateTime = messageUpdateTime
515                         self._history = history
516                         self._historyUpdateTime = historyUpdateTime
517                         self._dnd = dnd
518                         self._callback = callback
519                         return True
520                 else:
521                         _moduleLogger.debug(
522                                 "Skipping cache due to version mismatch (%s-%s)" % (
523                                         version, build
524                                 )
525                         )
526                         return False
527
528         def _save_to_cache(self):
529                 _moduleLogger.info("Saving cache")
530                 if self._cachePath is None:
531                         return
532                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
533
534                 try:
535                         dataToDump = (
536                                 constants.__version__, constants.__build__,
537                                 self._contacts, self._contactUpdateTime,
538                                 self._messages, self._messageUpdateTime,
539                                 self._history, self._historyUpdateTime,
540                                 self._dnd, self._callback
541                         )
542                         with open(cachePath, "wb") as f:
543                                 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
544                         _moduleLogger.info("Cache saved")
545                 except (pickle.PickleError, IOError):
546                         _moduleLogger.exception("While saving")
547
548         def _clear_cache(self):
549                 updateContacts = len(self._contacts) != 0
550                 updateMessages = len(self._messages) != 0
551                 updateHistory = len(self._history) != 0
552                 oldDnd = self._dnd
553                 oldCallback = self._callback
554
555                 self._contacts = {}
556                 self._contactUpdateTime = datetime.datetime(1971, 1, 1)
557                 self._messages = []
558                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
559                 self._history = []
560                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
561                 self._dnd = False
562                 self._callback = ""
563
564                 if updateContacts:
565                         self.contactsUpdated.emit()
566                 if updateMessages:
567                         self.messagesUpdated.emit()
568                 if updateHistory:
569                         self.historyUpdated.emit()
570                 if oldDnd != self._dnd:
571                         self.dndStateChange.emit(self._dnd)
572                 if oldCallback != self._callback:
573                         self.callbackNumberChanged.emit(self._callback)
574
575                 self._save_to_cache()
576
577         def _update_contacts(self):
578                 try:
579                         with qui_utils.notify_busy(self._errorLog, "Updating Contacts"):
580                                 self._contacts = yield (
581                                         self._backend[0].get_contacts,
582                                         (),
583                                         {},
584                                 )
585                 except Exception, e:
586                         self.error.emit(str(e))
587                         return
588                 self._contactUpdateTime = datetime.datetime.now()
589                 self.contactsUpdated.emit()
590
591         def _update_messages(self):
592                 try:
593                         with qui_utils.notify_busy(self._errorLog, "Updating Messages"):
594                                 self._messages = yield (
595                                         self._backend[0].get_messages,
596                                         (),
597                                         {},
598                                 )
599                 except Exception, e:
600                         self.error.emit(str(e))
601                         return
602                 self._messageUpdateTime = datetime.datetime.now()
603                 self.messagesUpdated.emit()
604
605         def _update_history(self):
606                 try:
607                         with qui_utils.notify_busy(self._errorLog, "Updating History"):
608                                 self._history = yield (
609                                         self._backend[0].get_recent,
610                                         (),
611                                         {},
612                                 )
613                 except Exception, e:
614                         self.error.emit(str(e))
615                         return
616                 self._historyUpdateTime = datetime.datetime.now()
617                 self.historyUpdated.emit()
618
619         def _update_dnd(self):
620                 oldDnd = self._dnd
621                 try:
622                         self._dnd = yield (
623                                 self._backend[0].is_dnd,
624                                 (),
625                                 {},
626                         )
627                 except Exception, e:
628                         self.error.emit(str(e))
629                         return
630                 if oldDnd != self._dnd:
631                         self.dndStateChange(self._dnd)
632
633         def _perform_op_while_loggedin(self, op):
634                 if self.state == self.LOGGEDIN_STATE:
635                         op.start()
636                 else:
637                         self._push_login_op(op)
638
639         def _push_login_op(self, asyncOp):
640                 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
641                 if asyncOp in self._loginOps:
642                         _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
643                         return
644                 self._loginOps.append(asyncOp)