imip-agent

Annotated imiptools/data.py

1355:6975cdaac4a4
2017-10-20 Paul Boddie Simplify the interface of the rule periods computation function.
paul@213 1
#!/usr/bin/env python
paul@213 2
paul@213 3
"""
paul@213 4
Interpretation of vCalendar content.
paul@213 5
paul@1230 6
Copyright (C) 2014, 2015, 2016, 2017 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@560 23
from datetime import date, datetime, timedelta
paul@213 24
from email.mime.text import MIMEText
paul@939 25
from imiptools.dates import format_datetime, get_datetime, \
paul@625 26
                            get_datetime_item as get_item_from_datetime, \
paul@625 27
                            get_datetime_tzid, \
paul@628 28
                            get_duration, get_period, get_period_item, \
paul@627 29
                            get_recurrence_start_point, \
paul@1204 30
                            get_time, get_timestamp, get_tzid, to_datetime, \
paul@1204 31
                            to_timezone, to_utc_datetime
paul@1230 32
from imiptools.freebusy import FreeBusyPeriod
paul@1230 33
from imiptools.period import Period, RecurringPeriod
paul@213 34
from vCalendar import iterwrite, parse, ParseError, to_dict, to_node
paul@256 35
from vRecurrence import get_parameters, get_rule
paul@213 36
import email.utils
paul@213 37
paul@213 38
try:
paul@213 39
    from cStringIO import StringIO
paul@213 40
except ImportError:
paul@213 41
    from StringIO import StringIO
paul@213 42
paul@213 43
class Object:
paul@213 44
paul@213 45
    "Access to calendar structures."
paul@213 46
paul@213 47
    def __init__(self, fragment):
paul@1123 48
paul@1123 49
        """
paul@1123 50
        Initialise the object with the given 'fragment'. This must be a
paul@1123 51
        dictionary mapping an object type (such as "VEVENT") to a tuple
paul@1123 52
        containing the object details and attributes, each being a dictionary
paul@1123 53
        itself.
paul@1123 54
paul@1123 55
        The result of parse_object can be processed to obtain a fragment by
paul@1123 56
        obtaining a collection of records for an object type. For example:
paul@1123 57
paul@1123 58
        l = parse_object(f, encoding, "VCALENDAR")
paul@1123 59
        events = l["VEVENT"]
paul@1123 60
        event = events[0]
paul@1123 61
paul@1123 62
        Then, the specific object must be presented as follows:
paul@1123 63
paul@1123 64
        object = Object({"VEVENT" : event})
paul@1204 65
paul@1204 66
        A convienience function is also provided to initialise objects:
paul@1204 67
paul@1204 68
        object = new_object("VEVENT")
paul@1123 69
        """
paul@1123 70
paul@213 71
        self.objtype, (self.details, self.attr) = fragment.items()[0]
paul@213 72
paul@535 73
    def get_uid(self):
paul@535 74
        return self.get_value("UID")
paul@535 75
paul@535 76
    def get_recurrenceid(self):
paul@563 77
paul@563 78
        """
paul@563 79
        Return the recurrence identifier, normalised to a UTC datetime if
paul@627 80
        specified as a datetime or date with accompanying time zone information,
paul@627 81
        maintained as a date or floating datetime otherwise. If no recurrence
paul@627 82
        identifier is present, None is returned.
paul@627 83
paul@627 84
        Note that this normalised form of the identifier may well not be the
paul@627 85
        same as the originally-specified identifier because that could have been
paul@627 86
        specified using an accompanying TZID attribute, whereas the normalised
paul@627 87
        form is effectively a converted datetime value.
paul@563 88
        """
paul@563 89
paul@627 90
        if not self.has_key("RECURRENCE-ID"):
paul@627 91
            return None
paul@627 92
        dt, attr = self.get_datetime_item("RECURRENCE-ID")
paul@628 93
paul@628 94
        # Coerce any date to a UTC datetime if TZID was specified.
paul@628 95
paul@627 96
        tzid = attr.get("TZID")
paul@627 97
        if tzid:
paul@627 98
            dt = to_timezone(to_datetime(dt, tzid), "UTC")
paul@627 99
        return format_datetime(dt)
paul@627 100
paul@627 101
    def get_recurrence_start_point(self, recurrenceid, tzid):
paul@627 102
paul@627 103
        """
paul@627 104
        Return the start point corresponding to the given 'recurrenceid', using
paul@627 105
        the fallback 'tzid' to define the specific point in time referenced by
paul@627 106
        the recurrence identifier if the identifier has a date representation.
paul@627 107
paul@627 108
        If 'recurrenceid' is given as None, this object's recurrence identifier
paul@627 109
        is used to obtain a start point, but if this object does not provide a
paul@627 110
        recurrence, None is returned.
paul@627 111
paul@627 112
        A start point is typically used to match free/busy periods which are
paul@627 113
        themselves defined in terms of UTC datetimes.
