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