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