imip-agent

Annotated imiptools/data.py

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