1 from __future__ import with_statement
15 from PyQt4 import QtCore
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
25 _moduleLogger = logging.getLogger(__name__)
28 class _DraftContact(object):
30 def __init__(self, title, description, numbersWithDescriptions):
32 self.description = description
33 self.numbers = numbersWithDescriptions
34 self.selectedNumber = numbersWithDescriptions[0][0]
37 class Draft(QtCore.QObject):
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)
47 recipientsChanged = QtCore.pyqtSignal()
49 def __init__(self, pool, backend, errorLog):
50 QtCore.QObject.__init__(self)
51 self._errorLog = errorLog
54 self._backend = backend
55 self._busyReason = None
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)
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)
74 le = concurrent.AsyncLinearExecution(self._pool, self._cancel)
77 def _get_message(self):
80 def _set_message(self, message):
81 self._message = message
83 message = property(_get_message, _set_message)
85 def add_contact(self, contactId, title, description, numbersWithDescriptions):
86 if self._busyReason is not None:
87 raise RuntimeError("Please wait for %r" % self._busyReason)
88 # Allow overwriting of contacts so that the message can be updated and the SMS dialog popped back up
89 contactDetails = _DraftContact(title, description, numbersWithDescriptions)
90 self._contacts[contactId] = contactDetails
91 self.recipientsChanged.emit()
93 def remove_contact(self, contactId):
94 if self._busyReason is not None:
95 raise RuntimeError("Please wait for %r" % self._busyReason)
96 assert contactId in self._contacts, "Contact missing"
97 del self._contacts[contactId]
98 self.recipientsChanged.emit()
100 def get_contacts(self):
101 return self._contacts.iterkeys()
103 def get_num_contacts(self):
104 return len(self._contacts)
106 def get_title(self, cid):
107 return self._contacts[cid].title
109 def get_description(self, cid):
110 return self._contacts[cid].description
112 def get_numbers(self, cid):
113 return self._contacts[cid].numbers
115 def get_selected_number(self, cid):
116 return self._contacts[cid].selectedNumber
118 def set_selected_number(self, cid, number):
119 # @note I'm lazy, this isn't firing any kind of signal since only one
120 # controller right now and that is the viewer
121 assert number in (nWD[0] for nWD in self._contacts[cid].numbers), "Number not selectable"
122 self._contacts[cid].selectedNumber = number
125 if self._busyReason is not None:
126 raise RuntimeError("Please wait for %r" % self._busyReason)
130 oldContacts = self._contacts
134 self.recipientsChanged.emit()
136 @contextlib.contextmanager
137 def _busy(self, message):
138 if self._busyReason is not None:
139 raise RuntimeError("Already busy doing %r" % self._busyReason)
141 self._busyReason = message
144 self._busyReason = None
146 def _send(self, numbers, text):
147 self.sendingMessage.emit()
149 with self._busy("Sending Text"):
150 with qui_utils.notify_busy(self._errorLog, "Sending Text"):
152 self._backend[0].send_sms,
156 self.sentMessage.emit()
159 _moduleLogger.exception("Reporting error to user")
160 self.error.emit(str(e))
162 def _call(self, number):
165 with self._busy("Calling"):
166 with qui_utils.notify_busy(self._errorLog, "Calling"):
168 self._backend[0].call,
175 _moduleLogger.exception("Reporting error to user")
176 self.error.emit(str(e))
179 self.cancelling.emit()
181 with qui_utils.notify_busy(self._errorLog, "Cancelling"):
183 self._backend[0].cancel,
187 self.cancelled.emit()
189 _moduleLogger.exception("Reporting error to user")
190 self.error.emit(str(e))
193 class Session(QtCore.QObject):
195 # @todo Somehow add support for csv contacts
197 stateChange = QtCore.pyqtSignal(str)
198 loggedOut = QtCore.pyqtSignal()
199 loggedIn = QtCore.pyqtSignal()
200 callbackNumberChanged = QtCore.pyqtSignal(str)
202 accountUpdated = QtCore.pyqtSignal()
203 messagesUpdated = QtCore.pyqtSignal()
204 newMessages = QtCore.pyqtSignal()
205 historyUpdated = QtCore.pyqtSignal()
206 dndStateChange = QtCore.pyqtSignal(bool)
208 error = QtCore.pyqtSignal(str)
210 LOGGEDOUT_STATE = "logged out"
211 LOGGINGIN_STATE = "logging in"
212 LOGGEDIN_STATE = "logged in"
214 MESSAGE_TEXTS = "Text"
215 MESSAGE_VOICEMAILS = "Voicemail"
218 _OLDEST_COMPATIBLE_FORMAT_VERSION = misc_utils.parse_version("1.3.0")
223 def __init__(self, errorLog, cachePath = None):
224 QtCore.QObject.__init__(self)
225 self._errorLog = errorLog
226 self._pool = qore_utils.AsyncPool()
228 self._loggedInTime = self._LOGGEDOUT_TIME
230 self._cachePath = cachePath
231 self._username = None
232 self._draft = Draft(self._pool, self._backend, self._errorLog)
235 self._accountUpdateTime = datetime.datetime(1971, 1, 1)
237 self._cleanMessages = []
238 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
240 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
247 self._LOGGEDOUT_TIME: self.LOGGEDOUT_STATE,
248 self._LOGGINGIN_TIME: self.LOGGINGIN_STATE,
249 }.get(self._loggedInTime, self.LOGGEDIN_STATE)
255 def login(self, username, password):
256 assert self.state == self.LOGGEDOUT_STATE, "Can only log-in when logged out (currently %s" % self.state
257 assert username != "", "No username specified"
258 if self._cachePath is not None:
259 cookiePath = os.path.join(self._cachePath, "%s.cookies" % username)
263 if self._username != username or not self._backend:
264 from backends import gv_backend
266 self._backend[0:0] = [gv_backend.GVDialer(cookiePath)]
269 le = concurrent.AsyncLinearExecution(self._pool, self._login)
270 le.start(username, password)
273 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
274 _moduleLogger.info("Logging out")
276 self._loggedInTime = self._LOGGEDOUT_TIME
277 self._backend[0].persist()
278 self._save_to_cache()
279 self.stateChange.emit(self.LOGGEDOUT_STATE)
280 self.loggedOut.emit()
283 assert self.state == self.LOGGEDOUT_STATE, "Can only clear when logged out (currently %s" % self.state
284 self._backend[0].logout()
289 def logout_and_clear(self):
290 assert self.state != self.LOGGEDOUT_STATE, "Can only logout if logged in (currently %s" % self.state
291 _moduleLogger.info("Logging out and clearing the account")
293 self._loggedInTime = self._LOGGEDOUT_TIME
295 self.stateChange.emit(self.LOGGEDOUT_STATE)
296 self.loggedOut.emit()
298 def update_account(self, force = True):
299 if not force and self._contacts:
301 le = concurrent.AsyncLinearExecution(self._pool, self._update_account), (), {}
302 self._perform_op_while_loggedin(le)
304 def get_contacts(self):
305 return self._contacts
307 def get_when_contacts_updated(self):
308 return self._accountUpdateTime
310 def update_messages(self, messageType, force = True):
311 if not force and self._messages:
313 le = concurrent.AsyncLinearExecution(self._pool, self._update_messages), (messageType, ), {}
314 self._perform_op_while_loggedin(le)
316 def get_messages(self):
317 return self._messages
319 def get_when_messages_updated(self):
320 return self._messageUpdateTime
322 def update_history(self, force = True):
323 if not force and self._history:
325 le = concurrent.AsyncLinearExecution(self._pool, self._update_history), (), {}
326 self._perform_op_while_loggedin(le)
328 def get_history(self):
331 def get_when_history_updated(self):
332 return self._historyUpdateTime
334 def update_dnd(self):
335 le = concurrent.AsyncLinearExecution(self._pool, self._update_dnd), (), {}
336 self._perform_op_while_loggedin(le)
338 def set_dnd(self, dnd):
339 le = concurrent.AsyncLinearExecution(self._pool, self._set_dnd)
342 def _set_dnd(self, dnd):
345 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
346 with qui_utils.notify_busy(self._errorLog, "Setting DND Status"):
348 self._backend[0].set_dnd,
353 _moduleLogger.exception("Reporting error to user")
354 self.error.emit(str(e))
357 if oldDnd != self._dnd:
358 self.dndStateChange.emit(self._dnd)
363 def get_account_number(self):
364 if self.state != self.LOGGEDIN_STATE:
366 return self._backend[0].get_account_number()
368 def get_callback_numbers(self):
369 if self.state != self.LOGGEDIN_STATE:
371 return self._backend[0].get_callback_numbers()
373 def get_callback_number(self):
374 return self._callback
376 def set_callback_number(self, callback):
377 le = concurrent.AsyncLinearExecution(self._pool, self._set_callback_number)
380 def _set_callback_number(self, callback):
381 oldCallback = self._callback
383 assert self.state == self.LOGGEDIN_STATE, "Callbacks configurable only when logged in (currently %s" % self.state
385 self._backend[0].set_callback_number,
390 _moduleLogger.exception("Reporting error to user")
391 self.error.emit(str(e))
393 self._callback = callback
394 if oldCallback != self._callback:
395 self.callbackNumberChanged.emit(self._callback)
397 def _login(self, username, password):
398 with qui_utils.notify_busy(self._errorLog, "Logging In"):
399 self._loggedInTime = self._LOGGINGIN_TIME
400 self.stateChange.emit(self.LOGGINGIN_STATE)
401 finalState = self.LOGGEDOUT_STATE
404 if accountData is None and self._backend[0].is_quick_login_possible():
405 accountData = yield (
406 self._backend[0].refresh_account_info,
410 if accountData is not None:
411 _moduleLogger.info("Logged in through cookies")
413 # Force a clearing of the cookies
415 self._backend[0].logout,
420 if accountData is None:
421 accountData = yield (
422 self._backend[0].login,
423 (username, password),
426 if accountData is not None:
427 _moduleLogger.info("Logged in through credentials")
429 if accountData is not None:
430 self._loggedInTime = int(time.time())
431 oldUsername = self._username
432 self._username = username
433 finalState = self.LOGGEDIN_STATE
434 if oldUsername != self._username:
435 needOps = not self._load()
440 self.stateChange.emit(finalState)
441 finalState = None # Mark it as already set
442 self._process_account_data(accountData)
445 loginOps = self._loginOps[:]
448 del self._loginOps[:]
449 for asyncOp, args, kwds in loginOps:
450 asyncOp.start(*args, **kwds)
452 self._loggedInTime = self._LOGGEDOUT_TIME
453 self.error.emit("Error logging in")
455 _moduleLogger.exception("Booh")
456 self._loggedInTime = self._LOGGEDOUT_TIME
457 _moduleLogger.exception("Reporting error to user")
458 self.error.emit(str(e))
460 if finalState is not None:
461 self.stateChange.emit(finalState)
462 if accountData is not None and self._callback:
463 self.set_callback_number(self._callback)
466 updateMessages = len(self._messages) != 0
467 updateHistory = len(self._history) != 0
469 oldCallback = self._callback
472 self._cleanMessages = []
477 loadedFromCache = self._load_from_cache()
479 updateMessages = True
483 self.messagesUpdated.emit()
485 self.historyUpdated.emit()
486 if oldDnd != self._dnd:
487 self.dndStateChange.emit(self._dnd)
488 if oldCallback != self._callback:
489 self.callbackNumberChanged.emit(self._callback)
491 return loadedFromCache
493 def _load_from_cache(self):
494 if self._cachePath is None:
496 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
499 with open(cachePath, "rb") as f:
500 dumpedData = pickle.load(f)
501 except (pickle.PickleError, IOError, EOFError, ValueError, ImportError):
502 _moduleLogger.exception("Pickle fun loading")
505 _moduleLogger.exception("Weirdness loading")
509 version, build = dumpedData[0:2]
511 _moduleLogger.exception("Upgrade/downgrade fun")
514 _moduleLogger.exception("Weirdlings")
517 if misc_utils.compare_versions(
518 self._OLDEST_COMPATIBLE_FORMAT_VERSION,
519 misc_utils.parse_version(version),
524 messages, messageUpdateTime,
525 history, historyUpdateTime,
529 _moduleLogger.exception("Upgrade/downgrade fun")
532 _moduleLogger.exception("Weirdlings")
535 _moduleLogger.info("Loaded cache")
536 self._messages = messages
537 self._alert_on_messages(self._messages)
538 self._messageUpdateTime = messageUpdateTime
539 self._history = history
540 self._historyUpdateTime = historyUpdateTime
542 self._callback = callback
546 "Skipping cache due to version mismatch (%s-%s)" % (
552 def _save_to_cache(self):
553 _moduleLogger.info("Saving cache")
554 if self._cachePath is None:
556 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
560 constants.__version__, constants.__build__,
561 self._messages, self._messageUpdateTime,
562 self._history, self._historyUpdateTime,
563 self._dnd, self._callback
565 with open(cachePath, "wb") as f:
566 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
567 _moduleLogger.info("Cache saved")
568 except (pickle.PickleError, IOError):
569 _moduleLogger.exception("While saving")
571 def _clear_cache(self):
572 updateMessages = len(self._messages) != 0
573 updateHistory = len(self._history) != 0
575 oldCallback = self._callback
578 self._messageUpdateTime = datetime.datetime(1971, 1, 1)
580 self._historyUpdateTime = datetime.datetime(1971, 1, 1)
585 self.messagesUpdated.emit()
587 self.historyUpdated.emit()
588 if oldDnd != self._dnd:
589 self.dndStateChange.emit(self._dnd)
590 if oldCallback != self._callback:
591 self.callbackNumberChanged.emit(self._callback)
593 self._save_to_cache()
595 def _update_account(self):
597 assert self.state == self.LOGGEDIN_STATE, "Contacts requires being logged in (currently %s)" % self.state
598 with qui_utils.notify_busy(self._errorLog, "Updating Account"):
599 accountData = yield (
600 self._backend[0].refresh_account_info,
605 _moduleLogger.exception("Reporting error to user")
606 self.error.emit(str(e))
608 self._process_account_data(accountData)
610 def _update_messages(self, messageType):
612 assert self.state == self.LOGGEDIN_STATE, "Messages requires being logged in (currently %s" % self.state
613 with qui_utils.notify_busy(self._errorLog, "Updating %s Messages" % messageType):
614 self._messages = yield (
615 self._backend[0].get_messages,
620 _moduleLogger.exception("Reporting error to user")
621 self.error.emit(str(e))
623 self._messageUpdateTime = datetime.datetime.now()
624 self.messagesUpdated.emit()
625 self._alert_on_messages(self._messages)
627 def _update_history(self):
629 assert self.state == self.LOGGEDIN_STATE, "History requires being logged in (currently %s" % self.state
630 with qui_utils.notify_busy(self._errorLog, "Updating History"):
631 self._history = yield (
632 self._backend[0].get_recent,
637 _moduleLogger.exception("Reporting error to user")
638 self.error.emit(str(e))
640 self._historyUpdateTime = datetime.datetime.now()
641 self.historyUpdated.emit()
643 def _update_dnd(self):
646 assert self.state == self.LOGGEDIN_STATE, "DND requires being logged in (currently %s" % self.state
648 self._backend[0].is_dnd,
653 _moduleLogger.exception("Reporting error to user")
654 self.error.emit(str(e))
656 if oldDnd != self._dnd:
657 self.dndStateChange(self._dnd)
659 def _perform_op_while_loggedin(self, op):
660 if self.state == self.LOGGEDIN_STATE:
662 op.start(*args, **kwds)
664 self._push_login_op(op)
666 def _push_login_op(self, asyncOp):
667 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
668 if asyncOp in self._loginOps:
669 _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
671 self._loginOps.append(asyncOp)
673 def _process_account_data(self, accountData):
674 self._contacts = dict(
675 (contactId, contactDetails)
676 for contactId, contactDetails in accountData["contacts"].iteritems()
677 # A zero contact id is the catch all for unknown contacts
681 self._accountUpdateTime = datetime.datetime.now()
682 self.accountUpdated.emit()
684 def _alert_on_messages(self, messages):
685 cleanNewMessages = list(self._clean_messages(messages))
686 cleanNewMessages.sort(key=lambda m: m["contactId"])
687 if self._cleanMessages:
688 if self._cleanMessages != cleanNewMessages:
689 self.newMessages.emit()
690 self._cleanMessages = cleanNewMessages
692 def _clean_messages(self, messages):
693 for message in messages:
696 for kv in message.iteritems()
708 # Don't let outbound messages cause alerts, especially if the package has only outbound
709 cleaned["messageParts"] = [
710 tuple(part[0:-1]) for part in cleaned["messageParts"] if part[0] != "Me:"
712 if not cleaned["messageParts"]: