history.py 7.47 KB
Newer Older
1

2
import bisect
Adrian Georgescu's avatar
Adrian Georgescu committed
3
import pickle as pickle
4 5
import re

Dan Pascu's avatar
Dan Pascu committed
6
from PyQt5.QtGui import QIcon
7 8 9 10

from application.notification import IObserver, NotificationCenter
from application.python import Null
from application.python.types import Singleton
Dan Pascu's avatar
Dan Pascu committed
11
from datetime import date
12
from dateutil.tz import tzlocal
13
from zope.interface import implementer
14 15 16 17

from sipsimple.account import BonjourAccount
from sipsimple.addressbook import AddressbookManager
from sipsimple.threading import run_in_thread
18
from sipsimple.util import ISOTimestamp
19

20
from blink.resources import ApplicationData, Resources
21
from blink.util import run_in_gui_thread
Adrian Georgescu's avatar
Adrian Georgescu committed
22
import traceback
23 24


25 26 27
__all__ = ['HistoryManager']


28
@implementer(IObserver)
Adrian Georgescu's avatar
Adrian Georgescu committed
29
class HistoryManager(object, metaclass=Singleton):
30

31 32
    history_size = 20

33 34
    def __init__(self):
        try:
Adrian Georgescu's avatar
Adrian Georgescu committed
35
            data = pickle.load(open(ApplicationData.get('calls_history'), "rb"))
36
            if not isinstance(data, list) or not all(isinstance(item, HistoryEntry) and item.text and isinstance(item.call_time, ISOTimestamp) for item in data):
37
                raise ValueError("invalid save data")
Adrian Georgescu's avatar
Adrian Georgescu committed
38 39
        except Exception as e:
            traceback.print_exc()            
40
            self.calls = []
41
        else:
42
            self.calls = data[-self.history_size:]
43 44 45 46 47 48
        notification_center = NotificationCenter()
        notification_center.add_observer(self, name='SIPSessionDidEnd')
        notification_center.add_observer(self, name='SIPSessionDidFail')

    @run_in_thread('file-io')
    def save(self):
49 50
        with open(ApplicationData.get('calls_history'), 'wb+') as history_file:
            pickle.dump(self.calls, history_file)
51 52 53 54 55 56 57 58 59 60 61

    @run_in_gui_thread
    def handle_notification(self, notification):
        handler = getattr(self, '_NH_%s' % notification.name, Null)
        handler(notification)

    def _NH_SIPSessionDidEnd(self, notification):
        if notification.sender.account is BonjourAccount():
            return
        session = notification.sender
        entry = HistoryEntry.from_session(session)
62 63
        bisect.insort(self.calls, entry)
        self.calls = self.calls[-self.history_size:]
64 65 66 67 68 69 70
        self.save()

    def _NH_SIPSessionDidFail(self, notification):
        if notification.sender.account is BonjourAccount():
            return
        session = notification.sender
        entry = HistoryEntry.from_session(session)
Adrian Georgescu's avatar
Adrian Georgescu committed
71
        
72
        if session.direction == 'incoming':
73 74
            if notification.data.code != 487 or notification.data.failure_reason != 'Call completed elsewhere':
                entry.failed = True
75
        else:
76 77 78 79
            if notification.data.code == 0:
                entry.reason = 'Internal Error'
            elif notification.data.code == 487:
                entry.reason = 'Cancelled'
80
            else:
81
                entry.reason = notification.data.reason or notification.data.failure_reason
82 83 84
            entry.failed = True
        bisect.insort(self.calls, entry)
        self.calls = self.calls[-self.history_size:]
85 86 87
        self.save()


88 89 90 91
class IconDescriptor(object):
    def __init__(self, filename):
        self.filename = filename
        self.icon = None
92 93

    def __get__(self, instance, owner):
94 95 96 97
        if self.icon is None:
            self.icon = QIcon(self.filename)
            self.icon.filename = self.filename
        return self.icon
98

99 100
    def __set__(self, obj, value):
        raise AttributeError("attribute cannot be set")
101

102 103 104 105
    def __delete__(self, obj):
        raise AttributeError("attribute cannot be deleted")


106
class HistoryEntry(object):
107
    phone_number_re = re.compile(r'^(?P<number>(0|00|\+)[1-9]\d{7,14})@')
