imip-agent

Annotated imiptools/data.py

352:7edd0d9f6f13
2015-02-26 Paul Boddie Added initial support for RDATE and EXDATE properties. recurring-events
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@318 25
                            to_timezone, to_utc_datetime
paul@327 26
from imiptools.period import period_overlaps
paul@316 27
from pytz import timezone
paul@213 28
from vCalendar import iterwrite, parse, ParseError, to_dict, to_node
paul@256 29
from vRecurrence import get_parameters, get_rule
paul@213 30
import email.utils
paul@213 31
paul@213 32
try:
paul@213 33
    from cStringIO import StringIO
paul@213 34
except ImportError:
paul@213 35
    from StringIO import StringIO
paul@213 36
paul@213 37
class Object:
paul@213 38
paul@213 39
    "Access to calendar structures."
paul@213 40
paul@213 41
    def __init__(self, fragment):
paul@213 42
        self.objtype, (self.details, self.attr) = fragment.items()[0]
paul@213 43
paul@213 44
    def get_items(self, name, all=True):
paul@213 45
        return get_items(self.details, name, all)
paul@213 46
paul@213 47
    def get_item(self, name):
paul@213 48
        return get_item(self.details, name)
paul@213 49
paul@213 50
    def get_value_map(self, name):
paul@213 51
        return get_value_map(self.details, name)
paul@213 52
paul@213 53
    def get_values(self, name, all=True):
paul@213 54
        return get_values(self.details, name, all)
paul@213 55
paul@213 56
    def get_value(self, name):
paul@213 57
        return get_value(self.details, name)
paul@213 58
paul@213 59
    def get_utc_datetime(self, name):
paul@213 60
        return get_utc_datetime(self.details, name)
paul@213 61
paul@352 62
    def get_item_datetimes(self, name):
paul@352 63
        items = get_item_datetime_items(self.details, name)
paul@352 64
        return items and [dt for dt, attr in items]
paul@352 65
paul@352 66
    def get_item_datetime_items(self, name):
paul@352 67
        return get_item_datetime_items(self.details, name)
paul@352 68
paul@318 69
    def get_datetime(self, name):
paul@318 70
        dt, attr = get_datetime_item(self.details, name)
paul@318 71
        return dt
paul@318 72
paul@289 73
    def get_datetime_item(self, name):
paul@289 74
        return get_datetime_item(self.details, name)
paul@289 75
paul@213 76
    def to_node(self):
paul@213 77
        return to_node({self.objtype : [(self.details, self.attr)]})
paul@213 78
paul@213 79
    def to_part(self, method):
paul@213 80
        return to_part(method, [self.to_node()])
paul@213 81
paul@213 82
    # Direct access to the structure.
paul@213 83
paul@213 84
    def __getitem__(self, name):
paul@213 85
        return self.details[name]
paul@213 86
paul@213 87
    def __setitem__(self, name, value):
paul@213 88
        self.details[name] = value
paul@213 89
paul@213 90
    def __delitem__(self, name):
paul@213 91
        del self.details[name]
paul@213 92
paul@256 93
    # Computed results.
paul@256 94
paul@318 95
    def get_periods(self, tzid, window_size=100):
paul@318 96
        return get_periods(self, tzid, window_size)
paul@256 97
paul@291 98
    def get_periods_for_freebusy(self, tzid, window_size=100):
paul@318 99
        periods = self.get_periods(tzid, window_size)
paul@291 100
        return get_periods_for_freebusy(self, periods, tzid)
paul@291 101
paul@213 102
# Construction and serialisation.
paul@213 103
paul@213 104
def make_calendar(nodes, method=None):
paul@213 105
paul@213 106
    """
paul@213 107
    Return a complete calendar node wrapping the given 'nodes' and employing the
paul@213 108
    given 'method', if indicated.
paul@213 109
    """
paul@213 110
paul@213 111
    return ("VCALENDAR", {},
paul@213 112
            (method and [("METHOD", {}, method)] or []) +
paul@213 113
            [("VERSION", {}, "2.0")] +
paul@213 114
            nodes
paul@213 115
           )
paul@213 116
paul@327 117
def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None,
paul@327 118
    attendee_attr=None, dtstart=None, dtend=None):