paul@627 114
        """
paul@627 115
paul@627 116
        recurrenceid = recurrenceid or self.get_recurrenceid()
paul@627 117
        if recurrenceid:
paul@627 118
            return get_recurrence_start_point(recurrenceid, tzid)
paul@627 119
        else:
paul@627 120
            return None
paul@535 121
paul@679 122
    def get_recurrence_start_points(self, recurrenceids, tzid):
paul@679 123
        return [self.get_recurrence_start_point(recurrenceid, tzid) for recurrenceid in recurrenceids]
paul@679 124
paul@535 125
    # Structure access.
paul@535 126
paul@1204 127
    def add(self, obj):
paul@1204 128
paul@1204 129
        "Add 'obj' to the structure."
paul@1204 130
paul@1204 131
        name = obj.objtype
paul@1204 132
        if not self.details.has_key(name):
paul@1204 133
            l = self.details[name] = []
paul@1204 134
        else:
paul@1204 135
            l = self.details[name]
paul@1204 136
        l.append((obj.details, obj.attr))
paul@1204 137
paul@524 138
    def copy(self):
paul@1204 139
        return Object(self.to_dict())
paul@524 140
paul@1336 141
    # Access to (value, attributes) items.
paul@1336 142
paul@213 143
    def get_items(self, name, all=True):
paul@213 144
        return get_items(self.details, name, all)
paul@213 145
paul@213 146
    def get_item(self, name):
paul@213 147
        return get_item(self.details, name)
paul@213 148
paul@1336 149
    # Access to mappings.
paul@1336 150
paul@213 151
    def get_value_map(self, name):
paul@213 152
        return get_value_map(self.details, name)
paul@213 153
paul@1336 154
    # Access to mapped values.
paul@1336 155
paul@213 156
    def get_values(self, name, all=True):
paul@213 157
        return get_values(self.details, name, all)
paul@213 158
paul@213 159
    def get_value(self, name):
paul@213 160
        return get_value(self.details, name)
paul@213 161
paul@1336 162
    # Convenience methods asserting URI values.
paul@1336 163
paul@1336 164
    def get_uri_items(self, name, all=True):
paul@1336 165
        return uri_items(self.get_items(name, all))
paul@1336 166
paul@1336 167
    def get_uri_item(self, name):
paul@1336 168
        return uri_item(self.get_item(name))
paul@1336 169
paul@1336 170
    def get_uri_map(self, name):
paul@1336 171
        return uri_dict(self.get_value_map(name))
paul@1336 172
paul@1336 173
    def get_uri_values(self, name):
paul@1336 174
        return uri_values(self.get_values(name))
paul@1336 175
paul@1336 176
    def get_uri_value(self, name):
paul@1336 177
        return uri_value(self.get_value(name))
paul@1336 178
paul@1336 179
    get_uri = get_uri_value
paul@1336 180
paul@1336 181
    # Access to details as temporal objects.
paul@1336 182
paul@506 183
    def get_utc_datetime(self, name, date_tzid=None):
paul@506 184
        return get_utc_datetime(self.details, name, date_tzid)
paul@213 185
paul@417 186
    def get_date_value_items(self, name, tzid=None):
paul@417 187
        return get_date_value_items(self.details, name, tzid)
paul@352 188
paul@878 189
    def get_date_value_item_periods(self, name, tzid=None):
paul@878 190
        return get_date_value_item_periods(self.details, name, self.get_main_period(tzid).get_duration(), tzid)
paul@878 191
paul@646 192
    def get_period_values(self, name, tzid=None):
paul@646 193
        return get_period_values(self.details, name, tzid)
paul@630 194
paul@318 195
    def get_datetime(self, name):
paul@567 196
        t = get_datetime_item(self.details, name)
paul@567 197
        if not t: return None
paul@567 198
        dt, attr = t
paul@318 199
        return dt
paul@318 200
paul@289 201
    def get_datetime_item(self, name):
paul@289 202
        return get_datetime_item(self.details, name)
paul@289 203
paul@392 204
    def get_duration(self, name):
paul@392 205
        return get_duration(self.get_value(name))
paul@392 206
paul@1174 207
    # Serialisation.
paul@1174 208
paul@1204 209
    def to_dict(self):
paul@1204 210
        return to_dict(self.to_node())
paul@1204 211
paul@213 212
    def to_node(self):
paul@213 213
        return to_node({self.objtype : [(self.details, self.attr)]})
paul@213 214
paul@1174 215
    def to_part(self, method, encoding="utf-8", line_length=None):
paul@1174 216
        return to_part(method, [self.to_node()], encoding, line_length)
paul@213 217
paul@1174 218
    def to_string(self, encoding="utf-8", line_length=None):
paul@1174 219
        return to_string(self.to_node(), encoding, line_length)
paul@1081 220
paul@213 221
    # Direct access to the structure.
paul@213 222
paul@392 223
    def has_key(self, name):
paul@392 224
        return self.details.has_key(name)
paul@392 225
paul@524 226
    def get(self, name):
paul@524 227
        return self.details.get(name)
paul@524 228
paul@734 229
    def keys(self):
paul@734 230
        return self.details.keys()
paul@734 231
paul@213 232
    def __getitem__(self, name):
paul@213 233
        return self.details[name]
paul@213 234
paul@213 235
    def __setitem__(self, name, value):
paul@213 236
        self.details[name] = value
paul@213 237
paul@213 238
    def __delitem__(self, name):
paul@213 239
        del self.details[name]
paul@213 240
paul@524 241
    def remove(self, name):
paul@524 242
        try:
paul@524 243
            del self[name]
paul@524 244
        except KeyError:
paul@524 245
            pass
paul@524 246
paul@524 247
    def remove_all(self, names):
paul@524 248
        for name in names:
paul@524 249
            self.remove(name)
paul@524 250
paul@734 251
    def preserve(self, names):
paul@734 252
        for name in self.keys():
paul@734 253
            if not name in names:
paul@734 254
                self.remove(name)
paul@734 255
paul@256 256
    # Computed results.
paul@256 257
paul@879 258
    def get_main_period(self, tzid=None):
paul@797 259
paul@797 260
        """
paul@797 261
        Return a period object corresponding to the main start-end period for
paul@797 262
        the object.
paul@797 263
        """
paul@797 264
paul@879 265
        (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_main_period_items()
paul@879 266
        tzid = tzid or get_tzid(dtstart_attr, dtend_attr)
paul@797 267
        return RecurringPeriod(dtstart, dtend, tzid, "DTSTART", dtstart_attr, dtend_attr)
paul@797 268
paul@879 269
    def get_main_period_items(self):
paul@650 270
paul@650 271
        """
paul@650 272
        Return two (value, attributes) items corresponding to the main start-end
paul@650 273
        period for the object.
paul@650 274
        """
paul@650 275
paul@650 276
        dtstart, dtstart_attr = self.get_datetime_item("DTSTART")
paul@650 277
paul@650 278
        if self.has_key("DTEND"):
paul@650 279
            dtend, dtend_attr = self.get_datetime_item("DTEND")
paul@650 280
        elif self.has_key("DURATION"):
paul@650 281
            duration = self.get_duration("DURATION")
paul@650 282
            dtend = dtstart + duration
paul@650 283
            dtend_attr = dtstart_attr
paul@650 284
        else:
paul@650 285
            dtend, dtend_attr = dtstart, dtstart_attr
paul@650 286
paul@650 287
        return (dtstart, dtstart_attr), (dtend, dtend_attr)
paul@650 288
paul@1238 289
    def get_periods(self, tzid, start=None, end=None, inclusive=False):
paul@620 290
paul@620 291
        """
paul@620 292
        Return periods defined by this object, employing the given 'tzid' where
paul@620 293
        no time zone information is defined, and limiting the collection to a
paul@1238 294
        window of time with the given 'start' and 'end'.
paul@630 295
paul@630 296
        If 'end' is omitted, only explicit recurrences and recurrences from
paul@630 297
        explicitly-terminated rules will be returned.
paul@1123 298
paul@1123 299
        If 'inclusive' is set to a true value, any period occurring at the 'end'
paul@1123 300
        will be included.
paul@1123 301
        """
paul@1123 302
paul@1238 303
        return get_periods(self, tzid, start, end, inclusive)
paul@1123 304
paul@1123 305
    def has_period(self, tzid, period):
paul@1123 306
paul@1123 307
        """
paul@1123 308
        Return whether this object, employing the given 'tzid' where no time
paul@1123 309
        zone information is defined, has the given 'period'.
paul@620 310
        """
paul@620 311
paul@1238 312
        return period in self.get_periods(tzid, end=period.get_start_point(), inclusive=True)
paul@1123 313
paul@1123 314
    def has_recurrence(self, tzid, recurrenceid):
paul@1123 315
paul@1123 316
        """
paul@1123 317
        Return whether this object, employing the given 'tzid' where no time
paul@1123 318
        zone information is defined, has the given 'recurrenceid'.
