Fixing some errors seen when account is cleared
[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                 (
509                         version, build,
510                         contacts, contactUpdateTime,
511                         messages, messageUpdateTime,
512                         history, historyUpdateTime,
513                         dnd, callback
514                 ) = dumpedData
515
516                 if misc_utils.compare_versions(
517                         self._OLDEST_COMPATIBLE_FORMAT_VERSION,
518                         misc_utils.parse_version(version),
519                 ) <= 0:
520                         _moduleLogger.info("Loaded cache")
521                         self._contacts = contacts
522                         self._contactUpdateTime = contactUpdateTime
523                         self._messages = messages
524                         self._messageUpdateTime = messageUpdateTime
525                         self._history = history
526                         self._historyUpdateTime = historyUpdateTime
527                         self._dnd = dnd
528                         self._callback = callback
529                         return True
530                 else:
531                         _moduleLogger.debug(
532                                 "Skipping cache due to version mismatch (%s-%s)" % (
533                                         version, build
534                                 )
535                         )
536                         return False
537
538         def _save_to_cache(self):
539                 _moduleLogger.info("Saving cache")
540                 if self._cachePath is None:
541                         return
542                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
543
544                 try:
545                         dataToDump = (
546                                 constants.__version__, constants.__build__,
547                                 self._contacts, self._contactUpdateTime,
548                                 self._messages, self._messageUpdateTime,
549                                 self._history, self._historyUpdateTime,
550                                 self._dnd, self._callback
551                         )
552                         with open(cachePath, "wb") as f:
553                                 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
554                         _moduleLogger.info("Cache saved")
555                 except (pickle.PickleError, IOError):
556                         _moduleLogger.exception("While saving")
557
558         def _clear_cache(self):
559                 updateContacts = len(self._contacts) != 0
560                 updateMessages = len(self._messages) != 0
561                 updateHistory = len(self._history) != 0
562                 oldDnd = self._dnd
563                 oldCallback = self._callback
564
565                 self._contacts = {}
566                 self._contactUpdateTime = datetime.datetime(1971, 1, 1)
567                 self._messages = []
568                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
569                 self._history = []
570                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
571                 self._dnd = False
572                 self._callback = ""
573
574                 if updateContacts:
575                         self.contactsUpdated.emit()
576                 if updateMessages:
577                         self.messagesUpdated.emit()
578                 if updateHistory:
579                         self.historyUpdated.emit()
580                 if oldDnd != self._dnd:
581                         self.dndStateChange.emit(self._dnd)
582                 if oldCallback != self._callback:
583                         self.callbackNumberChanged.emit(self._callback)
584
585                 self._save_to_cache()
586
587         def _update_contacts(self):
588                 try:
589                         assert self.state == self.LOGGEDIN_STATE, "Contacts requires being logged in (currently %s" % self.state
590                         with qui_utils.notify_busy(self._errorLog, "Updating Contacts"):
591                                 self._contacts = yield (
592                                         self._backend[0].get_contacts,
593                                         (),
594                                         {},
595                                 )
596                 except Exception, e:
597                         _moduleLogger.exception("Reporting error to user")
598                         self.error.emit(str(e))
599                         return
600                 self._contactUpdateTime = datetime.datetime.now()
601                 self.contactsUpdated.emit()
602
603         def _update_messages(self):
604                 try:
605                         assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
606                         with qui_utils.notify_busy(self._errorLog, "Updating Messages"):
607                                 self._messages = yield (
608                                         self._backend[0].get_messages,
609                                         (),
610                                         {},
611                                 )
612                 except Exception, e:
613                         _moduleLogger.exception("Reporting error to user")
614                         self.error.emit(str(e))
615                         return
616                 self._messageUpdateTime = datetime.datetime.now()
617                 self.messagesUpdated.emit()
618
619         def _update_history(self):
620                 try:
621                         assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
622                         with qui_utils.notify_busy(self._errorLog, "Updating History"):
623                                 self._history = yield (
624                                         self._backend[0].get_recent,
625                                         (),
626                                         {},
627                                 )
628                 except Exception, e:
629                         _moduleLogger.exception("Reporting error to user")
630                         self.error.emit(str(e))
631                         return
632                 self._historyUpdateTime = datetime.datetime.now()
633                 self.historyUpdated.emit()
634
635         def _update_dnd(self):
636                 oldDnd = self._dnd
637                 try:
638                         assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
639                         self._dnd = yield (
640                                 self._backend[0].is_dnd,
641                                 (),
642                                 {},
643                         )
644                 except Exception, e:
645                         _moduleLogger.exception("Reporting error to user")
646                         self.error.emit(str(e))
647                         return
648                 if oldDnd != self._dnd:
649                         self.dndStateChange(self._dnd)
650
651         def _perform_op_while_loggedin(self, op):
652                 if self.state == self.LOGGEDIN_STATE:
653                         op.start()
654                 else:
655                         self._push_login_op(op)
656
657         def _push_login_op(self, asyncOp):
658                 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
659                 if asyncOp in self._loginOps:
660                         _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
661                         return
662                 self._loginOps.append(asyncOp)