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