paul@1123 319
        """
paul@1123 320
paul@1123 321
        start_point = self.get_recurrence_start_point(recurrenceid, tzid)
paul@1238 322
        for p in self.get_periods(tzid, end=start_point, inclusive=True):
paul@1123 323
            if p.get_start_point() == start_point:
paul@1123 324
                return True
paul@1123 325
        return False
paul@360 326
paul@1238 327
    def get_active_periods(self, recurrenceids, tzid, start=None, end=None):
paul@630 328
paul@630 329
        """
paul@630 330
        Return all periods specified by this object that are not replaced by
paul@630 331
        those defined by 'recurrenceids', using 'tzid' as a fallback time zone
paul@1238 332
        to convert floating dates and datetimes, and using 'start' and 'end' to
paul@1238 333
        respectively indicate the start and end of the time window within which
paul@1238 334
        periods are considered.
paul@630 335
        """
paul@630 336
paul@630 337
        # Specific recurrences yield all specified periods.
paul@630 338
paul@1238 339
        periods = self.get_periods(tzid, start, end)
paul@630 340
paul@630 341
        if self.get_recurrenceid():
paul@630 342
            return periods
paul@630 343
paul@630 344
        # Parent objects need to have their periods tested against redefined
paul@630 345
        # recurrences.
paul@630 346
paul@630 347
        active = []
paul@630 348
paul@630 349
        for p in periods:
paul@630 350
paul@630 351
            # Subtract any recurrences from the free/busy details of a
paul@630 352
            # parent object.
paul@630 353
paul@648 354
            if not p.is_replaced(recurrenceids):
paul@630 355
                active.append(p)
paul@630 356
paul@630 357
        return active
paul@630 358
paul@648 359
    def get_freebusy_period(self, period, only_organiser=False):
paul@648 360
paul@648 361
        """
paul@648 362
        Return a free/busy period for the given 'period' provided by this
paul@648 363
        object, using the 'only_organiser' status to produce a suitable
paul@648 364
        transparency value.
paul@648 365
        """
paul@648 366
paul@648 367
        return FreeBusyPeriod(
paul@648 368
            period.get_start_point(),
paul@648 369
            period.get_end_point(),
paul@648 370
            self.get_value("UID"),
paul@648 371
            only_organiser and "ORG" or self.get_value("TRANSP") or "OPAQUE",
paul@648 372
            self.get_recurrenceid(),
paul@648 373
            self.get_value("SUMMARY"),
paul@1336 374
            self.get_uri("ORGANIZER")
paul@648 375
            )
paul@648 376
paul@648 377
    def get_participation_status(self, participant):
paul@648 378
paul@648 379
        """
paul@648 380
        Return the participation status of the given 'participant', with the
paul@648 381
        special value "ORG" indicating organiser-only participation.
paul@648 382
        """
paul@648 383
    
paul@1336 384
        attendees = self.get_uri_map("ATTENDEE")
paul@1336 385
        organiser = self.get_uri("ORGANIZER")
paul@648 386
paul@692 387
        attendee_attr = attendees.get(participant)
paul@692 388
        if attendee_attr:
paul@692 389
            return attendee_attr.get("PARTSTAT", "NEEDS-ACTION")
paul@692 390
        elif organiser == participant:
paul@692 391
            return "ORG"
paul@648 392
paul@648 393
        return None
paul@648 394
paul@648 395
    def get_participation(self, partstat, include_needs_action=False):
paul@648 396
paul@648 397
        """
paul@648 398
        Return whether 'partstat' indicates some kind of participation in an
paul@648 399
        event. If 'include_needs_action' is specified as a true value, events
paul@648 400
        not yet responded to will be treated as events with tentative
paul@648 401
        participation.
paul@648 402
        """
paul@648 403
paul@648 404
        return not partstat in ("DECLINED", "DELEGATED", "NEEDS-ACTION") or \
paul@648 405
               include_needs_action and partstat == "NEEDS-ACTION" or \
paul@648 406
               partstat == "ORG"
paul@648 407
paul@422 408
    def get_tzid(self):
paul@562 409
paul@562 410
        """
paul@562 411
        Return a time zone identifier used by the start or end datetimes,
paul@562 412
        potentially suitable for converting dates to datetimes.
paul@562 413
        """
paul@562 414
paul@560 415
        if not self.has_key("DTSTART"):
paul@560 416
            return None
paul@422 417
        dtstart, dtstart_attr = self.get_datetime_item("DTSTART")
paul@630 418
        if self.has_key("DTEND"):
paul@630 419
            dtend, dtend_attr = self.get_datetime_item("DTEND")
paul@630 420
        else:
paul@630 421
            dtend_attr = None
paul@422 422
        return get_tzid(dtstart_attr, dtend_attr)
paul@422 423
paul@619 424
    def is_shared(self):
paul@619 425
paul@619 426
        """
paul@619 427
        Return whether this object is shared based on the presence of a SEQUENCE
paul@619 428
        property.
paul@619 429
        """
paul@619 430
paul@619 431
        return self.get_value("SEQUENCE") is not None
paul@619 432
paul@650 433
    def possibly_active_from(self, dt, tzid):
paul@650 434
paul@650 435
        """
paul@650 436
        Return whether the object is possibly active from or after the given
paul@650 437
        datetime 'dt' using 'tzid' to convert any dates or floating datetimes.
paul@650 438
        """
paul@650 439
paul@650 440
        dt = to_datetime(dt, tzid)
paul@650 441
        periods = self.get_periods(tzid)
paul@650 442
paul@650 443
        for p in periods:
paul@650 444
            if p.get_end_point() > dt:
paul@650 445
                return True
paul@650 446
paul@672 447
        return self.possibly_recurring_indefinitely()
paul@672 448
paul@672 449
    def possibly_recurring_indefinitely(self):
paul@672 450
paul@672 451
        "Return whether this object may recur indefinitely."
paul@672 452
paul@650 453
        rrule = self.get_value("RRULE")
paul@650 454
        parameters = rrule and get_parameters(rrule)
paul@650 455
        until = parameters and parameters.get("UNTIL")
paul@651 456
        count = parameters and parameters.get("COUNT")
paul@650 457
paul@672 458
        # Non-recurring periods or constrained recurrences.
paul@651 459
paul@651 460
        if not rrule or until or count:
paul@650 461
            return False
paul@651 462
paul@672 463
        # Unconstrained recurring periods will always lie beyond any specified
paul@651 464
        # datetime.
paul@651 465
paul@651 466
        else:
paul@650 467
            return True
paul@650 468
paul@627 469
    # Modification methods.
paul@627 470
paul@627 471
    def set_datetime(self, name, dt, tzid=None):
paul@627 472
paul@627 473
        """
paul@627 474
        Set a datetime for property 'name' using 'dt' and the optional fallback
paul@627 475
        'tzid', returning whether an update has occurred.
