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