Popup the account dialog when no callback number is configured
[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                                         if oldUsername != self._username:
399                                                 needOps = not self._load()
400                                         else:
401                                                 needOps = True
402
403                                         self.loggedIn.emit()
404
405                                         if needOps:
406                                                 loginOps = self._loginOps[:]
407                                         else:
408                                                 loginOps = []
409                                         del self._loginOps[:]
410                                         for asyncOp in loginOps:
411                                                 asyncOp.start()
412                         except Exception, e:
413                                 self._loggedInTime = self._LOGGEDOUT_TIME
414                                 self.error.emit(str(e))
415                         finally:
416                                 self.stateChange.emit(finalState)
417
418         def _load(self):
419                 updateContacts = len(self._contacts) != 0
420                 updateMessages = len(self._messages) != 0
421                 updateHistory = len(self._history) != 0
422                 oldDnd = self._dnd
423                 oldCallback = self._callback
424
425                 self._contacts = {}
426                 self._messages = []
427                 self._history = []
428                 self._dnd = False
429                 self._callback = ""
430
431                 loadedFromCache = self._load_from_cache()
432                 if loadedFromCache:
433                         updateContacts = True
434                         updateMessages = True
435                         updateHistory = True
436
437                 if updateContacts:
438                         self.contactsUpdated.emit()
439                 if updateMessages:
440                         self.messagesUpdated.emit()
441                 if updateHistory:
442                         self.historyUpdated.emit()
443                 if oldDnd != self._dnd:
444                         self.dndStateChange.emit(self._dnd)
445                 if oldCallback != self._callback:
446                         self.callbackNumberChanged.emit(self._callback)
447
448                 return loadedFromCache
449
450         def _load_from_cache(self):
451                 if self._cachePath is None:
452                         return False
453                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
454
455                 try:
456                         with open(cachePath, "rb") as f:
457                                 dumpedData = pickle.load(f)
458                 except (pickle.PickleError, IOError, EOFError, ValueError):
459                         _moduleLogger.exception("Pickle fun loading")
460                         return False
461                 except:
462                         _moduleLogger.exception("Weirdness loading")
463                         return False
464
465                 (
466                         version, build,
467                         contacts, contactUpdateTime,
468                         messages, messageUpdateTime,
469                         history, historyUpdateTime,
470                         dnd, callback
471                 ) = dumpedData
472
473                 if misc_utils.compare_versions(
474                         self._OLDEST_COMPATIBLE_FORMAT_VERSION,
475                         misc_utils.parse_version(version),
476                 ) <= 0:
477                         _moduleLogger.info("Loaded cache")
478                         self._contacts = contacts
479                         self._contactUpdateTime = contactUpdateTime
480                         self._messages = messages
481                         self._messageUpdateTime = messageUpdateTime
482                         self._history = history
483                         self._historyUpdateTime = historyUpdateTime
484                         self._dnd = dnd
485                         self._callback = callback
486                         return True
487                 else:
488                         _moduleLogger.debug(
489                                 "Skipping cache due to version mismatch (%s-%s)" % (
490                                         version, build
491                                 )
492                         )
493                         return False
494
495         def _save_to_cache(self):
496                 _moduleLogger.info("Saving cache")
497                 if self._cachePath is None:
498                         return
499                 cachePath = os.path.join(self._cachePath, "%s.cache" % self._username)
500
501                 try:
502                         dataToDump = (
503                                 constants.__version__, constants.__build__,
504                                 self._contacts, self._contactUpdateTime,
505                                 self._messages, self._messageUpdateTime,
506                                 self._history, self._historyUpdateTime,
507                                 self._dnd, self._callback
508                         )
509                         with open(cachePath, "wb") as f:
510                                 pickle.dump(dataToDump, f, pickle.HIGHEST_PROTOCOL)
511                         _moduleLogger.info("Cache saved")
512                 except (pickle.PickleError, IOError):
513                         _moduleLogger.exception("While saving")
514
515         def _clear_cache(self):
516                 updateContacts = len(self._contacts) != 0
517                 updateMessages = len(self._messages) != 0
518                 updateHistory = len(self._history) != 0
519                 oldDnd = self._dnd
520                 oldCallback = self._callback
521
522                 self._contacts = {}
523                 self._contactUpdateTime = datetime.datetime(1, 1, 1)
524                 self._messages = []
525                 self._messageUpdateTime = datetime.datetime(1, 1, 1)
526                 self._history = []
527                 self._historyUpdateTime = datetime.datetime(1, 1, 1)
528                 self._dnd = False
529                 self._callback = ""
530
531                 if updateContacts:
532                         self.contactsUpdated.emit()
533                 if updateMessages:
534                         self.messagesUpdated.emit()
535                 if updateHistory:
536                         self.historyUpdated.emit()
537                 if oldDnd != self._dnd:
538                         self.dndStateChange.emit(self._dnd)
539                 if oldCallback != self._callback:
540                         self.callbackNumberChanged.emit(self._callback)
541
542                 self._save_to_cache()
543
544         def _update_contacts(self):
545                 try:
546                         with notify_busy(self._errorLog, "Updating Contacts"):
547                                 self._contacts = yield (
548                                         self._backend[0].get_contacts,
549                                         (),
550                                         {},
551                                 )
552                 except Exception, e:
553                         self.error.emit(str(e))
554                         return
555                 self._contactUpdateTime = datetime.datetime.now()
556                 self.contactsUpdated.emit()
557
558         def _update_messages(self):
559                 try:
560                         with notify_busy(self._errorLog, "Updating Messages"):
561                                 self._messages = yield (
562                                         self._backend[0].get_messages,
563                                         (),
564                                         {},
565                                 )
566                 except Exception, e:
567                         self.error.emit(str(e))
568                         return
569                 self._messageUpdateTime = datetime.datetime.now()
570                 self.messagesUpdated.emit()
571
572         def _update_history(self):
573                 try:
574                         with notify_busy(self._errorLog, "Updating History"):
575                                 self._history = yield (
576                                         self._backend[0].get_recent,
577                                         (),
578                                         {},
579                                 )
580                 except Exception, e:
581                         self.error.emit(str(e))
582                         return
583                 self._historyUpdateTime = datetime.datetime.now()
584                 self.historyUpdated.emit()
585
586         def _update_dnd(self):
587                 oldDnd = self._dnd
588                 try:
589                         self._dnd = yield (
590                                 self._backend[0].is_dnd,
591                                 (),
592                                 {},
593                         )
594                 except Exception, e:
595                         self.error.emit(str(e))
596                         return
597                 if oldDnd != self._dnd:
598                         self.dndStateChange(self._dnd)
599
600         def _perform_op_while_loggedin(self, op):
601                 if self.state == self.LOGGEDIN_STATE:
602                         op.start()
603                 else:
604                         self._push_login_op(op)
605
606         def _push_login_op(self, asyncOp):
607                 assert self.state != self.LOGGEDIN_STATE, "Can only queue work when logged out"
608                 if asyncOp in self._loginOps:
609                         _moduleLogger.info("Skipping queueing duplicate op: %r" % asyncOp)
610                         return
611                 self._loginOps.append(asyncOp)