paul@627 476
        """
paul@627 477
paul@627 478
        if dt:
paul@627 479
            old_value = self.get_value(name)
paul@627 480
            self[name] = [get_item_from_datetime(dt, tzid)]
paul@627 481
            return format_datetime(dt) != old_value
paul@627 482
paul@627 483
        return False
paul@627 484
paul@627 485
    def set_period(self, period):
paul@627 486
paul@627 487
        "Set the given 'period' as the main start and end."
paul@627 488
paul@627 489
        result = self.set_datetime("DTSTART", period.get_start())
paul@627 490
        result = self.set_datetime("DTEND", period.get_end()) or result
paul@661 491
        if self.has_key("DURATION"):
paul@661 492
            del self["DURATION"]
paul@661 493
paul@627 494
        return result
paul@627 495
paul@627 496
    def set_periods(self, periods):
paul@627 497
paul@627 498
        """
paul@627 499
        Set the given 'periods' as recurrence date properties, replacing the
paul@627 500
        previous RDATE properties and ignoring any RRULE properties.
paul@627 501
        """
paul@627 502
paul@880 503
        old_values = set(self.get_date_value_item_periods("RDATE") or [])
paul@627 504
        new_rdates = []
paul@627 505
paul@627 506
        if self.has_key("RDATE"):
paul@627 507
            del self["RDATE"]
paul@627 508
paul@812 509
        main_changed = False
paul@812 510
paul@627 511
        for p in periods:
paul@879 512
            if p.origin == "RDATE" and p != self.get_main_period():
paul@627 513
                new_rdates.append(get_period_item(p.get_start(), p.get_end()))
paul@812 514
            elif p.origin == "DTSTART":
paul@812 515
                main_changed = self.set_period(p)
paul@627 516
paul@661 517
        if new_rdates:
paul@661 518
            self["RDATE"] = new_rdates
paul@661 519
paul@880 520
        return main_changed or old_values != set(self.get_date_value_item_periods("RDATE") or [])
paul@661 521
paul@872 522
    def set_rule(self, rule):
paul@872 523
paul@872 524
        """
paul@872 525
        Set the given 'rule' in this object, replacing the previous RRULE
paul@872 526
        property, returning whether the object has changed. The provided 'rule'
paul@872 527
        must be an item.
paul@872 528
        """
paul@872 529
paul@872 530
        if not rule:
paul@872 531
            return False
paul@872 532
paul@872 533
        old_rrule = self.get_item("RRULE")
paul@872 534
        self["RRULE"] = [rule]
paul@872 535
        return old_rrule != rule
paul@872 536
paul@872 537
    def set_exceptions(self, exceptions):
paul@872 538
paul@872 539
        """
paul@872 540
        Set the given 'exceptions' in this object, replacing the previous EXDATE
paul@872 541
        properties, returning whether the object has changed. The provided
paul@872 542
        'exceptions' must be a collection of items.
paul@872 543
        """
paul@872 544
paul@880 545
        old_exdates = set(self.get_date_value_item_periods("EXDATE") or [])
paul@872 546
        if exceptions:
paul@872 547
            self["EXDATE"] = exceptions
paul@880 548
            return old_exdates != set(self.get_date_value_item_periods("EXDATE") or [])
paul@872 549
        elif old_exdates:
paul@872 550
            del self["EXDATE"]
paul@872 551
            return True
paul@872 552
        else:
paul@872 553
            return False
paul@872 554
paul@809 555
    def update_dtstamp(self):
paul@809 556
paul@809 557
        "Update the DTSTAMP in the object."
paul@809 558
paul@809 559
        dtstamp = self.get_utc_datetime("DTSTAMP")
paul@809 560
        utcnow = get_time()
paul@809 561
        dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow)
paul@809 562
        self["DTSTAMP"] = [(dtstamp, {})]
paul@809 563
        return dtstamp
paul@809 564
paul@809 565
    def update_sequence(self, increment=False):
paul@809 566
paul@809 567
        "Set or update the SEQUENCE in the object."
paul@809 568
paul@809 569
        sequence = self.get_value("SEQUENCE") or "0"
paul@809 570
        self["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]
paul@809 571
        return sequence
paul@809 572
paul@878 573
    def update_exceptions(self, excluded, asserted):
paul@784 574
paul@784 575
        """
paul@784 576
        Update the exceptions to any rule by applying the list of 'excluded'
paul@878 577
        periods. Where 'asserted' periods are provided, exceptions will be
paul@878 578
        removed corresponding to those periods.
paul@784 579
        """
paul@784 580
paul@885 581
        old_exdates = self.get_date_value_item_periods("EXDATE") or []
paul@878 582
        new_exdates = set(old_exdates)
paul@878 583
        new_exdates.update(excluded)
paul@878 584
        new_exdates.difference_update(asserted)
paul@784 585
paul@885 586
        if not new_exdates and self.has_key("EXDATE"):
paul@878 587
            del self["EXDATE"]
paul@878 588
        else:
paul@784 589
            self["EXDATE"] = []
paul@878 590
            for p in new_exdates:
paul@878 591
                self["EXDATE"].append(get_period_item(p.get_start(), p.get_end()))
paul@784 592
paul@878 593
        return set(old_exdates) != new_exdates
paul@784 594
paul@669 595
    def correct_object(self, tzid, permitted_values):
paul@661 596
paul@939 597
        """
paul@939 598
        Correct the object's period details using the given 'tzid' and
paul@939 599
        'permitted_values'.
paul@939 600
        """
paul@661 601
paul@661 602
        corrected = set()
paul@661 603
        rdates = []
paul@661 604
paul@661 605
        for period in self.get_periods(tzid):
paul@939 606
            corrected_period = period.get_corrected(permitted_values)
paul@627 607
paul@939 608
            if corrected_period is period:
paul@661 609
                if period.origin == "RDATE":
paul@661 610
                    rdates.append(period)
paul@661 611
                continue
paul@661 612
paul@661 613
            if period.origin == "DTSTART":
paul@939 614
                self.set_period(corrected_period)
paul@661 615
                corrected.add("DTSTART")
paul@661 616
            elif period.origin == "RDATE":
paul@939 617
                rdates.append(corrected_period)
paul@661 618
                corrected.add("RDATE")
paul@661 619
paul@661 620
        if "RDATE" in corrected:
paul@661 621
            self.set_periods(rdates)
paul@661 622
paul@661 623
        return corrected
paul@627 624
paul@213 625
# Construction and serialisation.
paul@213 626
paul@213 627
def make_calendar(nodes, method=None):
paul@213 628
paul@213 629
    """
paul@213 630
    Return a complete calendar node wrapping the given 'nodes' and employing the
paul@213 631
    given 'method', if indicated.
paul@213 632
    """
paul@213 633
paul@213 634
    return ("VCALENDAR", {},
paul@213 635
            (method and [("METHOD", {}, method)] or []) +
paul@213 636
            [("VERSION", {}, "2.0")] +
paul@213 637
            nodes
paul@213 638
           )
paul@213 639
paul@327 640
def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None,
paul@562 641
                  attendee_attr=None, period=None):
paul@222 642
    
paul@222 643
    """
