imip-agent

Annotated imiptools/data.py

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