imip-agent

Annotated imiptools/data.py

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