paul@222 644
    Return a calendar node defining the free/busy details described in the given
paul@292 645
    'freebusy' list, employing the given 'uid', for the given 'organiser' and
paul@292 646
    optional 'organiser_attr', with the optional 'attendee' providing recipient
paul@292 647
    details together with the optional 'attendee_attr'.
paul@327 648
paul@562 649
    The result will be constrained to the 'period' if specified.
paul@222 650
    """
paul@222 651
    
paul@222 652
    record = []
paul@222 653
    rwrite = record.append
paul@222 654
    
paul@292 655
    rwrite(("ORGANIZER", organiser_attr or {}, organiser))
paul@222 656
paul@222 657
    if attendee:
paul@292 658
        rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 
paul@222 659
paul@222 660
    rwrite(("UID", {}, uid))
paul@222 661
paul@222 662
    if freebusy:
paul@327 663
paul@327 664
        # Get a constrained view if start and end limits are specified.
paul@327 665
paul@563 666
        if period:
paul@1189 667
            periods = freebusy.get_overlapping([period])
paul@563 668
        else:
paul@563 669
            periods = freebusy
paul@327 670
paul@327 671
        # Write the limits of the resource.
paul@327 672
paul@563 673
        if periods:
paul@563 674
            rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point())))
paul@563 675
            rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point())))
paul@563 676
        else:
paul@563 677
            rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point())))
paul@563 678
            rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point())))
paul@327 679
paul@458 680
        for p in periods:
paul@458 681
            if p.transp == "OPAQUE":
paul@529 682
                rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join(
paul@562 683
                    map(format_datetime, [p.get_start_point(), p.get_end_point()])
paul@529 684
                    )))
paul@222 685
paul@222 686
    return ("VFREEBUSY", {}, record)
paul@222 687
paul@1269 688
def parse_calendar(f, encoding):
paul@1269 689
paul@1269 690
    """
paul@1269 691
    Parse the iTIP content from 'f' having the given 'encoding'. Return a
paul@1269 692
    mapping from object types to collections of calendar objects.
paul@1269 693
    """
paul@1269 694
paul@1269 695
    cal = parse_object(f, encoding, "VCALENDAR")
paul@1269 696
    d = {}
paul@1269 697
paul@1269 698
    for objtype, values in cal.items():
paul@1269 699
        d[objtype] = l = []
paul@1269 700
        for value in values:
paul@1269 701
            l.append(Object({objtype : value}))
paul@1269 702
paul@1269 703
    return d
paul@1269 704
paul@213 705
def parse_object(f, encoding, objtype=None):
paul@213 706
paul@213 707
    """
paul@213 708
    Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is
paul@213 709
    given, only objects of that type will be returned. Otherwise, the root of
paul@213 710
    the content will be returned as a dictionary with a single key indicating
paul@213 711
    the object type.
paul@213 712
paul@213 713
    Return None if the content was not readable or suitable.
paul@213 714
    """
paul@213 715
paul@213 716
    try:
paul@213 717
        try:
paul@213 718
            doctype, attrs, elements = obj = parse(f, encoding=encoding)
paul@213 719
            if objtype and doctype == objtype:
paul@213 720
                return to_dict(obj)[objtype][0]
paul@213 721
            elif not objtype:
paul@213 722
                return to_dict(obj)
paul@213 723
        finally:
paul@213 724
            f.close()
paul@213 725
paul@213 726
    # NOTE: Handle parse errors properly.
paul@213 727
paul@213 728
    except (ParseError, ValueError):
paul@213 729
        pass
paul@213 730
paul@213 731
    return None
paul@213 732
paul@1072 733
def parse_string(s, encoding, objtype=None):
paul@1072 734
paul@1072 735
    """
paul@1072 736
    Parse the iTIP content from 's' having the given 'encoding'. If 'objtype' is
paul@1072 737
    given, only objects of that type will be returned. Otherwise, the root of
paul@1072 738
    the content will be returned as a dictionary with a single key indicating
paul@1072 739
    the object type.
paul@1072 740
paul@1072 741
    Return None if the content was not readable or suitable.
paul@1072 742
    """
paul@1072 743
paul@1072 744
    return parse_object(StringIO(s), encoding, objtype)
paul@1072 745
paul@1174 746
def to_part(method, fragments, encoding="utf-8", line_length=None):
paul@213 747
paul@213 748
    """
paul@1174 749
    Write using the given 'method', the given 'fragments' to a MIME
paul@213 750
    text/calendar part.
paul@213 751
    """
paul@213 752
paul@213 753
    out = StringIO()
paul@213 754
    try:
paul@1174 755
        to_stream(out, make_calendar(fragments, method), encoding, line_length)
paul@213 756
        part = MIMEText(out.getvalue(), "calendar", encoding)
paul@213 757
        part.set_param("method", method)
paul@213 758
        return part
paul@213 759
paul@213 760
    finally:
paul@213 761
        out.close()
paul@213 762
paul@1174 763
def to_stream(out, fragment, encoding="utf-8", line_length=None):
paul@1084 764
paul@1084 765
    "Write to the 'out' stream the given 'fragment'."
paul@1084 766
paul@1174 767
    iterwrite(out, encoding=encoding, line_length=line_length).append(fragment)
paul@213 768
paul@1174 769
def to_string(fragment, encoding="utf-8", line_length=None):
paul@1084 770
paul@1084 771
    "Return a string encoding the given 'fragment'."
paul@1084 772
paul@1072 773
    out = StringIO()
paul@1072 774
    try:
paul@1174 775
        to_stream(out, fragment, encoding, line_length)
paul@1072 776
        return out.getvalue()
paul@1072 777
paul@1072 778
    finally:
paul@1072 779
        out.close()
paul@1072 780
paul@1204 781
def new_object(object_type):
paul@1204 782
paul@1204 783
    "Make a new object of the given 'object_type'."
paul@1204 784
paul@1204 785
    return Object({object_type : ({}, {})})
paul@1204 786
paul@1204 787
def make_uid(user):
paul@1204 788
paul@1204 789
    "Return a unique identifier for a new object by the given 'user'."
paul@1204 790
paul@1204 791
    utcnow = get_timestamp()
paul@1204 792
    return "imip-agent-%s-%s" % (utcnow, get_address(user))
paul@1204 793
paul@213 794
# Structure access functions.
paul@213 795
paul@213 796
def get_items(d, name, all=True):
paul@213 797
paul@213 798
    """
paul@213 799
    Get all items from 'd' for the given 'name', returning single items if
paul@213 800
    'all' is specified and set to a false value and if only one value is
paul@213 801
    present for the name. Return None if no items are found for the name or if
paul@213 802
    many items are found but 'all' is set to a false value.
