Fixing a bug when login fails and not because of a random error
[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                         with notify_busy(self._errorLog, "Setting Callback"):
348                                 yield (
349                                         self._backend[0].set_callback_number,
350                                         (callback, ),
351                                         {},
352                                 )
353                 except Exception, e:
354                         self.error.emit(str(e))
355                         return
356                 self._callback = callback
357                 if oldCallback != self._callback:
358                         self.callbackNumberChanged.emit(self._callback)
359
360         def _login(self, username, password):
361                 with notify_busy(self._errorLog, "Logging In"):
362                         self._loggedInTime = self._LOGGINGIN_TIME
363                         self.stateChange.emit(self.LOGGINGIN_STATE)
364                         finalState = self.LOGGEDOUT_STATE
365                         isLoggedIn = False
366                         try:
367                                 if not isLoggedIn and self._backend[0].is_quick_login_possible():
368                                         isLoggedIn = yield (
369                                                 self._backend[0].is_authed,
370                                                 (),
371                                                 {},
372                                         )
373                                         if isLoggedIn:
374                                                 _moduleLogger.info("Logged in through cookies")
375                                         else:
376                                                 # Force a clearing of the cookies
377                                                 yield (
378                                                         self._backend[0].logout,
379                                                         (),
380                                                         {},
381                                                 )
382
383                                 if not isLoggedIn:
384                                         isLoggedIn = yield (
385                                                 self._backend[0].login,
386                                                 (username, password),
387                                                 {},
388                                         )
389                                         if isLoggedIn:
390                                                 _moduleLogger.info("Logged in through credentials")
391
392                                 if isLoggedIn:
393                                         self._loggedInTime = int(time.time())
394                                         oldUsername = self._username
395                                         self._username = username
396                                         finalState = self.LOGGEDIN_STATE
397                                         if oldUsername != self._username:
398                                                 needOps = not self._load()
399                                         else:
400                                                 needOps = True
401
402                                         self.loggedIn.emit()
403
404                                         if needOps:
405                                                 loginOps = self._loginOps[:]
406                                         else:
407                                                 loginOps = []
408                                         del self._loginOps[:]
409                                         for asyncOp in loginOps:
410                                                 asyncOp.start()
411                                 else:
412                                         self._loggedInTime = self._LOGGEDOUT_TIME
413                                         self.error.emit("Error logging in")
414                         except Exception, e:
415                                 self._loggedInTime = self._LOGGEDOUT_TIME
416                                 self.error.emit(str(e))
417                         finally:
418                                 self.stateChange.emit(finalState)
419                         if isLoggedIn and self._callback:
420                                 self.set_callback_number(self._callback)
421
422         def _load(self):
423                 updateContacts = len(self._contacts) != 0
424                 updateMessages = len(self._messages) != 0
425                 updateHistory = len(self._history) != 0
426                 oldDnd = self._dnd
427                 oldCallback = self._callback
428
429                 self._contacts = {}
430                 self._messages = []
431                 self._history = []
432                 self._dnd = False
433                 self._callback = ""
434
435                 loadedFromCache = self._load_from_cache()
436                 if loadedFromCache:
437                         updateContacts = True
438                         updateMessages = True
439                         updateHistory = True
440
441                 if updateContacts:
442                         self.contactsUpdated.emit()
443                 if updateMessages:
444                         self.messagesUpdated.emit()
445                 if updateHistory:
446                         self.historyUpdated.emit()
447                 if oldDnd != self._dnd:
448                         self.dndStateChange.emit(self._dnd)
449                 if oldCallback != self._callback:
450                         self.callbackNumberChanged.emit(self._callback)
451
452                 return loadedFromCache
453
454         def _load_from_cache(self):
455                 if self._cachePath is None:
456                         return False
457                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
458
459                 try:
460                         with open(cachePath, "rb") as f:
461                                 dumpedData = pickle.load(f)
462                 except (pickle.PickleError, IOError, EOFError, ValueError):
463                         _moduleLogger.exception("Pickle fun loading")
464                         return False
465                 except:
466                         _moduleLogger.exception("Weirdness loading")
467                         return False
468
469                 (
470                         version, build,
471                         contacts, contactUpdateTime,
472                         messages, messageUpdateTime,
473                         history, historyUpdateTime,
474                         dnd, callback
475                 ) = dumpedData
476
477                 if misc_utils.compare_versions(
478                         self._OLDEST_COMPATIBLE_FORMAT_VERSION,
479                         misc_utils.parse_version(version),
480                 ) <= 0:
481                         _moduleLogger.info("Loaded cache")
482                         self._contacts = contacts
483                         self._contactUpdateTime = contactUpdateTime
484                         self._messages = messages
485                         self._messageUpdateTime = messageUpdateTime
486                         self._history = history
487                         self._historyUpdateTime = historyUpdateTime
488                         self._dnd = dnd
489                         self._callback = callback
490                         return True
491                 else:
492                         _moduleLogger.debug(
493                                 "Skipping cache due to version mismatch (%s-%s)" % (
494                                         version, build
495                                 )
496                         )
497                         return False
498
499         def _save_to_cache(self):
500                 _moduleLogger.info("Saving cache")
501                 if self._cachePath is None:
502                         return
503                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
504
505                 try:
506                         dataToDump = (
507                                 constants.__version__, constants.__build__,
508                                 self._contacts, self._contactUpdateTime,
509                                 self._messages, self._messageUpdateTime,
510                                 self._history, self._historyUpdateTime,
511                                 self._dnd, self._callback
512                         )
513                         with open(cachePath, "wb") as f:
514                                 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
515                         _moduleLogger.info("Cache saved")
516                 except (pickle.PickleError, IOError):
517                         _moduleLogger.exception("While saving")
518
519         def _clear_cache(self):
520                 updateContacts = len(self._contacts) != 0
521                 updateMessages = len(self._messages) != 0
522                 updateHistory = len(self._history) != 0
523                 oldDnd = self._dnd
524                 oldCallback = self._callback
525
526                 self._contacts = {}
527                 self._contactUpdateTime = datetime.datetime(1, 1, 1)
528                 self._messages = []
529                 self._messageUpdateTime = datetime.datetime(1, 1, 1)
530                 self._history = []
531                 self._historyUpdateTime = datetime.datetime(1, 1, 1)
532                 self._dnd = False
533                 self._callback = ""
534
535                 if updateContacts:
536                         self.contactsUpdated.emit()
537                 if updateMessages:
538                         self.messagesUpdated.emit()
539                 if updateHistory:
540                         self.historyUpdated.emit()
541                 if oldDnd != self._dnd:
542                         self.dndStateChange.emit(self._dnd)
543                 if oldCallback != self._callback:
544                         self.callbackNumberChanged.emit(self._callback)
545
546                 self._save_to_cache()
547
548         def _update_contacts(self):
549                 try:
550                         with notify_busy(self._errorLog, "Updating Contacts"):
551                                 self._contacts = yield (
552                                         self._backend[0].get_contacts,
553                                         (),
554                                         {},
555                                 )
556                 except Exception, e:
557                         self.error.emit(str(e))
558                         return
559                 self._contactUpdateTime = datetime.datetime.now()
560                 self.contactsUpdated.emit()
561
562         def _update_messages(self):
563                 try:
564                         with notify_busy(self._errorLog, "Updating Messages"):
565                                 self._messages = yield (
566                                         self._backend[0].get_messages,
567                                         (),
568                                         {},
569                                 )
570                 except Exception, e:
571                         self.error.emit(str(e))
572                         return
573                 self._messageUpdateTime = datetime.datetime.now()
574                 self.messagesUpdated.emit()
575
576         def _update_history(self):
577                 try:
578                         with notify_busy(self._errorLog, "Updating History"):
579                                 self._history = yield (
580                                         self._backend[0].get_recent,
581                                         (),
582                                         {},
583                                 )
584                 except Exception, e:
585                         self.error.emit(str(e))
586                         return
587                 self._historyUpdateTime = datetime.datetime.now()
588                 self.historyUpdated.emit()
589
590         def _update_dnd(self):
591                 oldDnd = self._dnd
592                 try:
593                         self._dnd = yield (
594                                 self._backend[0].is_dnd,
595                                 (),
596                                 {},
597                         )
598                 except Exception, e:
599                         self.error.emit(str(e))
600                         return
601                 if oldDnd != self._dnd:
602                         self.dndStateChange(self._dnd)
603
604         def _perform_op_while_loggedin(self, op):
605                 if self.state == self.LOGGEDIN_STATE:
606                         op.start()
607                 else:
608                         self._push_login_op(op)
609
610         def _push_login_op(self, asyncOp):
611                 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
612                 if asyncOp in self._loginOps:
613                         _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
614                         return
615                 self._loginOps.append(asyncOp)