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