paul@213 803
    """
paul@213 804
paul@213 805
    if d.has_key(name):
paul@712 806
        items = [(value or None, attr) for value, attr in d[name]]
paul@213 807
        if all:
paul@462 808
            return items
paul@462 809
        elif len(items) == 1:
paul@462 810
            return items[0]
paul@213 811
        else:
paul@213 812
            return None
paul@213 813
    else:
paul@213 814
        return None
paul@213 815
paul@213 816
def get_item(d, name):
paul@213 817
    return get_items(d, name, False)
paul@213 818
paul@213 819
def get_value_map(d, name):
paul@213 820
paul@213 821
    """
paul@213 822
    Return a dictionary for all items in 'd' having the given 'name'. The
paul@213 823
    dictionary will map values for the name to any attributes or qualifiers
paul@213 824
    that may have been present.
paul@213 825
    """
paul@213 826
paul@213 827
    items = get_items(d, name)
paul@213 828
    if items:
paul@213 829
        return dict(items)
paul@213 830
    else:
paul@213 831
        return {}
paul@213 832
paul@462 833
def values_from_items(items):
paul@462 834
    return map(lambda x: x[0], items)
paul@462 835
paul@213 836
def get_values(d, name, all=True):
paul@213 837
    if d.has_key(name):
paul@462 838
        items = d[name]
paul@462 839
        if not all and len(items) == 1:
paul@462 840
            return items[0][0]
paul@213 841
        else:
paul@462 842
            return values_from_items(items)
paul@213 843
    else:
paul@213 844
        return None
paul@213 845
paul@213 846
def get_value(d, name):
paul@213 847
    return get_values(d, name, False)
paul@213 848
paul@417 849
def get_date_value_items(d, name, tzid=None):
paul@352 850
paul@352 851
    """
paul@389 852
    Obtain items from 'd' having the given 'name', where a single item yields
paul@389 853
    potentially many values. Return a list of tuples of the form (value,
paul@389 854
    attributes) where the attributes have been given for the property in 'd'.
paul@352 855
    """
paul@352 856
paul@403 857
    items = get_items(d, name)
paul@403 858
    if items:
paul@403 859
        all_items = []
paul@403 860
        for item in items:
paul@403 861
            values, attr = item
paul@417 862
            if not attr.has_key("TZID") and tzid:
paul@417 863
                attr["TZID"] = tzid
paul@403 864
            if not isinstance(values, list):
paul@403 865
                values = [values]
paul@403 866
            for value in values:
paul@403 867
                all_items.append((get_datetime(value, attr) or get_period(value, attr), attr))
paul@403 868
        return all_items
paul@352 869
    else:
paul@352 870
        return None
paul@352 871
paul@878 872
def get_date_value_item_periods(d, name, duration, tzid=None):
paul@878 873
paul@878 874
    """
paul@878 875
    Obtain items from 'd' having the given 'name', where a single item yields
paul@878 876
    potentially many values. The 'duration' must be provided to define the
paul@878 877
    length of periods having only a start datetime. Return a list of periods
paul@878 878
    corresponding to the property in 'd'.
paul@878 879
    """
paul@878 880
paul@878 881
    items = get_date_value_items(d, name, tzid)
paul@878 882
    if not items:
paul@878 883
        return items
paul@878 884
paul@878 885
    periods = []
paul@878 886
paul@878 887
    for value, attr in items:
paul@878 888
        if isinstance(value, tuple):
paul@878 889
            periods.append(RecurringPeriod(value[0], value[1], tzid, name, attr))
paul@878 890
        else:
paul@878 891
            periods.append(RecurringPeriod(value, value + duration, tzid, name, attr))
paul@878 892
paul@878 893
    return periods
paul@878 894
paul@646 895
def get_period_values(d, name, tzid=None):
paul@630 896
paul@630 897
    """
paul@630 898
    Return period values from 'd' for the given property 'name', using 'tzid'
paul@646 899
    where specified to indicate the time zone.
paul@630 900
    """
paul@630 901
paul@630 902
    values = []
paul@630 903
    for value, attr in get_items(d, name) or []:
paul@630 904
        if not attr.has_key("TZID") and tzid:
paul@630 905
            attr["TZID"] = tzid
paul@630 906
        start, end = get_period(value, attr)
paul@646 907
        values.append(Period(start, end, tzid=tzid))
paul@630 908
    return values
paul@630 909
paul@506 910
def get_utc_datetime(d, name, date_tzid=None):
paul@506 911
paul@506 912
    """
paul@506 913
    Return the value provided by 'd' for 'name' as a datetime in the UTC zone
paul@506 914
    or as a date, converting any date to a datetime if 'date_tzid' is specified.
paul@720 915
    If no datetime or date is available, None is returned.
paul@506 916
    """
paul@506 917
paul@348 918
    t = get_datetime_item(d, name)
paul@348 919
    if not t:
paul@348 920
        return None
paul@348 921
    else:
paul@348 922
        dt, attr = t
paul@720 923
        return dt is not None and to_utc_datetime(dt, date_tzid) or None
paul@289 924
paul@289 925
def get_datetime_item(d, name):
paul@562 926
paul@562 927
    """
paul@562 928
    Return the value provided by 'd' for 'name' as a datetime or as a date,
paul@562 929
    together with the attributes describing it. Return None if no value exists
paul@562 930
    for 'name' in 'd'.
paul@562 931
    """
paul@562 932
paul@348 933
    t = get_item(d, name)
paul@348 934
    if not t:
paul@348 935
        return None
paul@348 936
    else:
paul@348 937
        value, attr = t
paul@613 938
        dt = get_datetime(value, attr)
paul@616 939
        tzid = get_datetime_tzid(dt)
paul@616 940
        if tzid:
paul@616 941
            attr["TZID"] = tzid
paul@613 942
        return dt, attr
paul@213 943
paul@528 944
# Conversion functions.
paul@528 945
paul@792 946
def get_address_parts(values):
paul@792 947
paul@792 948
    "Return name and address tuples for each of the given 'values'."
paul@792 949
paul@792 950
    l = []
paul@792 951
    for name, address in values and email.utils.getaddresses(values) or []:
paul@792 952
        if is_mailto_uri(name):
paul@792 953
            name = name[7:] # strip "mailto:"
paul@792 954
        l.append((name, address))
paul@792 955
    return l
paul@792 956
paul@213 957
def get_addresses(values):
paul@790 958
paul@790 959
    """
paul@790 960
    Return only addresses from the given 'values' which may be of the form
paul@790 961
    "Common Name <recipient@domain>", with the latter part being the address
paul@790 962
    itself.
paul@790 963
    """
paul@790 964
paul@792 965
    return [address for name, address in get_address_parts(values)]
paul@213 966
paul@213 967
def get_address(value):
paul@790 968
paul@790 969
    "Return an e-mail address from the given 'value'."
paul@790 970
paul@712 971
    if not value: return None
paul@792 972
    return get_addresses([value])[0]
paul@792 973
paul@792 974
def get_verbose_address(value, attr=None):
paul@792 975
paul@792 976
    """
paul@792 977
    Return a verbose e-mail address featuring any name from the given 'value'
paul@792 978
    and any accompanying 'attr' dictionary.