paul@222 119
    
paul@222 120
    """
paul@222 121
    Return a calendar node defining the free/busy details described in the given
paul@292 122
    'freebusy' list, employing the given 'uid', for the given 'organiser' and
paul@292 123
    optional 'organiser_attr', with the optional 'attendee' providing recipient
paul@292 124
    details together with the optional 'attendee_attr'.
paul@327 125
paul@327 126
    The result will be constrained to the 'dtstart' and 'dtend' period if these
paul@327 127
    parameters are given.
paul@222 128
    """
paul@222 129
    
paul@222 130
    record = []
paul@222 131
    rwrite = record.append
paul@222 132
    
paul@292 133
    rwrite(("ORGANIZER", organiser_attr or {}, organiser))
paul@222 134
paul@222 135
    if attendee:
paul@292 136
        rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 
paul@222 137
paul@222 138
    rwrite(("UID", {}, uid))
paul@222 139
paul@222 140
    if freebusy:
paul@327 141
paul@327 142
        # Get a constrained view if start and end limits are specified.
paul@327 143
paul@327 144
        periods = dtstart and dtend and period_overlaps(freebusy, (dtstart, dtend), True) or freebusy
paul@327 145
paul@327 146
        # Write the limits of the resource.
paul@327 147
paul@327 148
        rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, periods[0][0]))
paul@327 149
        rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, periods[-1][1]))
paul@327 150
paul@344 151
        for start, end, uid, transp, recurrenceid in periods:
paul@222 152
            if transp == "OPAQUE":
paul@222 153
                rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join([start, end])))
paul@222 154
paul@222 155
    return ("VFREEBUSY", {}, record)
paul@222 156
paul@213 157
def parse_object(f, encoding, objtype=None):
paul@213 158
paul@213 159
    """
paul@213 160
    Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is
paul@213 161
    given, only objects of that type will be returned. Otherwise, the root of
paul@213 162
    the content will be returned as a dictionary with a single key indicating
paul@213 163
    the object type.
paul@213 164
paul@213 165
    Return None if the content was not readable or suitable.
paul@213 166
    """
paul@213 167
paul@213 168
    try:
paul@213 169
        try:
paul@213 170
            doctype, attrs, elements = obj = parse(f, encoding=encoding)
paul@213 171
            if objtype and doctype == objtype:
paul@213 172
                return to_dict(obj)[objtype][0]
paul@213 173
            elif not objtype:
paul@213 174
                return to_dict(obj)
paul@213 175
        finally:
paul@213 176
            f.close()
paul@213 177
paul@213 178
    # NOTE: Handle parse errors properly.
paul@213 179
paul@213 180
    except (ParseError, ValueError):
paul@213 181
        pass
paul@213 182
paul@213 183
    return None
paul@213 184
paul@213 185
def to_part(method, calendar):
paul@213 186
paul@213 187
    """
paul@213 188
    Write using the given 'method', the 'calendar' details to a MIME
paul@213 189
    text/calendar part.
paul@213 190
    """
paul@213 191
paul@213 192
    encoding = "utf-8"
paul@213 193
    out = StringIO()
paul@213 194
    try:
paul@213 195
        to_stream(out, make_calendar(calendar, method), encoding)
paul@213 196
        part = MIMEText(out.getvalue(), "calendar", encoding)
paul@213 197
        part.set_param("method", method)
paul@213 198
        return part
paul@213 199
paul@213 200
    finally:
paul@213 201
        out.close()
paul@213 202
paul@213 203
def to_stream(out, fragment, encoding="utf-8"):
paul@213 204
    iterwrite(out, encoding=encoding).append(fragment)
paul@213 205
paul@213 206
# Structure access functions.
paul@213 207
paul@213 208
def get_items(d, name, all=True):
paul@213 209
paul@213 210
    """
paul@213 211
    Get all items from 'd' for the given 'name', returning single items if
paul@213 212
    'all' is specified and set to a false value and if only one value is
paul@213 213
    present for the name. Return None if no items are found for the name or if
paul@213 214
    many items are found but 'all' is set to a false value.
paul@213 215
    """
paul@213 216
paul@213 217
    if d.has_key(name):
paul@213 218
        values = d[name]
paul@213 219
        if all:
