imip-agent

Annotated imiptools/data.py

291:e28bfb8b0ca1
2015-02-08 Paul Boddie Convert periods to complete UTC datetimes for free/busy recording and sharing. Thus, dates should never appear in free/busy data because they ambiguously define time periods, but this requires a time regime/zone interpretation to occur, and this is done using either a TZID record in each user's preferences or the system timezone, assuming that users are more likely to have a connection to the system they are using than some arbitrary time zone such as UTC. Fixed busy time display in the manager for periods without event information.
paul@213 1
#!/usr/bin/env python
paul@213 2
paul@213 3
"""
paul@213 4
Interpretation of vCalendar content.
paul@213 5
paul@213 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@213 7
paul@213 8
This program is free software; you can redistribute it and/or modify it under
paul@213 9
the terms of the GNU General Public License as published by the Free Software
paul@213 10
Foundation; either version 3 of the License, or (at your option) any later
paul@213 11
version.
paul@213 12
paul@213 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@213 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@213 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@213 16
details.
paul@213 17
paul@213 18
You should have received a copy of the GNU General Public License along with
paul@213 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@213 20
"""
paul@213 21
paul@256 22
from datetime import datetime, timedelta
paul@213 23
from email.mime.text import MIMEText
paul@291 24
from imiptools.dates import format_datetime, get_datetime, get_freebusy_period, \
paul@291 25
                            to_utc_datetime
paul@213 26
from vCalendar import iterwrite, parse, ParseError, to_dict, to_node
paul@256 27
from vRecurrence import get_parameters, get_rule
paul@213 28
import email.utils
paul@213 29
paul@213 30
try:
paul@213 31
    from cStringIO import StringIO
paul@213 32
except ImportError:
paul@213 33
    from StringIO import StringIO
paul@213 34
paul@213 35
class Object:
paul@213 36
paul@213 37
    "Access to calendar structures."
paul@213 38
paul@213 39
    def __init__(self, fragment):
paul@213 40
        self.objtype, (self.details, self.attr) = fragment.items()[0]
paul@213 41
paul@213 42
    def get_items(self, name, all=True):
paul@213 43
        return get_items(self.details, name, all)
paul@213 44
paul@213 45
    def get_item(self, name):
paul@213 46
        return get_item(self.details, name)
paul@213 47
paul@213 48
    def get_value_map(self, name):
paul@213 49
        return get_value_map(self.details, name)
paul@213 50
paul@213 51
    def get_values(self, name, all=True):
paul@213 52
        return get_values(self.details, name, all)
paul@213 53
paul@213 54
    def get_value(self, name):
paul@213 55
        return get_value(self.details, name)
paul@213 56
paul@213 57
    def get_utc_datetime(self, name):
paul@213 58
        return get_utc_datetime(self.details, name)
paul@213 59
paul@289 60
    def get_datetime_item(self, name):
paul@289 61
        return get_datetime_item(self.details, name)
paul@289 62
paul@213 63
    def to_node(self):
paul@213 64
        return to_node({self.objtype : [(self.details, self.attr)]})
paul@213 65
paul@213 66
    def to_part(self, method):
paul@213 67
        return to_part(method, [self.to_node()])
paul@213 68
paul@213 69
    # Direct access to the structure.
paul@213 70
paul@213 71
    def __getitem__(self, name):
paul@213 72
        return self.details[name]
paul@213 73
paul@213 74
    def __setitem__(self, name, value):
paul@213 75
        self.details[name] = value
paul@213 76
paul@213 77
    def __delitem__(self, name):
paul@213 78
        del self.details[name]
paul@213 79
paul@256 80
    # Computed results.
paul@256 81
paul@256 82
    def get_periods(self, window_size=100):
paul@256 83
        return get_periods(self, window_size)
paul@256 84
paul@291 85
    def get_periods_for_freebusy(self, tzid, window_size=100):
paul@291 86
        periods = self.get_periods(window_size)
paul@291 87
        return get_periods_for_freebusy(self, periods, tzid)
paul@291 88
paul@213 89
# Construction and serialisation.
paul@213 90
paul@213 91
def make_calendar(nodes, method=None):
paul@213 92
paul@213 93
    """
paul@213 94
    Return a complete calendar node wrapping the given 'nodes' and employing the
paul@213 95
    given 'method', if indicated.
paul@213 96
    """
paul@213 97
paul@213 98
    return ("VCALENDAR", {},
paul@213 99
            (method and [("METHOD", {}, method)] or []) +
paul@213 100
            [("VERSION", {}, "2.0")] +
paul@213 101
            nodes
paul@213 102
           )
paul@213 103
paul@222 104
def make_freebusy(freebusy, uid, organiser, attendee=None):
paul@222 105
    