paul@792 979
    """
paul@792 980
paul@810 981
    l = get_address_parts([value])
paul@810 982
    if not l:
paul@810 983
        return value
paul@810 984
    name, address = l[0]
paul@792 985
    if not name:
paul@792 986
        name = attr and attr.get("CN")
paul@792 987
    if name and address:
paul@792 988
        return "%s <%s>" % (name, address)
paul@792 989
    else:
paul@792 990
        return address
paul@792 991
paul@792 992
def is_mailto_uri(value):
paul@1251 993
paul@1251 994
    """
paul@1251 995
    Return whether 'value' is a mailto: URI, with the protocol potentially being
paul@1251 996
    in upper case.
paul@1251 997
    """
paul@1251 998
paul@792 999
    return value.lower().startswith("mailto:")
paul@213 1000
paul@213 1001
def get_uri(value):
paul@790 1002
paul@790 1003
    "Return a URI for the given 'value'."
paul@790 1004
paul@712 1005
    if not value: return None
paul@1251 1006
paul@1251 1007
    # Normalise to "mailto:" or return other URI form.
paul@1251 1008
paul@792 1009
    return is_mailto_uri(value) and ("mailto:%s" % value[7:]) or \
paul@712 1010
           ":" in value and value or \
paul@790 1011
           "mailto:%s" % get_address(value)
paul@213 1012
paul@792 1013
def uri_parts(values):
paul@792 1014
paul@792 1015
    "Return any common name plus the URI for each of the given 'values'."
paul@792 1016
paul@792 1017
    return [(name, get_uri(address)) for name, address in get_address_parts(values)]
paul@792 1018
paul@309 1019
uri_value = get_uri
paul@309 1020
paul@309 1021
def uri_values(values):
paul@309 1022
    return map(get_uri, values)
paul@309 1023
paul@213 1024
def uri_dict(d):
paul@213 1025
    return dict([(get_uri(key), value) for key, value in d.items()])
paul@213 1026
paul@213 1027
def uri_item(item):
paul@213 1028
    return get_uri(item[0]), item[1]
paul@213 1029
paul@213 1030
def uri_items(items):
paul@213 1031
    return [(get_uri(value), attr) for value, attr in items]
paul@213 1032
paul@220 1033
# Operations on structure data.
paul@220 1034
paul@682 1035
def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp):
paul@220 1036
paul@220 1037
    """
paul@220 1038
    Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and
paul@682 1039
    'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object
paul@220 1040
    providing the new information is really newer than the object providing the
paul@220 1041
    old information.
paul@220 1042
    """
paul@220 1043
paul@220 1044
    have_sequence = old_sequence is not None and new_sequence is not None
paul@220 1045
    is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence)
paul@220 1046
paul@220 1047
    have_dtstamp = old_dtstamp and new_dtstamp
paul@220 1048
    is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp
paul@220 1049
paul@220 1050
    is_old_sequence = have_sequence and (
paul@220 1051
        int(new_sequence) < int(old_sequence) or
paul@220 1052
        is_same_sequence and is_old_dtstamp
paul@220 1053
        )
paul@220 1054
paul@682 1055
    return is_same_sequence and ignore_dtstamp or not is_old_sequence
paul@220 1056
paul@1176 1057
def check_delegation(attendee_map, attendee, attendee_attr):
paul@1176 1058
paul@1176 1059
    """
paul@1176 1060
    Using the 'attendee_map', check the attributes for the given 'attendee'
paul@1176 1061
    provided as 'attendee_attr', following the delegation chain back to the
paul@1177 1062
    delegators and forward again to yield the delegate identities in each
paul@1177 1063
    case. Pictorially...
paul@1177 1064
paul@1177 1065
    attendee -> DELEGATED-FROM -> delegator
paul@1177 1066
           ? <-  DELEGATED-TO  <---
paul@1177 1067
paul@1177 1068
    Return whether 'attendee' was identified as a delegate by providing the
paul@1177 1069
    identity of any delegators referencing the attendee.
paul@1176 1070
    """
paul@1176 1071
paul@1177 1072
    delegators = []
paul@1177 1073
paul@1176 1074
    # The recipient should have a reference to the delegator.
paul@1176 1075
paul@1176 1076
    delegated_from = attendee_attr and attendee_attr.get("DELEGATED-FROM")
paul@1177 1077
    if delegated_from:
paul@1177 1078
paul@1177 1079
        # Examine all delegators.
paul@1177 1080
paul@1177 1081
        for delegator in delegated_from:
paul@1177 1082
            delegator_attr = attendee_map.get(delegator)
paul@1176 1083
paul@1177 1084
            # The delegator should have a reference to the recipient.
paul@1176 1085
paul@1177 1086
            delegated_to = delegator_attr and delegator_attr.get("DELEGATED-TO")
paul@1177 1087
            if delegated_to and attendee in delegated_to:
paul@1177 1088
                delegators.append(delegator)
paul@1177 1089
paul@1177 1090
    return delegators
paul@1176 1091
paul@1354 1092
def make_rule_period(start, duration, attr, tzid):
paul@1353 1093
paul@1353 1094
    """
paul@1353 1095
    Make a period for the rule period starting at 'start' with the given
paul@1354 1096
    'duration' employing the given datetime 'attr' and 'tzid'.
paul@1353 1097
    """
paul@1353 1098
paul@1353 1099
    # Determine the resolution of the period.
paul@1353 1100
paul@1353 1101
    create = len(start) == 3 and date or datetime
paul@1353 1102
    start = to_timezone(create(*start), tzid)
paul@1353 1103
    end = start + duration
paul@1353 1104
paul@1353 1105
    # Create the period with accompanying metadata based on the main
paul@1353 1106
    # period and event details.
paul@1353 1107
paul@1353 1108
    return RecurringPeriod(start, end, tzid, "RRULE", attr)
paul@1353 1109
paul@1355 1110
def get_rule_periods(rrule, main_period, tzid, end, inclusive=False):
paul@1354 1111
paul@1354 1112
    """
paul@1355 1113
    Return periods for the given 'rrule', employing the 'main_period' and
paul@1355 1114
    'tzid'.
paul@1354 1115
paul@1354 1116
    The specified 'end' datetime indicates the end of the window for which
paul@1354 1117
    periods shall be computed.
paul@1354 1118
paul@1354 1119
    If 'inclusive' is set to a true value, any period occurring at the 'end'
paul@1354 1120
    will be included.
