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