paul@222 106
    """
paul@222 107
    Return a calendar node defining the free/busy details described in the given
paul@222 108
    'freebusy' list, employing the given 'uid', for the given 'organiser', with
paul@222 109
    the optional 'attendee' providing recipient details.
paul@222 110
    """
paul@222 111
    
paul@222 112
    record = []
paul@222 113
    rwrite = record.append
paul@222 114
    
paul@222 115
    rwrite(("ORGANIZER", {}, organiser))
paul@222 116
paul@222 117
    if attendee:
paul@222 118
        rwrite(("ATTENDEE", {}, attendee)) 
paul@222 119
paul@222 120
    rwrite(("UID", {}, uid))
paul@222 121
paul@222 122
    if freebusy:
paul@222 123
        for start, end, uid, transp in freebusy:
paul@222 124
            if transp == "OPAQUE":
paul@222 125
                rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join([start, end])))
paul@222 126
paul@222 127
    return ("VFREEBUSY", {}, record)
paul@222 128
paul@213 129
def parse_object(f, encoding, objtype=None):
paul@213 130
paul@213 131
    """
paul@213 132
    Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is
paul@213 133
    given, only objects of that type will be returned. Otherwise, the root of
paul@213 134
    the content will be returned as a dictionary with a single key indicating
paul@213 135
    the object type.
paul@213 136
paul@213 137
    Return None if the content was not readable or suitable.
paul@213 138
    """
paul@213 139
paul@213 140
    try:
paul@213 141
        try:
paul@213 142
            doctype, attrs, elements = obj = parse(f, encoding=encoding)
paul@213 143
            if objtype and doctype == objtype:
paul@213 144
                return to_dict(obj)[objtype][0]
paul@213 145
            elif not objtype:
paul@213 146
                return to_dict(obj)
paul@213 147
        finally:
paul@213 148
            f.close()
paul@213 149
paul@213 150
    # NOTE: Handle parse errors properly.
paul@213 151
paul@213 152
    except (ParseError, ValueError):
paul@213 153
        pass
paul@213 154
paul@213 155
    return None
paul@213 156
paul@213 157
def to_part(method, calendar):
paul@213 158
paul@213 159
    """
paul@213 160
    Write using the given 'method', the 'calendar' details to a MIME
paul@213 161
    text/calendar part.
paul@213 162
    """
paul@213 163
paul@213 164
    encoding = "utf-8"
paul@213 165
    out = StringIO()
paul@213 166
    try:
paul@213 167
        to_stream(out, make_calendar(calendar, method), encoding)
paul@213 168
        part = MIMEText(out.getvalue(), "calendar", encoding)
paul@213 169
        part.set_param("method", method)
paul@213 170
        return part
paul@213 171
paul@213 172
    finally:
paul@213 173
        out.close()
paul@213 174
paul@213 175
def to_stream(out, fragment, encoding="utf-8"):
paul@213 176
    iterwrite(out, encoding=encoding).append(fragment)
paul@213 177
paul@213 178
# Structure access functions.
paul@213 179
paul@213 180
def get_items(d, name, all=True):
paul@213 181
paul@213 182
    """
paul@213 183
    Get all items from 'd' for the given 'name', returning single items if
paul@213 184
    'all' is specified and set to a false value and if only one value is
paul@213 185
    present for the name. Return None if no items are found for the name or if
paul@213 186
    many items are found but 'all' is set to a false value.
paul@213 187
    """
paul@213 188
paul@213 189
    if d.has_key(name):
paul@213 190
        values = d[name]
paul@213 191
        if all:
paul@213 192
            return values
paul@213 193
        elif len(values) == 1:
paul@213 194
            return values[0]
paul@213 195
        else:
paul@213 196
            return None
paul@213 197
    else:
paul@213 198
        return None
paul@213 199
paul@213 200
def get_item(d, name):
paul@213 201
    return get_items(d, name, False)
paul@213 202
paul@213 203
def get_value_map(d, name):
paul@213 204
paul@213 205
    """
paul@213 206
    Return a dictionary for all items in 'd' having the given 'name'. The
paul@213 207
    dictionary will map values for the name to any attributes or qualifiers
paul@213 208
    that may have been present.
paul@213 209
    """
paul@213 210
paul@213 211
    items = get_items(d, name)
paul@213 212
    if items:
paul@213 213
        return dict(items)
paul@213 214
    else:
paul@213 215
        return {}
paul@213 216
paul@213 217
def get_values(d, name, all=True):
paul@213 218
    if d.has_key(name):
paul@213 219
        values = d[name]
paul@213 220
        if not all and len(values) == 1:
paul@213 221
            return values[0][0]
paul@213 222
        else:
paul@213 223
            return map(lambda x: x[0], values)
paul@213 224
    else:
paul@213 225
        return None