paul@213 220
            return values
paul@213 221
        elif len(values) == 1:
paul@213 222
            return values[0]
paul@213 223
        else:
paul@213 224
            return None
paul@213 225
    else:
paul@213 226
        return None
paul@213 227
paul@213 228
def get_item(d, name):
paul@213 229
    return get_items(d, name, False)
paul@213 230
paul@213 231
def get_value_map(d, name):
paul@213 232
paul@213 233
    """
paul@213 234
    Return a dictionary for all items in 'd' having the given 'name'. The
paul@213 235
    dictionary will map values for the name to any attributes or qualifiers
paul@213 236
    that may have been present.
paul@213 237
    """
paul@213 238
paul@213 239
    items = get_items(d, name)
paul@213 240
    if items:
paul@213 241
        return dict(items)
paul@213 242
    else:
paul@213 243
        return {}
paul@213 244
paul@213 245
def get_values(d, name, all=True):
paul@213 246
    if d.has_key(name):
paul@213 247
        values = d[name]
paul@213 248
        if not all and len(values) == 1:
paul@213 249
            return values[0][0]
paul@213 250
        else:
paul@213 251
            return map(lambda x: x[0], values)
paul@213 252
    else:
paul@213 253
        return None
paul@213 254
paul@213 255
def get_value(d, name):
paul@213 256
    return get_values(d, name, False)
paul@213 257
paul@352 258
def get_item_datetime_items(d, name):
paul@352 259
paul@352 260
    """
paul@352 261
    Return datetime items from 'd' having the given 'name', where a single item
paul@352 262
    yields potentially many datetime values, each employing the attributes given
paul@352 263
    for the principal item.
paul@352 264
    """
paul@352 265
paul@352 266
    item = get_item(d, name)
paul@352 267
    if item:
paul@352 268
        values, attr = item
paul@352 269
        if not isinstance(values, list):
paul@352 270
            values = [values]
paul@352 271
        return [(get_datetime(value, attr), attr) for value in values]
paul@352 272
    else:
paul@352 273
        return None
paul@352 274
paul@213 275
def get_utc_datetime(d, name):
paul@348 276
    t = get_datetime_item(d, name)
paul@348 277
    if not t:
paul@348 278
        return None
paul@348 279
    else:
paul@348 280
        dt, attr = t
paul@348 281
        return to_utc_datetime(dt)
paul@289 282
paul@289 283
def get_datetime_item(d, name):
paul@348 284
    t = get_item(d, name)
paul@348 285
    if not t:
paul@348 286
        return None
paul@348 287
    else:
paul@348 288
        value, attr = t
paul@348 289
        return get_datetime(value, attr), attr
paul@213 290
paul@213 291
def get_addresses(values):
paul@213 292
    return [address for name, address in email.utils.getaddresses(values)]
paul@213 293
paul@213 294
def get_address(value):
paul@333 295
    value = value.lower()
paul@333 296
    return value.startswith("mailto:") and value[7:] or value
paul@213 297
paul@213 298
def get_uri(value):
paul@213 299
    return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower()
paul@213 300
paul@309 301
uri_value = get_uri
paul@309 302
paul@309 303
def uri_values(values):
paul@309 304
    return map(get_uri, values)
paul@309 305
paul@213 306
def uri_dict(d):
paul@213 307
    return dict([(get_uri(key), value) for key, value in d.items()])
paul@213 308
paul@213 309
def uri_item(item):
paul@213 310
    return get_uri(item[0]), item[1]
paul@213 311
paul@213 312
def uri_items(items):
paul@213 313
    return [(get_uri(value), attr) for value, attr in items]
paul@213 314
paul@220 315
# Operations on structure data.
paul@220 316
paul@220 317
def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set):
paul@220 318
paul@220 319
    """
paul@220 320
    Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and
paul@220 321
    'new_dtstamp', and the 'partstat_set' indication, whether the object
paul@220 322
    providing the new information is really newer than the object providing the
paul@220 323
    old information.
paul@220 324
    """
paul@220 325
paul@220 326
    have_sequence = old_sequence is not None and new_sequence is not None
paul@220 327
    is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence)
paul@220 328
paul@220 329
    have_dtstamp = old_dtstamp and new_dtstamp
