Improving error handling
[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                         _moduleLogger.exception("Reporting error to user")
164                         self.error.emit(str(e))
165
166         def _call(self, number):
167                 self.calling.emit()
168                 try:
169                         with self._busy("Calling"):
170                                 with qui_utils.notify_busy(self._errorLog, "Calling"):
171                                         yield (
172                                                 self._backend[0].call,
173                                                 (number, ),
174                                                 {},
175                                         )
176                                 self.called.emit()
177                                 self._clear()
178                 except Exception, e:
179                         _moduleLogger.exception("Reporting error to user")
180                         self.error.emit(str(e))
181
182         def _cancel(self):
183                 self.cancelling.emit()
184                 try:
185                         with qui_utils.notify_busy(self._errorLog, "Cancelling"):
186                                 yield (
187                                         self._backend[0].cancel,
188                                         (),
189                                         {},
190                                 )
191                         self.cancelled.emit()
192                 except Exception, e:
193                         _moduleLogger.exception("Reporting error to user")
194                         self.error.emit(str(e))
195
196
197 class Session(QtCore.QObject):
198
199         # @todo Somehow add support for csv contacts
200
201         stateChange = QtCore.pyqtSignal(str)
202         loggedOut = QtCore.pyqtSignal()
203         loggedIn = QtCore.pyqtSignal()
204         callbackNumberChanged = QtCore.pyqtSignal(str)
205
206         contactsUpdated = QtCore.pyqtSignal()
207         messagesUpdated = QtCore.pyqtSignal()
208         historyUpdated = QtCore.pyqtSignal()
209         dndStateChange = QtCore.pyqtSignal(bool)
210
211         error = QtCore.pyqtSignal(str)
212
213         LOGGEDOUT_STATE = "logged out"
214         LOGGINGIN_STATE = "logging in"
215         LOGGEDIN_STATE = "logged in"
216
217         _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.1.90")
218
219         _LOGGEDOUT_TIME = -1
220         _LOGGINGIN_TIME = 0
221
222         def __init__(self, errorLog, cachePath = None):
223                 QtCore.QObject.__init__(self)
224                 self._errorLog = errorLog
225                 self._pool = qore_utils.AsyncPool()
226                 self._backend = []
227                 self._loggedInTime = self._LOGGEDOUT_TIME
228                 self._loginOps = []
229                 self._cachePath = cachePath
230                 self._username = None
231                 self._draft = Draft(self._pool, self._backend, self._errorLog)
232
233                 self._contacts = {}
234                 self._contactUpdateTime = datetime.datetime(1971, 1, 1)
235                 self._messages = []
236                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
237                 self._history = []
238                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
239                 self._dnd = False
240                 self._callback = ""
241
242         @property
243         def state(self):
244                 return {
245                         self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
246                         self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
247                 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
248
249         @property
250         def draft(self):
251                 return self._draft
252
253         def login(self, username, password):
254                 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
255                 assert username != "", "No username specified"
256                 if self._cachePath is not None:
257                         cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
258                 else:
259                         cookiePath = None
260
261                 if self._username != username or not self._backend:
262                         from backends import gv_backend
263                         del self._backend[:]
264                         self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
265
266                 self._pool.start()
267                 le = concurrent.AsyncLinearExecution(self._pool, self._login)
268                 le.start(username, password)
269
270         def logout(self):
271                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
272                 _moduleLogger.info("Logging out")
273                 self._pool.stop()
274                 self._loggedInTime = self._LOGGEDOUT_TIME
275                 self._backend[0].persist()
276                 self._save_to_cache()
277                 self.stateChange.emit(self.LOGGEDOUT_STATE)
278                 self.loggedOut.emit()
279
280         def clear(self):
281                 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
282                 self._backend[0].logout()
283                 del self._backend[0]
284                 self._clear_cache()
285                 self._draft.clear()
286
287         def logout_and_clear(self):
288                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
289                 _moduleLogger.info("Logging out and clearing the account")
290                 self._pool.stop()
291                 self._loggedInTime = self._LOGGEDOUT_TIME
292                 self.clear()
293                 self.stateChange.emit(self.LOGGEDOUT_STATE)
294                 self.loggedOut.emit()
295
296         def update_contacts(self, force = True):
297                 if not force and self._contacts:
298                         return
299                 le = concurrent.AsyncLinearExecution(self._pool, self._update_contacts)
300                 self._perform_op_while_loggedin(le)
301
302         def get_contacts(self):
303                 return self._contacts
304
305         def get_when_contacts_updated(self):
306                 return self._contactUpdateTime
307
308         def update_messages(self, force = True):
309                 if not force and self._messages:
310                         return
311                 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages)
312                 self._perform_op_while_loggedin(le)
313
314         def get_messages(self):
315                 return self._messages
316
317         def get_when_messages_updated(self):
318                 return self._messageUpdateTime
319
320         def update_history(self, force = True):
321                 if not force and self._history:
322                         return
323                 le = concurrent.AsyncLinearExecution(self._pool, self._update_history)
324                 self._perform_op_while_loggedin(le)
325
326         def get_history(self):
327                 return self._history
328
329         def get_when_history_updated(self):
330                 return self._historyUpdateTime
331
332         def update_dnd(self):
333                 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd)
334                 self._perform_op_while_loggedin(le)
335
336         def set_dnd(self, dnd):
337                 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
338                 le.start(dnd)
339
340         def _set_dnd(self, dnd):
341                 oldDnd = self._dnd
342                 try:
343                         assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
344                         with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
345                                 yield (
346                                         self._backend[0].set_dnd,
347                                         (dnd, ),
348                                         {},
349                                 )
350                 except Exception, e:
351                         _moduleLogger.exception("Reporting error to user")
352                         self.error.emit(str(e))
353                         return
354                 self._dnd = dnd
355                 if oldDnd != self._dnd:
356                         self.dndStateChange.emit(self._dnd)
357
358         def get_dnd(self):
359                 return self._dnd
360
361         def get_account_number(self):
362                 if self.state != self.LOGGEDIN_STATE:
363                         return ""
364                 return self._backend[0].get_account_number()
365
366         def get_callback_numbers(self):
367                 if self.state != self.LOGGEDIN_STATE:
368                         return {}
369                 return self._backend[0].get_callback_numbers()
370
371         def get_callback_number(self):
372                 return self._callback
373
374         def set_callback_number(self, callback):
375                 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
376                 le.start(callback)
377
378         def _set_callback_number(self, callback):
379                 oldCallback = self._callback
380                 try:
381                         assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
382                         yield (
383                                 self._backend[0].set_callback_number,
384                                 (callback, ),
385                                 {},
386                         )
387                 except Exception, e:
388                         _moduleLogger.exception("Reporting error to user")
389                         self.error.emit(str(e))
390                         return
391                 self._callback = callback
392                 if oldCallback != self._callback:
393                         self.callbackNumberChanged.emit(self._callback)
394
395         def _login(self, username, password):
396                 with qui_utils.notify_busy(self._errorLog, "Logging In"):
397                         self._loggedInTime = self._LOGGINGIN_TIME
398                         self.stateChange.emit(self.LOGGINGIN_STATE)
399                         finalState = self.LOGGEDOUT_STATE
400                         isLoggedIn = False
401                         try:
402                                 if not isLoggedIn and self._backend[0].is_quick_login_possible():
403                                         isLoggedIn = yield (
404                                                 self._backend[0].is_authed,
405                                                 (),
406                                                 {},
407                                         )
408                                         if isLoggedIn:
409                                                 _moduleLogger.info("Logged in through cookies")
410                                         else:
411                                                 # Force a clearing of the cookies
412                                                 yield (
413                                                         self._backend[0].logout,
414                                                         (),
415                                                         {},
416                                                 )
417
418                                 if not isLoggedIn:
419                                         isLoggedIn = yield (
420                                                 self._backend[0].login,
421                                                 (username, password),
422                                                 {},
423                                         )
424                                         if isLoggedIn:
425                                                 _moduleLogger.info("Logged in through credentials")
426
427                                 if isLoggedIn:
428                                         self._loggedInTime = int(time.time())
429                                         oldUsername = self._username
430                                         self._username = username
431                                         finalState = self.LOGGEDIN_STATE
432                                         if oldUsername != self._username:
433                                                 needOps = not self._load()
434                                         else:
435                                                 needOps = True
436
437                                         self.loggedIn.emit()
438                                         self.stateChange.emit(finalState)
439                                         finalState = None # Mark it as already set
440
441                                         if needOps:
442                                                 loginOps = self._loginOps[:]
443                                         else:
444                                                 loginOps = []
445                                         del self._loginOps[:]
446                                         for asyncOp in loginOps:
447                                                 asyncOp.start()
448                                 else:
449                                         self._loggedInTime = self._LOGGEDOUT_TIME
450                                         self.error.emit("Error logging in")
451                         except Exception, e:
452                                 self._loggedInTime = self._LOGGEDOUT_TIME
453                                 _moduleLogger.exception("Reporting error to user")
454                                 self.error.emit(str(e))
455                         finally:
456                                 if finalState is not None:
457                                         self.stateChange.emit(finalState)
458                         if isLoggedIn and self._callback:
459                                 self.set_callback_number(self._callback)
460
461         def _load(self):
462                 updateContacts = len(self._contacts) != 0
463                 updateMessages = len(self._messages) != 0
464                 updateHistory = len(self._history) != 0
465                 oldDnd = self._dnd
466                 oldCallback = self._callback
467
468                 self._contacts = {}
469                 self._messages = []
470                 self._history = []
471                 self._dnd = False
472                 self._callback = ""
473
474                 loadedFromCache = self._load_from_cache()
475                 if loadedFromCache:
476                         updateContacts = True
477                         updateMessages = True
478                         updateHistory = True
479
480                 if updateContacts:
481                         self.contactsUpdated.emit()
482                 if updateMessages:
483                         self.messagesUpdated.emit()
484                 if updateHistory:
485                         self.historyUpdated.emit()
486                 if oldDnd != self._dnd:
487                         self.dndStateChange.emit(self._dnd)
488                 if oldCallback != self._callback:
489                         self.callbackNumberChanged.emit(self._callback)
490
491                 return loadedFromCache
492
493         def _load_from_cache(self):
494                 if self._cachePath is None:
495                         return False
496                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
497
498                 try:
499                         with open(cachePath, "rb") as f:
500                                 dumpedData = pickle.load(f)
501                 except (pickle.PickleError, IOError, EOFError, ValueError):
502                         _moduleLogger.exception("Pickle fun loading")
503                         return False
504                 except:
505                         _moduleLogger.exception("Weirdness loading")
506                         return False
507
508                 try:
509                         (
510                                 version, build,
511                                 contacts, contactUpdateTime,
512                                 messages, messageUpdateTime,
513                                 history, historyUpdateTime,
514                                 dnd, callback
515                         ) = dumpedData
516                 except ValueError:
517                         _moduleLogger.exception("Upgrade/downgrade fun")
518                         return False
519                 except:
520                         _moduleLogger.exception("Weirdlings")
521
522                 if misc_utils.compare_versions(
523                         self._OLDEST_COMPATIBLE_FORMAT_VERSION,
524                         misc_utils.parse_version(version),
525                 ) <= 0:
526                         _moduleLogger.info("Loaded cache")
527                         self._contacts = contacts
528                         self._contactUpdateTime = contactUpdateTime
529                         self._messages = messages
530                         self._messageUpdateTime = messageUpdateTime
531                         self._history = history
532                         self._historyUpdateTime = historyUpdateTime
533                         self._dnd = dnd
534                         self._callback = callback
535                         return True
536                 else:
537                         _moduleLogger.debug(
538                                 "Skipping cache due to version mismatch (%s-%s)" % (
539                                         version, build
540                                 )
541                         )
542                         return False
543
544         def _save_to_cache(self):
545                 _moduleLogger.info("Saving cache")
546                 if self._cachePath is None:
547                         return
548                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
549
550                 try:
551                         dataToDump = (
552                                 constants.__version__, constants.__build__,
553                                 self._contacts, self._contactUpdateTime,
554                                 self._messages, self._messageUpdateTime,
555                                 self._history, self._historyUpdateTime,
556                                 self._dnd, self._callback
557                         )
558                         with open(cachePath, "wb") as f:
559                                 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
560                         _moduleLogger.info("Cache saved")
561                 except (pickle.PickleError, IOError):
562                         _moduleLogger.exception("While saving")
563
564         def _clear_cache(self):
565                 updateContacts = len(self._contacts) != 0
566                 updateMessages = len(self._messages) != 0
567                 updateHistory = len(self._history) != 0
568                 oldDnd = self._dnd
569                 oldCallback = self._callback
570
571                 self._contacts = {}
572                 self._contactUpdateTime = datetime.datetime(1971, 1, 1)
573                 self._messages = []
574                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
575                 self._history = []
576                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
577                 self._dnd = False
578                 self._callback = ""
579
580                 if updateContacts:
581                         self.contactsUpdated.emit()
582                 if updateMessages:
583                         self.messagesUpdated.emit()
584                 if updateHistory:
585                         self.historyUpdated.emit()
586                 if oldDnd != self._dnd:
587                         self.dndStateChange.emit(self._dnd)
588                 if oldCallback != self._callback:
589                         self.callbackNumberChanged.emit(self._callback)
590
591                 self._save_to_cache()
592
593         def _update_contacts(self):
594                 try:
595                         assert self.state == self.LOGGEDIN_STATE, "Contacts requires being logged in (currently %s" % self.state
596                         with qui_utils.notify_busy(self._errorLog, "Updating Contacts"):
597                                 self._contacts = yield (
598                                         self._backend[0].get_contacts,
599                                         (),
600                                         {},
601                                 )
602                 except Exception, e:
603                         _moduleLogger.exception("Reporting error to user")
604                         self.error.emit(str(e))
605                         return
606                 self._contactUpdateTime = datetime.datetime.now()
607                 self.contactsUpdated.emit()
608
609         def _update_messages(self):
610                 try:
611                         assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
612                         with qui_utils.notify_busy(self._errorLog, "Updating Messages"):
613                                 self._messages = yield (
614                                         self._backend[0].get_messages,
615                                         (),
616                                         {},
617                                 )
618                 except Exception, e:
619                         _moduleLogger.exception("Reporting error to user")
620                         self.error.emit(str(e))
621                         return
622                 self._messageUpdateTime = datetime.datetime.now()
623                 self.messagesUpdated.emit()
624
625         def _update_history(self):
626                 try:
627                         assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
628                         with qui_utils.notify_busy(self._errorLog, "Updating History"):
629                                 self._history = yield (
630                                         self._backend[0].get_recent,
631                                         (),
632                                         {},
633                                 )
634                 except Exception, e:
635                         _moduleLogger.exception("Reporting error to user")
636                         self.error.emit(str(e))
637                         return
638                 self._historyUpdateTime = datetime.datetime.now()
639                 self.historyUpdated.emit()
640
641         def _update_dnd(self):
642                 oldDnd = self._dnd
643                 try:
644                         assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
645                         self._dnd = yield (
646                                 self._backend[0].is_dnd,
647                                 (),
648                                 {},
649                         )
650                 except Exception, e:
651                         _moduleLogger.exception("Reporting error to user")
652                         self.error.emit(str(e))
653                         return
654                 if oldDnd != self._dnd:
655                         self.dndStateChange(self._dnd)
656
657         def _perform_op_while_loggedin(self, op):
658                 if self.state == self.LOGGEDIN_STATE:
659                         op.start()
660                 else:
661                         self._push_login_op(op)
662
663         def _push_login_op(self, asyncOp):
664                 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
665                 if asyncOp in self._loginOps:
666                         _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
667                         return
668                 self._loginOps.append(asyncOp)