Exposing to the user the ability to do partial refreshes
[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                 # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up
89                 contactDetails = _DraftContact(title, description, numbersWithDescriptions)
90                 self._contacts[contactId] = contactDetails
91                 self.recipientsChanged.emit()
92
93         def remove_contact(self, contactId):
94                 if self._busyReason is not None:
95                         raise RuntimeError("Please wait for %r" % self._busyReason)
96                 assert contactId in self._contacts, "Contact missing"
97                 del self._contacts[contactId]
98                 self.recipientsChanged.emit()
99
100         def get_contacts(self):
101                 return self._contacts.iterkeys()
102
103         def get_num_contacts(self):
104                 return len(self._contacts)
105
106         def get_title(self, cid):
107                 return self._contacts[cid].title
108
109         def get_description(self, cid):
110                 return self._contacts[cid].description
111
112         def get_numbers(self, cid):
113                 return self._contacts[cid].numbers
114
115         def get_selected_number(self, cid):
116                 return self._contacts[cid].selectedNumber
117
118         def set_selected_number(self, cid, number):
119                 # @note I'm lazy, this isn't firing any kind of signal since only one
120                 # controller right now and that is the viewer
121                 assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
122                 self._contacts[cid].selectedNumber = number
123
124         def clear(self):
125                 if self._busyReason is not None:
126                         raise RuntimeError("Please wait for %r" % self._busyReason)
127                 self._clear()
128
129         def _clear(self):
130                 oldContacts = self._contacts
131                 self._contacts = {}
132                 self._message = ""
133                 if oldContacts:
134                         self.recipientsChanged.emit()
135
136         @contextlib.contextmanager
137         def _busy(self, message):
138                 if self._busyReason is not None:
139                         raise RuntimeError("Already busy doing %r" % self._busyReason)
140                 try:
141                         self._busyReason = message
142                         yield
143                 finally:
144                         self._busyReason = None
145
146         def _send(self, numbers, text):
147                 self.sendingMessage.emit()
148                 try:
149                         with self._busy("Sending Text"):
150                                 with qui_utils.notify_busy(self._errorLog, "Sending Text"):
151                                         yield (
152                                                 self._backend[0].send_sms,
153                                                 (numbers, text),
154                                                 {},
155                                         )
156                                 self.sentMessage.emit()
157                                 self._clear()
158                 except Exception, e:
159                         _moduleLogger.exception("Reporting error to user")
160                         self.error.emit(str(e))
161
162         def _call(self, number):
163                 self.calling.emit()
164                 try:
165                         with self._busy("Calling"):
166                                 with qui_utils.notify_busy(self._errorLog, "Calling"):
167                                         yield (
168                                                 self._backend[0].call,
169                                                 (number, ),
170                                                 {},
171                                         )
172                                 self.called.emit()
173                                 self._clear()
174                 except Exception, e:
175                         _moduleLogger.exception("Reporting error to user")
176                         self.error.emit(str(e))
177
178         def _cancel(self):
179                 self.cancelling.emit()
180                 try:
181                         with qui_utils.notify_busy(self._errorLog, "Cancelling"):
182                                 yield (
183                                         self._backend[0].cancel,
184                                         (),
185                                         {},
186                                 )
187                         self.cancelled.emit()
188                 except Exception, e:
189                         _moduleLogger.exception("Reporting error to user")
190                         self.error.emit(str(e))
191
192
193 class Session(QtCore.QObject):
194
195         # @todo Somehow add support for csv contacts
196
197         stateChange = QtCore.pyqtSignal(str)
198         loggedOut = QtCore.pyqtSignal()
199         loggedIn = QtCore.pyqtSignal()
200         callbackNumberChanged = QtCore.pyqtSignal(str)
201
202         accountUpdated = QtCore.pyqtSignal()
203         messagesUpdated = QtCore.pyqtSignal()
204         newMessages = QtCore.pyqtSignal()
205         historyUpdated = QtCore.pyqtSignal()
206         dndStateChange = QtCore.pyqtSignal(bool)
207
208         error = QtCore.pyqtSignal(str)
209
210         LOGGEDOUT_STATE = "logged out"
211         LOGGINGIN_STATE = "logging in"
212         LOGGEDIN_STATE = "logged in"
213
214         MESSAGE_TEXTS = "Text"
215         MESSAGE_VOICEMAILS = "Voicemail"
216         MESSAGE_ALL = "All"
217
218         _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0")
219
220         _LOGGEDOUT_TIME = -1
221         _LOGGINGIN_TIME = 0
222
223         def __init__(self, errorLog, cachePath = None):
224                 QtCore.QObject.__init__(self)
225                 self._errorLog = errorLog
226                 self._pool = qore_utils.AsyncPool()
227                 self._backend = []
228                 self._loggedInTime = self._LOGGEDOUT_TIME
229                 self._loginOps = []
230                 self._cachePath = cachePath
231                 self._username = None
232                 self._draft = Draft(self._pool, self._backend, self._errorLog)
233
234                 self._contacts = {}
235                 self._accountUpdateTime = datetime.datetime(1971, 1, 1)
236                 self._messages = []
237                 self._cleanMessages = []
238                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
239                 self._history = []
240                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
241                 self._dnd = False
242                 self._callback = ""
243
244         @property
245         def state(self):
246                 return {
247                         self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
248                         self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
249                 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
250
251         @property
252         def draft(self):
253                 return self._draft
254
255         def login(self, username, password):
256                 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
257                 assert username != "", "No username specified"
258                 if self._cachePath is not None:
259                         cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
260                 else:
261                         cookiePath = None
262
263                 if self._username != username or not self._backend:
264                         from backends import gv_backend
265                         del self._backend[:]
266                         self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
267
268                 self._pool.start()
269                 le = concurrent.AsyncLinearExecution(self._pool, self._login)
270                 le.start(username, password)
271
272         def logout(self):
273                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
274                 _moduleLogger.info("Logging out")
275                 self._pool.stop()
276                 self._loggedInTime = self._LOGGEDOUT_TIME
277                 self._backend[0].persist()
278                 self._save_to_cache()
279                 self.stateChange.emit(self.LOGGEDOUT_STATE)
280                 self.loggedOut.emit()
281
282         def clear(self):
283                 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
284                 self._backend[0].logout()
285                 del self._backend[0]
286                 self._clear_cache()
287                 self._draft.clear()
288
289         def logout_and_clear(self):
290                 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
291                 _moduleLogger.info("Logging out and clearing the account")
292                 self._pool.stop()
293                 self._loggedInTime = self._LOGGEDOUT_TIME
294                 self.clear()
295                 self.stateChange.emit(self.LOGGEDOUT_STATE)
296                 self.loggedOut.emit()
297
298         def update_account(self, force = True):
299                 if not force and self._contacts:
300                         return
301                 le = concurrent.AsyncLinearExecution(self._pool, self._update_account), (), {}
302                 self._perform_op_while_loggedin(le)
303
304         def get_contacts(self):
305                 return self._contacts
306
307         def get_when_contacts_updated(self):
308                 return self._accountUpdateTime
309
310         def update_messages(self, messageType, force = True):
311                 if not force and self._messages:
312                         return
313                 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages), (messageType, ), {}
314                 self._perform_op_while_loggedin(le)
315
316         def get_messages(self):
317                 return self._messages
318
319         def get_when_messages_updated(self):
320                 return self._messageUpdateTime
321
322         def update_history(self, force = True):
323                 if not force and self._history:
324                         return
325                 le = concurrent.AsyncLinearExecution(self._pool, self._update_history), (), {}
326                 self._perform_op_while_loggedin(le)
327
328         def get_history(self):
329                 return self._history
330
331         def get_when_history_updated(self):
332                 return self._historyUpdateTime
333
334         def update_dnd(self):
335                 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd), (), {}
336                 self._perform_op_while_loggedin(le)
337
338         def set_dnd(self, dnd):
339                 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
340                 le.start(dnd)
341
342         def _set_dnd(self, dnd):
343                 oldDnd = self._dnd
344                 try:
345                         assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
346                         with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
347                                 yield (
348                                         self._backend[0].set_dnd,
349                                         (dnd, ),
350                                         {},
351                                 )
352                 except Exception, e:
353                         _moduleLogger.exception("Reporting error to user")
354                         self.error.emit(str(e))
355                         return
356                 self._dnd = dnd
357                 if oldDnd != self._dnd:
358                         self.dndStateChange.emit(self._dnd)
359
360         def get_dnd(self):
361                 return self._dnd
362
363         def get_account_number(self):
364                 if self.state != self.LOGGEDIN_STATE:
365                         return ""
366                 return self._backend[0].get_account_number()
367
368         def get_callback_numbers(self):
369                 if self.state != self.LOGGEDIN_STATE:
370                         return {}
371                 return self._backend[0].get_callback_numbers()
372
373         def get_callback_number(self):
374                 return self._callback
375
376         def set_callback_number(self, callback):
377                 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
378                 le.start(callback)
379
380         def _set_callback_number(self, callback):
381                 oldCallback = self._callback
382                 try:
383                         assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
384                         yield (
385                                 self._backend[0].set_callback_number,
386                                 (callback, ),
387                                 {},
388                         )
389                 except Exception, e:
390                         _moduleLogger.exception("Reporting error to user")
391                         self.error.emit(str(e))
392                         return
393                 self._callback = callback
394                 if oldCallback != self._callback:
395                         self.callbackNumberChanged.emit(self._callback)
396
397         def _login(self, username, password):
398                 with qui_utils.notify_busy(self._errorLog, "Logging In"):
399                         self._loggedInTime = self._LOGGINGIN_TIME
400                         self.stateChange.emit(self.LOGGINGIN_STATE)
401                         finalState = self.LOGGEDOUT_STATE
402                         accountData = None
403                         try:
404                                 if accountData is None and self._backend[0].is_quick_login_possible():
405                                         accountData = yield (
406                                                 self._backend[0].refresh_account_info,
407                                                 (),
408                                                 {},
409                                         )
410                                         if accountData is not None:
411                                                 _moduleLogger.info("Logged in through cookies")
412                                         else:
413                                                 # Force a clearing of the cookies
414                                                 yield (
415                                                         self._backend[0].logout,
416                                                         (),
417                                                         {},
418                                                 )
419
420                                 if accountData is None:
421                                         accountData = yield (
422                                                 self._backend[0].login,
423                                                 (username, password),
424                                                 {},
425                                         )
426                                         if accountData is not None:
427                                                 _moduleLogger.info("Logged in through credentials")
428
429                                 if accountData is not None:
430                                         self._loggedInTime = int(time.time())
431                                         oldUsername = self._username
432                                         self._username = username
433                                         finalState = self.LOGGEDIN_STATE
434                                         if oldUsername != self._username:
435                                                 needOps = not self._load()
436                                         else:
437                                                 needOps = True
438
439                                         self.loggedIn.emit()
440                                         self.stateChange.emit(finalState)
441                                         finalState = None # Mark it as already set
442                                         self._process_account_data(accountData)
443
444                                         if needOps:
445                                                 loginOps = self._loginOps[:]
446                                         else:
447                                                 loginOps = []
448                                         del self._loginOps[:]
449                                         for asyncOp, args, kwds in loginOps:
450                                                 asyncOp.start(*args, **kwds)
451                                 else:
452                                         self._loggedInTime = self._LOGGEDOUT_TIME
453                                         self.error.emit("Error logging in")
454                         except Exception, e:
455                                 _moduleLogger.exception("Booh")
456                                 self._loggedInTime = self._LOGGEDOUT_TIME
457                                 _moduleLogger.exception("Reporting error to user")
458                                 self.error.emit(str(e))
459                         finally:
460                                 if finalState is not None:
461                                         self.stateChange.emit(finalState)
462                         if accountData is not None and self._callback:
463                                 self.set_callback_number(self._callback)
464
465         def _load(self):
466                 updateMessages = len(self._messages) != 0
467                 updateHistory = len(self._history) != 0
468                 oldDnd = self._dnd
469                 oldCallback = self._callback
470
471                 self._messages = []
472                 self._cleanMessages = []
473                 self._history = []
474                 self._dnd = False
475                 self._callback = ""
476
477                 loadedFromCache = self._load_from_cache()
478                 if loadedFromCache:
479                         updateMessages = True
480                         updateHistory = True
481
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, ImportError):
502                         _moduleLogger.exception("Pickle fun loading")
503                         return False
504                 except:
505                         _moduleLogger.exception("Weirdness loading")
506                         return False
507
508                 try:
509                         version, build = dumpedData[0:2]
510                 except ValueError:
511                         _moduleLogger.exception("Upgrade/downgrade fun")
512                         return False
513                 except:
514                         _moduleLogger.exception("Weirdlings")
515                         return False
516
517                 if misc_utils.compare_versions(
518                         self._OLDEST_COMPATIBLE_FORMAT_VERSION,
519                         misc_utils.parse_version(version),
520                 ) <= 0:
521                         try:
522                                 (
523                                         version, build,
524                                         messages, messageUpdateTime,
525                                         history, historyUpdateTime,
526                                         dnd, callback
527                                 ) = dumpedData
528                         except ValueError:
529                                 _moduleLogger.exception("Upgrade/downgrade fun")
530                                 return False
531                         except:
532                                 _moduleLogger.exception("Weirdlings")
533                                 return False
534
535                         _moduleLogger.info("Loaded cache")
536                         self._messages = messages
537                         self._alert_on_messages(self._messages)
538                         self._messageUpdateTime = messageUpdateTime
539                         self._history = history
540                         self._historyUpdateTime = historyUpdateTime
541                         self._dnd = dnd
542                         self._callback = callback
543                         return True
544                 else:
545                         _moduleLogger.debug(
546                                 "Skipping cache due to version mismatch (%s-%s)" % (
547                                         version, build
548                                 )
549                         )
550                         return False
551
552         def _save_to_cache(self):
553                 _moduleLogger.info("Saving cache")
554                 if self._cachePath is None:
555                         return
556                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
557
558                 try:
559                         dataToDump = (
560                                 constants.__version__, constants.__build__,
561                                 self._messages, self._messageUpdateTime,
562                                 self._history, self._historyUpdateTime,
563                                 self._dnd, self._callback
564                         )
565                         with open(cachePath, "wb") as f:
566                                 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
567                         _moduleLogger.info("Cache saved")
568                 except (pickle.PickleError, IOError):
569                         _moduleLogger.exception("While saving")
570
571         def _clear_cache(self):
572                 updateMessages = len(self._messages) != 0
573                 updateHistory = len(self._history) != 0
574                 oldDnd = self._dnd
575                 oldCallback = self._callback
576
577                 self._messages = []
578                 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
579                 self._history = []
580                 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
581                 self._dnd = False
582                 self._callback = ""
583
584                 if updateMessages:
585                         self.messagesUpdated.emit()
586                 if updateHistory:
587                         self.historyUpdated.emit()
588                 if oldDnd != self._dnd:
589                         self.dndStateChange.emit(self._dnd)
590                 if oldCallback != self._callback:
591                         self.callbackNumberChanged.emit(self._callback)
592
593                 self._save_to_cache()
594
595         def _update_account(self):
596                 try:
597                         assert self.state == self.LOGGEDIN_STATE, "Contacts requires being logged in (currently %s)" % self.state
598                         with qui_utils.notify_busy(self._errorLog, "Updating Account"):
599                                 accountData = yield (
600                                         self._backend[0].refresh_account_info,
601                                         (),
602                                         {},
603                                 )
604                 except Exception, e:
605                         _moduleLogger.exception("Reporting error to user")
606                         self.error.emit(str(e))
607                         return
608                 self._process_account_data(accountData)
609
610         def _update_messages(self, messageType):
611                 try:
612                         assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
613                         with qui_utils.notify_busy(self._errorLog, "Updating %s Messages" % messageType):
614                                 self._messages = yield (
615                                         self._backend[0].get_messages,
616                                         (messageType, ),
617                                         {},
618                                 )
619                 except Exception, e:
620                         _moduleLogger.exception("Reporting error to user")
621                         self.error.emit(str(e))
622                         return
623                 self._messageUpdateTime = datetime.datetime.now()
624                 self.messagesUpdated.emit()
625                 self._alert_on_messages(self._messages)
626
627         def _update_history(self):
628                 try:
629                         assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
630                         with qui_utils.notify_busy(self._errorLog, "Updating History"):
631                                 self._history = yield (
632                                         self._backend[0].get_recent,
633                                         (),
634                                         {},
635                                 )
636                 except Exception, e:
637                         _moduleLogger.exception("Reporting error to user")
638                         self.error.emit(str(e))
639                         return
640                 self._historyUpdateTime = datetime.datetime.now()
641                 self.historyUpdated.emit()
642
643         def _update_dnd(self):
644                 oldDnd = self._dnd
645                 try:
646                         assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
647                         self._dnd = yield (
648                                 self._backend[0].is_dnd,
649                                 (),
650                                 {},
651                         )
652                 except Exception, e:
653                         _moduleLogger.exception("Reporting error to user")
654                         self.error.emit(str(e))
655                         return
656                 if oldDnd != self._dnd:
657                         self.dndStateChange(self._dnd)
658
659         def _perform_op_while_loggedin(self, op):
660                 if self.state == self.LOGGEDIN_STATE:
661                         op, args, kwds = op
662                         op.start(*args, **kwds)
663                 else:
664                         self._push_login_op(op)
665
666         def _push_login_op(self, asyncOp):
667                 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
668                 if asyncOp in self._loginOps:
669                         _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
670                         return
671                 self._loginOps.append(asyncOp)
672
673         def _process_account_data(self, accountData):
674                 self._contacts = dict(
675                         (contactId, contactDetails)
676                         for contactId, contactDetails in accountData["contacts"].iteritems()
677                         # A zero contact id is the catch all for unknown contacts
678                         if contactId != "0"
679                 )
680
681                 self._accountUpdateTime = datetime.datetime.now()
682                 self.accountUpdated.emit()
683
684         def _alert_on_messages(self, messages):
685                 cleanNewMessages = list(self._clean_messages(messages))
686                 cleanNewMessages.sort(key=lambda m: m["contactId"])
687                 if self._cleanMessages:
688                         if self._cleanMessages != cleanNewMessages:
689                                 self.newMessages.emit()
690                 self._cleanMessages = cleanNewMessages
691
692         def _clean_messages(self, messages):
693                 for message in messages:
694                         cleaned = dict(
695                                 kv
696                                 for kv in message.iteritems()
697                                 if kv[0] not in
698                                 [
699                                         "relTime",
700                                         "time",
701                                         "isArchived",
702                                         "isRead",
703                                         "isSpam",
704                                         "isTrash",
705                                 ]
706                         )
707
708                         # Don't let outbound messages cause alerts, especially if the package has only outbound
709                         cleaned["messageParts"] = [
710                                 tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:"
711                         ]
712                         if not cleaned["messageParts"]:
713                                 continue
714
715                         yield cleaned