108

109 110 111 112 113
    incoming_normal_icon = IconDescriptor(Resources.get('icons/arrow-inward-blue.svg'))
    outgoing_normal_icon = IconDescriptor(Resources.get('icons/arrow-outward-green.svg'))
    incoming_failed_icon = IconDescriptor(Resources.get('icons/arrow-inward-red.svg'))
    outgoing_failed_icon = IconDescriptor(Resources.get('icons/arrow-outward-red.svg'))

114
    def __init__(self, direction, name, uri, account_id, call_time, duration, failed=False, reason=None):
115
        self.direction = direction
116 117
        self.name = name
        self.uri = uri
118 119 120
        self.account_id = account_id
        self.call_time = call_time
        self.duration = duration
121
        self.failed = failed
122
        self.reason = reason
123 124

    def __reduce__(self):
125
        return self.__class__, (self.direction, self.name, self.uri, self.account_id, self.call_time, self.duration, self.failed, self.reason)
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150

    def __eq__(self, other):
        return self is other

    def __ne__(self, other):
        return self is not other

    def __lt__(self, other):
        return self.call_time < other.call_time

    def __le__(self, other):
        return self.call_time <= other.call_time

    def __gt__(self, other):
        return self.call_time > other.call_time

    def __ge__(self, other):
        return self.call_time >= other.call_time

    @property
    def icon(self):
        if self.failed:
            return self.incoming_failed_icon if self.direction == 'incoming' else self.outgoing_failed_icon
        else:
            return self.incoming_normal_icon if self.direction == 'incoming' else self.outgoing_normal_icon
151

152 153
    @property
    def text(self):
Adrian Georgescu's avatar
Adrian Georgescu committed
154
        result = str(self.name or self.uri)
155
        if self.call_time:
156 157
            call_time = self.call_time.astimezone(tzlocal())
            call_date = call_time.date()
Dan Pascu's avatar
Dan Pascu committed
158
            today = date.today()
159 160
            days = (today - call_date).days
            if call_date == today:
161
                result += call_time.strftime(" at %H:%M")
162
            elif days == 1:
163
                result += call_time.strftime(" Yesterday at %H:%M")
164
            elif days < 7:
165
                result += call_time.strftime(" on %A")
166
            elif call_date.year == today.year:
167
                result += call_time.strftime(" on %B %d")
168
            else:
169
                result += call_time.strftime(" on %Y-%m-%d")
170
        if self.duration:
171 172 173 174 175 176 177
            seconds = int(self.duration.total_seconds())
            if seconds >= 3600:
                result += """ (%dh%02d'%02d")""" % (seconds / 3600, (seconds % 3600) / 60, seconds % 60)
            else:
                result += """ (%d'%02d")""" % (seconds / 60, seconds % 60)
        elif self.reason:
            result += ' (%s)' % self.reason.title()
178 179
        return result

180 181 182
    @classmethod
    def from_session(cls, session):
        if session.start_time is None and session.end_time is not None:
183
            # Session may have ended before it fully started
184
            session.start_time = session.end_time
185
        call_time = session.start_time or ISOTimestamp.now()
186 187 188 189
        if session.start_time and session.end_time:
            duration = session.end_time - session.start_time
        else:
            duration = None
Adrian Georgescu's avatar
Adrian Georgescu committed
190 191 192 193 194 195 196
        user = session.remote_identity.uri.user
        domain = session.remote_identity.uri.host

        user = user.decode() if isinstance(user, bytes) else user
        domain = domain.decode() if isinstance(domain, bytes) else domain
        
        remote_uri = '%s@%s' % (user, domain)
197 198 199 200 201 202 203 204 205
        match = cls.phone_number_re.match(remote_uri)
        if match:
            remote_uri = match.group('number')
        try:
            contact = next(contact for contact in AddressbookManager().get_contacts() if remote_uri in (addr.uri for addr in contact.uris))
        except StopIteration:
            display_name = session.remote_identity.display_name
        else:
            display_name = contact.name
Adrian Georgescu's avatar
Adrian Georgescu committed
206
        return cls(session.direction, display_name, remote_uri, str(session.account.id), call_time, duration)