paul@220 330
    is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp
paul@220 331
paul@220 332
    is_old_sequence = have_sequence and (
paul@220 333
        int(new_sequence) < int(old_sequence) or
paul@220 334
        is_same_sequence and is_old_dtstamp
paul@220 335
        )
paul@220 336
paul@220 337
    return is_same_sequence and partstat_set or not is_old_sequence
paul@220 338
paul@256 339
# NOTE: Need to expose the 100 day window for recurring events in the
paul@256 340
# NOTE: configuration.
paul@256 341
paul@318 342
def get_periods(obj, tzid, window_size=100):
paul@256 343
paul@256 344
    """
paul@256 345
    Return periods for the given object 'obj', confining materialised periods
paul@256 346
    to the given 'window_size' in days starting from the present moment.
paul@256 347
    """
paul@256 348
paul@318 349
    rrule = obj.get_value("RRULE")
paul@318 350
paul@318 351
    # Use localised datetimes.
paul@318 352
paul@318 353
    dtstart, start_attr = obj.get_datetime_item("DTSTART")
paul@318 354
    dtend, end_attr = obj.get_datetime_item("DTEND")
paul@318 355
    tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid
paul@256 356
paul@256 357
    # NOTE: Need also DURATION support.
paul@256 358
paul@256 359
    duration = dtend - dtstart
paul@256 360
paul@352 361
    if not rrule:
paul@352 362
        periods = [(dtstart, dtend)]
paul@352 363
    else:
paul@352 364
        # Recurrence rules create multiple instances to be checked.
paul@352 365
        # Conflicts may only be assessed within a period defined by policy
paul@352 366
        # for the agent, with instances outside that period being considered
paul@352 367
        # unchecked.
paul@352 368
paul@352 369
        window_end = to_timezone(datetime.now(), tzid) + timedelta(window_size)
paul@256 370
paul@352 371
        selector = get_rule(dtstart, rrule)
paul@352 372
        parameters = get_parameters(rrule)
paul@352 373
        periods = []
paul@352 374
paul@352 375
        for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")):
paul@352 376
            start = to_timezone(datetime(*start), tzid)
paul@352 377
            end = start + duration
paul@352 378
            periods.append((start, end))
paul@352 379
paul@352 380
    # Add recurrence dates.
paul@256 381
paul@352 382
    periods = set(periods)
paul@352 383
    rdates = obj.get_item_datetimes("RDATE")
paul@352 384
paul@352 385
    if rdates:
paul@352 386
        for rdate in rdates:
paul@352 387
            periods.add((rdate, rdate + duration))
paul@352 388
paul@352 389
    # Exclude exception dates.
paul@352 390
paul@352 391
    exdates = obj.get_item_datetimes("EXDATE")
paul@256 392
paul@352 393
    if exdates:
paul@352 394
        for exdate in exdates:
paul@352 395
            period = (exdate, exdate + duration)
paul@352 396
            if period in periods:
paul@352 397
                periods.remove(period)
paul@256 398
paul@352 399
    # Return a sorted list of the periods.
paul@352 400
paul@352 401
    periods = list(periods)
paul@352 402
    periods.sort()
paul@256 403
    return periods
paul@256 404
paul@291 405
def get_periods_for_freebusy(obj, periods, tzid):
paul@291 406
paul@306 407
    """
paul@306 408
    Get free/busy-compliant periods employed by 'obj' from the given 'periods',
paul@306 409
    using the indicated 'tzid' to convert dates to datetimes.
paul@306 410
    """
paul@306 411
paul@291 412
    start, start_attr = obj.get_datetime_item("DTSTART")
paul@291 413
    end, end_attr = obj.get_datetime_item("DTEND")
paul@291 414
paul@291 415
    tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid
paul@291 416
paul@291 417
    l = []
paul@291 418
paul@291 419
    for start, end in periods:
paul@291 420
        start, end = get_freebusy_period(start, end, tzid)
paul@320 421
        start, end = [to_timezone(x, "UTC") for x in start, end]
paul@291 422
        l.append((format_datetime(start), format_datetime(end)))
paul@291 423
paul@291 424
    return l
paul@291 425
paul@213 426
# vim: tabstop=4 expandtab shiftwidth=4