paul@213 226
paul@213 227
def get_value(d, name):
paul@213 228
    return get_values(d, name, False)
paul@213 229
paul@213 230
def get_utc_datetime(d, name):
paul@289 231
    dt, attr = get_datetime_item(d, name)
paul@289 232
    return to_utc_datetime(dt)
paul@289 233
paul@289 234
def get_datetime_item(d, name):
paul@213 235
    value, attr = get_item(d, name)
paul@289 236
    return get_datetime(value, attr), attr
paul@213 237
paul@213 238
def get_addresses(values):
paul@213 239
    return [address for name, address in email.utils.getaddresses(values)]
paul@213 240
paul@213 241
def get_address(value):
paul@213 242
    return value.lower().startswith("mailto:") and value.lower()[7:] or value
paul@213 243
paul@213 244
def get_uri(value):
paul@213 245
    return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower()
paul@213 246
paul@213 247
def uri_dict(d):
paul@213 248
    return dict([(get_uri(key), value) for key, value in d.items()])
paul@213 249
paul@213 250
def uri_item(item):
paul@213 251
    return get_uri(item[0]), item[1]
paul@213 252
paul@213 253
def uri_items(items):
paul@213 254
    return [(get_uri(value), attr) for value, attr in items]
paul@213 255
paul@220 256
# Operations on structure data.
paul@220 257
paul@220 258
def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set):
paul@220 259
paul@220 260
    """
paul@220 261
    Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and
paul@220 262
    'new_dtstamp', and the 'partstat_set' indication, whether the object
paul@220 263
    providing the new information is really newer than the object providing the
paul@220 264
    old information.
paul@220 265
    """
paul@220 266
paul@220 267
    have_sequence = old_sequence is not None and new_sequence is not None
paul@220 268
    is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence)
paul@220 269
paul@220 270
    have_dtstamp = old_dtstamp and new_dtstamp
paul@220 271
    is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp
paul@220 272
paul@220 273
    is_old_sequence = have_sequence and (
paul@220 274
        int(new_sequence) < int(old_sequence) or
paul@220 275
        is_same_sequence and is_old_dtstamp
paul@220 276
        )
paul@220 277
paul@220 278
    return is_same_sequence and partstat_set or not is_old_sequence
paul@220 279
paul@256 280
# NOTE: Need to expose the 100 day window for recurring events in the
paul@256 281
# NOTE: configuration.
paul@256 282
paul@256 283
def get_periods(obj, window_size=100):
paul@256 284
paul@256 285
    """
paul@256 286
    Return periods for the given object 'obj', confining materialised periods
paul@256 287
    to the given 'window_size' in days starting from the present moment.
paul@256 288
    """
paul@256 289
paul@256 290
    dtstart = obj.get_utc_datetime("DTSTART")
paul@256 291
    dtend = obj.get_utc_datetime("DTEND")
paul@256 292
paul@256 293
    # NOTE: Need also DURATION support.
paul@256 294
paul@256 295
    duration = dtend - dtstart
paul@256 296
paul@256 297
    # Recurrence rules create multiple instances to be checked.
paul@256 298
    # Conflicts may only be assessed within a period defined by policy
paul@256 299
    # for the agent, with instances outside that period being considered
paul@256 300
    # unchecked.
paul@256 301
paul@256 302
    window_end = datetime.now() + timedelta(window_size)
paul@256 303
paul@256 304
    # NOTE: Need also RDATE and EXDATE support.
paul@256 305
paul@256 306
    rrule = obj.get_value("RRULE")
paul@256 307
paul@256 308
    if rrule:
paul@256 309
        selector = get_rule(dtstart, rrule)
paul@256 310
        parameters = get_parameters(rrule)
paul@256 311
        periods = []
paul@256 312
        for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")):
paul@256 313
            start = datetime(*start, tzinfo=timezone("UTC"))
paul@256 314
            end = start + duration
paul@291 315
            periods.append((start, end))
paul@256 316
    else:
paul@291 317
        periods = [(dtstart, dtend)]
paul@256 318
paul@256 319
    return periods
paul@256 320
paul@291 321
def get_periods_for_freebusy(obj, periods, tzid):
paul@291 322
paul@291 323
    start, start_attr = obj.get_datetime_item("DTSTART")
paul@291 324
    end, end_attr = obj.get_datetime_item("DTEND")
paul@291 325
paul@291 326
    tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid
paul@291 327
paul@291 328
    l = []
paul@291 329
paul@291 330
    for start, end in periods:
paul@291 331
        start, end = get_freebusy_period(start, end, tzid)
paul@291 332
        l.append((format_datetime(start), format_datetime(end)))
paul@291 333
paul@291 334
    return l
paul@291 335
paul@213 336
# vim: tabstop=4 expandtab shiftwidth=4