paul@1354 1121
    """
paul@1354 1122
paul@1355 1123
    start = main_period.get_start()
paul@1355 1124
    attr = main_period.get_start_attr()
paul@1355 1125
    duration = main_period.get_duration()
paul@1355 1126
paul@1354 1127
    parameters = rrule and get_parameters(rrule)
paul@1354 1128
    selector = get_rule(start, rrule)
paul@1354 1129
paul@1354 1130
    until = parameters.get("UNTIL")
paul@1354 1131
paul@1354 1132
    if until:
paul@1354 1133
        until_dt = to_timezone(get_datetime(until, attr), tzid)
paul@1354 1134
        end = end and min(until_dt, end) or until_dt
paul@1354 1135
        inclusive = True
paul@1354 1136
paul@1354 1137
    # Obtain period instances, starting from the main period. Since counting
paul@1354 1138
    # must start from the first period, filtering from a start date must be
paul@1354 1139
    # done after the instances have been obtained.
paul@1354 1140
paul@1354 1141
    periods = []
paul@1354 1142
paul@1354 1143
    for recurrence_start in selector.materialise(start, end,
paul@1354 1144
                                                 parameters.get("COUNT"),
paul@1354 1145
                                                 parameters.get("BYSETPOS"),
paul@1354 1146
                                                 inclusive):
paul@1354 1147
paul@1354 1148
        periods.append(make_rule_period(recurrence_start, duration, attr, tzid))
paul@1354 1149
paul@1354 1150
    return periods
paul@1354 1151
paul@1238 1152
def get_periods(obj, tzid, start=None, end=None, inclusive=False):
paul@256 1153
paul@256 1154
    """
paul@618 1155
    Return periods for the given object 'obj', employing the given 'tzid' where
paul@618 1156
    no time zone information is available (for whole day events, for example),
paul@1238 1157
    confining materialised periods to after the given 'start' datetime and
paul@1238 1158
    before the given 'end' datetime.
paul@618 1159
paul@630 1160
    If 'end' is omitted, only explicit recurrences and recurrences from
paul@630 1161
    explicitly-terminated rules will be returned.
paul@630 1162
paul@630 1163
    If 'inclusive' is set to a true value, any period occurring at the 'end'
paul@630 1164
    will be included.
paul@256 1165
    """
paul@256 1166
paul@318 1167
    rrule = obj.get_value("RRULE")
paul@636 1168
    parameters = rrule and get_parameters(rrule)
paul@318 1169
paul@318 1170
    # Use localised datetimes.
paul@318 1171
paul@797 1172
    main_period = obj.get_main_period(tzid)
paul@797 1173
paul@618 1174
    # Attempt to get time zone details from the object, using the supplied zone
paul@618 1175
    # only as a fallback.
paul@618 1176
paul@638 1177
    obj_tzid = obj.get_tzid()
paul@256 1178
paul@352 1179
    if not rrule:
paul@797 1180
        periods = [main_period]
paul@630 1181
paul@1354 1182
    # Recurrence rules create multiple instances to be checked.
paul@1354 1183
    # Conflicts may only be assessed within a period defined by policy
paul@1354 1184
    # for the agent, with instances outside that period being considered
paul@1354 1185
    # unchecked.
paul@1354 1186
paul@636 1187
    elif end or parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT"):
paul@630 1188
paul@1354 1189
        # Define a selection period with a start point. The end will be handled
paul@1354 1190
        # in the materialisation process.
paul@352 1191
paul@1354 1192
        selection_period = Period(start, None)
paul@352 1193
        periods = []
paul@352 1194
paul@1355 1195
        for period in get_rule_periods(rrule, main_period, obj_tzid or tzid,
paul@1355 1196
                                       end, inclusive):
paul@1238 1197
paul@1238 1198
            # Filter out periods before the start.
paul@1238 1199
paul@1238 1200
            if period.within(selection_period):
paul@1238 1201
                periods.append(period)
paul@352 1202
paul@635 1203
    else:
paul@635 1204
        periods = []
paul@635 1205
paul@352 1206
    # Add recurrence dates.
paul@256 1207
paul@1353 1208
    rdates = obj.get_date_value_item_periods("RDATE", obj_tzid or tzid)
paul@352 1209
    if rdates:
paul@878 1210
        periods += rdates
paul@424 1211
paul@424 1212
    # Return a sorted list of the periods.
paul@424 1213
paul@542 1214
    periods.sort()
paul@352 1215
paul@352 1216
    # Exclude exception dates.
paul@352 1217
paul@1353 1218
    exdates = obj.get_date_value_item_periods("EXDATE", obj_tzid or tzid)
paul@256 1219
paul@352 1220
    if exdates:
paul@878 1221
        for period in exdates:
paul@424 1222
            i = bisect_left(periods, period)
paul@458 1223
            while i < len(periods) and periods[i] == period:
paul@424 1224
                del periods[i]
paul@256 1225
paul@256 1226
    return periods
paul@256 1227
paul@606 1228
def get_sender_identities(mapping):
paul@606 1229
paul@606 1230
    """
paul@606 1231
    Return a mapping from actual senders to the identities for which they
paul@606 1232
    have provided data, extracting this information from the given
paul@1325 1233
    'mapping'. The SENT-BY attribute provides sender information in preference
paul@1325 1234
    to the property values given as the mapping keys.
paul@606 1235
    """
paul@606 1236
paul@606 1237
    senders = {}
paul@606 1238
paul@606 1239
    for value, attr in mapping.items():
paul@606 1240
        sent_by = attr.get("SENT-BY")
paul@606 1241
        if sent_by:
paul@606 1242
            sender = get_uri(sent_by)
paul@606 1243
        else:
paul@606 1244
            sender = value
paul@606 1245
paul@606 1246
        if not senders.has_key(sender):
paul@606 1247
            senders[sender] = []
paul@606 1248
paul@606 1249
        senders[sender].append(value)
paul@606 1250
paul@606 1251
    return senders
paul@606 1252
paul@1238 1253
def get_window_end(tzid, days=100, start=None):
paul@606 1254
paul@618 1255
    """
paul@618 1256
    Return a datetime in the time zone indicated by 'tzid' marking the end of a
paul@1238 1257
    window of the given number of 'days'. If 'start' is not indicated, the start
paul@1238 1258
    of the window will be the current moment.
paul@618 1259
    """
paul@618 1260
paul@1238 1261
    return to_timezone(start or datetime.now(), tzid) + timedelta(days)
paul@606 1262
paul@1326 1263
def update_attendees_with_delegates(stored_attendees, attendees):
paul@1326 1264
paul@1326 1265
    """
paul@1326 1266
    Update the 'stored_attendees' mapping with delegate information from the
paul@1326 1267
    given 'attendees' mapping.
paul@1326 1268
    """
paul@1326 1269
paul@1326 1270
    # Check for delegated attendees.
paul@1326 1271
paul@1326 1272
    for attendee, attendee_attr in attendees.items():
paul@1326 1273
paul@1326 1274
        # Identify delegates and check the delegation using the updated
paul@1326 1275
        # attendee information.
paul@1326 1276
paul@1326 1277
        if not stored_attendees.has_key(attendee) and \
paul@1326 1278
           attendee_attr.has_key("DELEGATED-FROM") and \
paul@1326 1279
           check_delegation(stored_attendees, attendee, attendee_attr):
paul@1326 1280
paul@1326 1281
            stored_attendees[attendee] = attendee_attr
paul@1326 1282
paul@213 1283
# vim: tabstop=4 expandtab shiftwidth=4