imip-agent

Annotated imiptools/data.py

849:d3f276fb7b28
2015-10-16 Paul Boddie When handling COUNTER messages, only handle information from the sending attendee, do not reset participation status generally, and register the counter-proposal only for the sending attendee.
paul@213 1
#!/usr/bin/env python
paul@213 2
paul@213 3
"""
paul@213 4
Interpretation of vCalendar content.
paul@213 5
paul@213 6
Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>
paul@213 7
paul@213 8
This program is free software; you can redistribute it and/or modify it under
paul@213 9
the terms of the GNU General Public License as published by the Free Software
paul@213 10
Foundation; either version 3 of the License, or (at your option) any later
paul@213 11
version.
paul@213 12
paul@213 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@213 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@213 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@213 16
details.
paul@213 17
paul@213 18
You should have received a copy of the GNU General Public License along with
paul@213 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@213 20
"""
paul@213 21
paul@424 22
from bisect import bisect_left
paul@560 23
from datetime import date, datetime, timedelta
paul@213 24
from email.mime.text import MIMEText
paul@669 25
from imiptools.dates import check_permitted_values, correct_datetime, \
paul@661 26
                            format_datetime, get_datetime, \
paul@625 27
                            get_datetime_item as get_item_from_datetime, \
paul@625 28
                            get_datetime_tzid, \
paul@628 29
                            get_duration, get_period, get_period_item, \
paul@627 30
                            get_recurrence_start_point, \
paul@809 31
                            get_time, get_tzid, to_datetime, to_timezone, \
paul@809 32
                            to_utc_datetime
paul@648 33
from imiptools.period import FreeBusyPeriod, Period, RecurringPeriod, period_overlaps
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@213 48
        self.objtype, (self.details, self.attr) = fragment.items()[0]
paul@213 49
paul@535 50
    def get_uid(self):
paul@535 51
        return self.get_value("UID")
paul@535 52
paul@535 53
    def get_recurrenceid(self):
paul@563 54
paul@563 55
        """
paul@563 56
        Return the recurrence identifier, normalised to a UTC datetime if
paul@627 57
        specified as a datetime or date with accompanying time zone information,
paul@627 58
        maintained as a date or floating datetime otherwise. If no recurrence
paul@627 59
        identifier is present, None is returned.
paul@627 60
paul@627 61
        Note that this normalised form of the identifier may well not be the
paul@627 62
        same as the originally-specified identifier because that could have been
paul@627 63
        specified using an accompanying TZID attribute, whereas the normalised
paul@627 64
        form is effectively a converted datetime value.
paul@563 65
        """
paul@563 66
paul@627 67
        if not self.has_key("RECURRENCE-ID"):
paul@627 68
            return None
paul@627 69
        dt, attr = self.get_datetime_item("RECURRENCE-ID")
paul@628 70
paul@628 71
        # Coerce any date to a UTC datetime if TZID was specified.
paul@628 72
paul@627 73
        tzid = attr.get("TZID")
paul@627 74
        if tzid:
paul@627 75
            dt = to_timezone(to_datetime(dt, tzid), "UTC")
paul@627 76
        return format_datetime(dt)
paul@627 77
paul@627 78
    def get_recurrence_start_point(self, recurrenceid, tzid):
paul@627 79
paul@627 80
        """
paul@627 81
        Return the start point corresponding to the given 'recurrenceid', using
paul@627 82
        the fallback 'tzid' to define the specific point in time referenced by
paul@627 83
        the recurrence identifier if the identifier has a date representation.
paul@627 84
paul@627 85
        If 'recurrenceid' is given as None, this object's recurrence identifier
paul@627 86
        is used to obtain a start point, but if this object does not provide a
paul@627 87
        recurrence, None is returned.
paul@627 88
paul@627 89
        A start point is typically used to match free/busy periods which are
paul@627 90
        themselves defined in terms of UTC datetimes.
paul@627 91
        """
paul@627 92
paul@627 93
        recurrenceid = recurrenceid or self.get_recurrenceid()
paul@627 94
        if recurrenceid:
paul@627 95
            return get_recurrence_start_point(recurrenceid, tzid)
paul@627 96
        else:
paul@627 97
            return None
paul@535 98
paul@679 99
    def get_recurrence_start_points(self, recurrenceids, tzid):
paul@679 100
        return [self.get_recurrence_start_point(recurrenceid, tzid) for recurrenceid in recurrenceids]
paul@679 101
paul@535 102
    # Structure access.
paul@535 103
paul@524 104
    def copy(self):
paul@524 105
        return Object(to_dict(self.to_node()))
paul@524 106
paul@213 107
    def get_items(self, name, all=True):
paul@213 108
        return get_items(self.details, name, all)
paul@213 109
paul@213 110
    def get_item(self, name):
paul@213 111
        return get_item(self.details, name)
paul@213 112
paul@213 113
    def get_value_map(self, name):
paul@213 114
        return get_value_map(self.details, name)
paul@213 115
paul@213 116
    def get_values(self, name, all=True):
paul@213 117
        return get_values(self.details, name, all)
paul@213 118
paul@213 119
    def get_value(self, name):
paul@213 120
        return get_value(self.details, name)
paul@213 121
paul@506 122
    def get_utc_datetime(self, name, date_tzid=None):
paul@506 123
        return get_utc_datetime(self.details, name, date_tzid)
paul@213 124
paul@417 125
    def get_date_values(self, name, tzid=None):
paul@417 126
        items = get_date_value_items(self.details, name, tzid)
paul@389 127
        return items and [value for value, attr in items]
paul@352 128
paul@417 129
    def get_date_value_items(self, name, tzid=None):
paul@417 130
        return get_date_value_items(self.details, name, tzid)
paul@352 131
paul@646 132
    def get_period_values(self, name, tzid=None):
paul@646 133
        return get_period_values(self.details, name, tzid)
paul@630 134
paul@318 135
    def get_datetime(self, name):
paul@567 136
        t = get_datetime_item(self.details, name)
paul@567 137
        if not t: return None
paul@567 138
        dt, attr = t
paul@318 139
        return dt
paul@318 140
paul@289 141
    def get_datetime_item(self, name):
paul@289 142
        return get_datetime_item(self.details, name)
paul@289 143
paul@392 144
    def get_duration(self, name):
paul@392 145
        return get_duration(self.get_value(name))
paul@392 146
paul@213 147
    def to_node(self):
paul@213 148
        return to_node({self.objtype : [(self.details, self.attr)]})
paul@213 149
paul@213 150
    def to_part(self, method):
paul@213 151
        return to_part(method, [self.to_node()])
paul@213 152
paul@213 153
    # Direct access to the structure.
paul@213 154
paul@392 155
    def has_key(self, name):
paul@392 156
        return self.details.has_key(name)
paul@392 157
paul@524 158
    def get(self, name):
paul@524 159
        return self.details.get(name)
paul@524 160
paul@734 161
    def keys(self):
paul@734 162
        return self.details.keys()
paul@734 163
paul@213 164
    def __getitem__(self, name):
paul@213 165
        return self.details[name]
paul@213 166
paul@213 167
    def __setitem__(self, name, value):
paul@213 168
        self.details[name] = value
paul@213 169
paul@213 170
    def __delitem__(self, name):
paul@213 171
        del self.details[name]
paul@213 172
paul@524 173
    def remove(self, name):
paul@524 174
        try:
paul@524 175
            del self[name]
paul@524 176
        except KeyError:
paul@524 177
            pass
paul@524 178
paul@524 179
    def remove_all(self, names):
paul@524 180
        for name in names:
paul@524 181
            self.remove(name)
paul@524 182
paul@734 183
    def preserve(self, names):
paul@734 184
        for name in self.keys():
paul@734 185
            if not name in names:
paul@734 186
                self.remove(name)
paul@734 187
paul@256 188
    # Computed results.
paul@256 189
paul@797 190
    def get_main_period(self, tzid):
paul@797 191
paul@797 192
        """
paul@797 193
        Return a period object corresponding to the main start-end period for
paul@797 194
        the object.
paul@797 195
        """
paul@797 196
paul@797 197
        (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_main_period_items(tzid)
paul@797 198
        return RecurringPeriod(dtstart, dtend, tzid, "DTSTART", dtstart_attr, dtend_attr)
paul@797 199
paul@650 200
    def get_main_period_items(self, tzid):
paul@650 201
paul@650 202
        """
paul@650 203
        Return two (value, attributes) items corresponding to the main start-end
paul@650 204
        period for the object.
paul@650 205
        """
paul@650 206
paul@650 207
        dtstart, dtstart_attr = self.get_datetime_item("DTSTART")
paul@650 208
paul@650 209
        if self.has_key("DTEND"):
paul@650 210
            dtend, dtend_attr = self.get_datetime_item("DTEND")
paul@650 211
        elif self.has_key("DURATION"):
paul@650 212
            duration = self.get_duration("DURATION")
paul@650 213
            dtend = dtstart + duration
paul@650 214
            dtend_attr = dtstart_attr
paul@650 215
        else:
paul@650 216
            dtend, dtend_attr = dtstart, dtstart_attr
paul@650 217
paul@650 218
        return (dtstart, dtstart_attr), (dtend, dtend_attr)
paul@650 219
paul@630 220
    def get_periods(self, tzid, end=None):
paul@620 221
paul@620 222
        """
paul@620 223
        Return periods defined by this object, employing the given 'tzid' where
paul@620 224
        no time zone information is defined, and limiting the collection to a
paul@620 225
        window of time with the given 'end'.
paul@630 226
paul@630 227
        If 'end' is omitted, only explicit recurrences and recurrences from
paul@630 228
        explicitly-terminated rules will be returned.
paul@620 229
        """
paul@620 230
paul@458 231
        return get_periods(self, tzid, end)
paul@360 232
paul@630 233
    def get_active_periods(self, recurrenceids, tzid, end=None):
paul@630 234
paul@630 235
        """
paul@630 236
        Return all periods specified by this object that are not replaced by
paul@630 237
        those defined by 'recurrenceids', using 'tzid' as a fallback time zone
paul@630 238
        to convert floating dates and datetimes, and using 'end' to indicate the
paul@630 239
        end of the time window within which periods are considered.
paul@630 240
        """
paul@630 241
paul@630 242
        # Specific recurrences yield all specified periods.
paul@630 243
paul@630 244
        periods = self.get_periods(tzid, end)
paul@630 245
paul@630 246
        if self.get_recurrenceid():
paul@630 247
            return periods
paul@630 248
paul@630 249
        # Parent objects need to have their periods tested against redefined
paul@630 250
        # recurrences.
paul@630 251
paul@630 252
        active = []
paul@630 253
paul@630 254
        for p in periods:
paul@630 255
paul@630 256
            # Subtract any recurrences from the free/busy details of a
paul@630 257
            # parent object.
paul@630 258
paul@648 259
            if not p.is_replaced(recurrenceids):
paul@630 260
                active.append(p)
paul@630 261
paul@630 262
        return active
paul@630 263
paul@648 264
    def get_freebusy_period(self, period, only_organiser=False):
paul@648 265
paul@648 266
        """
paul@648 267
        Return a free/busy period for the given 'period' provided by this
paul@648 268
        object, using the 'only_organiser' status to produce a suitable
paul@648 269
        transparency value.
paul@648 270
        """
paul@648 271
paul@648 272
        return FreeBusyPeriod(
paul@648 273
            period.get_start_point(),
paul@648 274
            period.get_end_point(),
paul@648 275
            self.get_value("UID"),
paul@648 276
            only_organiser and "ORG" or self.get_value("TRANSP") or "OPAQUE",
paul@648 277
            self.get_recurrenceid(),
paul@648 278
            self.get_value("SUMMARY"),
paul@814 279
            get_uri(self.get_value("ORGANIZER"))
paul@648 280
            )
paul@648 281
paul@648 282
    def get_participation_status(self, participant):
paul@648 283
paul@648 284
        """
paul@648 285
        Return the participation status of the given 'participant', with the
paul@648 286
        special value "ORG" indicating organiser-only participation.
paul@648 287
        """
paul@648 288
    
paul@814 289
        attendees = uri_dict(self.get_value_map("ATTENDEE"))
paul@814 290
        organiser = get_uri(self.get_value("ORGANIZER"))
paul@648 291
paul@692 292
        attendee_attr = attendees.get(participant)
paul@692 293
        if attendee_attr:
paul@692 294
            return attendee_attr.get("PARTSTAT", "NEEDS-ACTION")
paul@692 295
        elif organiser == participant:
paul@692 296
            return "ORG"
paul@648 297
paul@648 298
        return None
paul@648 299
paul@648 300
    def get_participation(self, partstat, include_needs_action=False):
paul@648 301
paul@648 302
        """
paul@648 303
        Return whether 'partstat' indicates some kind of participation in an
paul@648 304
        event. If 'include_needs_action' is specified as a true value, events
paul@648 305
        not yet responded to will be treated as events with tentative
paul@648 306
        participation.
paul@648 307
        """
paul@648 308
paul@648 309
        return not partstat in ("DECLINED", "DELEGATED", "NEEDS-ACTION") or \
paul@648 310
               include_needs_action and partstat == "NEEDS-ACTION" or \
paul@648 311
               partstat == "ORG"
paul@648 312
paul@422 313
    def get_tzid(self):
paul@562 314
paul@562 315
        """
paul@562 316
        Return a time zone identifier used by the start or end datetimes,
paul@562 317
        potentially suitable for converting dates to datetimes.
paul@562 318
        """
paul@562 319
paul@560 320
        if not self.has_key("DTSTART"):
paul@560 321
            return None
paul@422 322
        dtstart, dtstart_attr = self.get_datetime_item("DTSTART")
paul@630 323
        if self.has_key("DTEND"):
paul@630 324
            dtend, dtend_attr = self.get_datetime_item("DTEND")
paul@630 325
        else:
paul@630 326
            dtend_attr = None
paul@422 327
        return get_tzid(dtstart_attr, dtend_attr)
paul@422 328
paul@619 329
    def is_shared(self):
paul@619 330
paul@619 331
        """
paul@619 332
        Return whether this object is shared based on the presence of a SEQUENCE
paul@619 333
        property.
paul@619 334
        """
paul@619 335
paul@619 336
        return self.get_value("SEQUENCE") is not None
paul@619 337
paul@650 338
    def possibly_active_from(self, dt, tzid):
paul@650 339
paul@650 340
        """
paul@650 341
        Return whether the object is possibly active from or after the given
paul@650 342
        datetime 'dt' using 'tzid' to convert any dates or floating datetimes.
paul@650 343
        """
paul@650 344
paul@650 345
        dt = to_datetime(dt, tzid)
paul@650 346
        periods = self.get_periods(tzid)
paul@650 347
paul@650 348
        for p in periods:
paul@650 349
            if p.get_end_point() > dt:
paul@650 350
                return True
paul@650 351
paul@672 352
        return self.possibly_recurring_indefinitely()
paul@672 353
paul@672 354
    def possibly_recurring_indefinitely(self):
paul@672 355
paul@672 356
        "Return whether this object may recur indefinitely."
paul@672 357
paul@650 358
        rrule = self.get_value("RRULE")
paul@650 359
        parameters = rrule and get_parameters(rrule)
paul@650 360
        until = parameters and parameters.get("UNTIL")
paul@651 361
        count = parameters and parameters.get("COUNT")
paul@650 362
paul@672 363
        # Non-recurring periods or constrained recurrences.
paul@651 364
paul@651 365
        if not rrule or until or count:
paul@650 366
            return False
paul@651 367
paul@672 368
        # Unconstrained recurring periods will always lie beyond any specified
paul@651 369
        # datetime.
paul@651 370
paul@651 371
        else:
paul@650 372
            return True
paul@650 373
paul@627 374
    # Modification methods.
paul@627 375
paul@627 376
    def set_datetime(self, name, dt, tzid=None):
paul@627 377
paul@627 378
        """
paul@627 379
        Set a datetime for property 'name' using 'dt' and the optional fallback
paul@627 380
        'tzid', returning whether an update has occurred.
paul@627 381
        """
paul@627 382
paul@627 383
        if dt:
paul@627 384
            old_value = self.get_value(name)
paul@627 385
            self[name] = [get_item_from_datetime(dt, tzid)]
paul@627 386
            return format_datetime(dt) != old_value
paul@627 387
paul@627 388
        return False
paul@627 389
paul@627 390
    def set_period(self, period):
paul@627 391
paul@627 392
        "Set the given 'period' as the main start and end."
paul@627 393
paul@627 394
        result = self.set_datetime("DTSTART", period.get_start())
paul@627 395
        result = self.set_datetime("DTEND", period.get_end()) or result
paul@661 396
        if self.has_key("DURATION"):
paul@661 397
            del self["DURATION"]
paul@661 398
paul@627 399
        return result
paul@627 400
paul@627 401
    def set_periods(self, periods):
paul@627 402
paul@627 403
        """
paul@627 404
        Set the given 'periods' as recurrence date properties, replacing the
paul@627 405
        previous RDATE properties and ignoring any RRULE properties.
paul@627 406
        """
paul@627 407
paul@753 408
        old_values = set(self.get_date_values("RDATE") or [])
paul@627 409
        new_rdates = []
paul@627 410
paul@627 411
        if self.has_key("RDATE"):
paul@627 412
            del self["RDATE"]
paul@627 413
paul@812 414
        main_changed = False
paul@812 415
paul@627 416
        for p in periods:
paul@812 417
            if p.origin == "RDATE":
paul@627 418
                new_rdates.append(get_period_item(p.get_start(), p.get_end()))
paul@812 419
            elif p.origin == "DTSTART":
paul@812 420
                main_changed = self.set_period(p)
paul@627 421
paul@661 422
        if new_rdates:
paul@661 423
            self["RDATE"] = new_rdates
paul@661 424
paul@812 425
        return main_changed or old_values != set(self.get_date_values("RDATE") or [])
paul@661 426
paul@809 427
    def update_dtstamp(self):
paul@809 428
paul@809 429
        "Update the DTSTAMP in the object."
paul@809 430
paul@809 431
        dtstamp = self.get_utc_datetime("DTSTAMP")
paul@809 432
        utcnow = get_time()
paul@809 433
        dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow)
paul@809 434
        self["DTSTAMP"] = [(dtstamp, {})]
paul@809 435
        return dtstamp
paul@809 436
paul@809 437
    def update_sequence(self, increment=False):
paul@809 438
paul@809 439
        "Set or update the SEQUENCE in the object."
paul@809 440
paul@809 441
        sequence = self.get_value("SEQUENCE") or "0"
paul@809 442
        self["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]
paul@809 443
        return sequence
paul@809 444
paul@848 445
    def update_senders(self, user=None):
paul@848 446
paul@848 447
        "Remove SENT-BY attributes from properties."
paul@848 448
paul@848 449
        for identity, attr in self.get_items("ATTENDEE") or []:
paul@848 450
            if attr.has_key("SENT-BY") and (not user or get_uri(identity) != user):
paul@848 451
                del attr["SENT-BY"]
paul@848 452
paul@784 453
    def update_exceptions(self, excluded):
paul@784 454
paul@784 455
        """
paul@784 456
        Update the exceptions to any rule by applying the list of 'excluded'
paul@784 457
        periods.
paul@784 458
        """
paul@784 459
paul@784 460
        to_exclude = set(excluded).difference(self.get_date_values("EXDATE") or [])
paul@784 461
        if not to_exclude:
paul@784 462
            return False
paul@784 463
paul@784 464
        if not self.has_key("EXDATE"):
paul@784 465
            self["EXDATE"] = []
paul@784 466
paul@784 467
        for p in to_exclude:
paul@784 468
            self["EXDATE"].append(get_period_item(p.get_start(), p.get_end()))
paul@784 469
paul@784 470
        return True
paul@784 471
paul@669 472
    def correct_object(self, tzid, permitted_values):
paul@661 473
paul@661 474
        "Correct the object's period details."
paul@661 475
paul@661 476
        corrected = set()
paul@661 477
        rdates = []
paul@661 478
paul@661 479
        for period in self.get_periods(tzid):
paul@661 480
            start = period.get_start()
paul@661 481
            end = period.get_end()
paul@669 482
            start_errors = check_permitted_values(start, permitted_values)
paul@669 483
            end_errors = check_permitted_values(end, permitted_values)
paul@627 484
paul@661 485
            if not (start_errors or end_errors):
paul@661 486
                if period.origin == "RDATE":
paul@661 487
                    rdates.append(period)
paul@661 488
                continue
paul@661 489
paul@661 490
            if start_errors:
paul@669 491
                start = correct_datetime(start, permitted_values)
paul@661 492
            if end_errors:
paul@669 493
                end = correct_datetime(end, permitted_values)
paul@661 494
            period = RecurringPeriod(start, end, period.tzid, period.origin, period.get_start_attr(), period.get_end_attr())
paul@661 495
paul@661 496
            if period.origin == "DTSTART":
paul@661 497
                self.set_period(period)
paul@661 498
                corrected.add("DTSTART")
paul@661 499
            elif period.origin == "RDATE":
paul@661 500
                rdates.append(period)
paul@661 501
                corrected.add("RDATE")
paul@661 502
paul@661 503
        if "RDATE" in corrected:
paul@661 504
            self.set_periods(rdates)
paul@661 505
paul@661 506
        return corrected
paul@627 507
paul@213 508
# Construction and serialisation.
paul@213 509
paul@213 510
def make_calendar(nodes, method=None):
paul@213 511
paul@213 512
    """
paul@213 513
    Return a complete calendar node wrapping the given 'nodes' and employing the
paul@213 514
    given 'method', if indicated.
paul@213 515
    """
paul@213 516
paul@213 517
    return ("VCALENDAR", {},
paul@213 518
            (method and [("METHOD", {}, method)] or []) +
paul@213 519
            [("VERSION", {}, "2.0")] +
paul@213 520
            nodes
paul@213 521
           )
paul@213 522
paul@327 523
def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None,
paul@562 524
                  attendee_attr=None, period=None):
paul@222 525
    
paul@222 526
    """
paul@222 527
    Return a calendar node defining the free/busy details described in the given
paul@292 528
    'freebusy' list, employing the given 'uid', for the given 'organiser' and
paul@292 529
    optional 'organiser_attr', with the optional 'attendee' providing recipient
paul@292 530
    details together with the optional 'attendee_attr'.
paul@327 531
paul@562 532
    The result will be constrained to the 'period' if specified.
paul@222 533
    """
paul@222 534
    
paul@222 535
    record = []
paul@222 536
    rwrite = record.append
paul@222 537
    
paul@292 538
    rwrite(("ORGANIZER", organiser_attr or {}, organiser))
paul@222 539
paul@222 540
    if attendee:
paul@292 541
        rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 
paul@222 542
paul@222 543
    rwrite(("UID", {}, uid))
paul@222 544
paul@222 545
    if freebusy:
paul@327 546
paul@327 547
        # Get a constrained view if start and end limits are specified.
paul@327 548
paul@563 549
        if period:
paul@563 550
            periods = period_overlaps(freebusy, period, True)
paul@563 551
        else:
paul@563 552
            periods = freebusy
paul@327 553
paul@327 554
        # Write the limits of the resource.
paul@327 555
paul@563 556
        if periods:
paul@563 557
            rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point())))
paul@563 558
            rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point())))
paul@563 559
        else:
paul@563 560
            rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point())))
paul@563 561
            rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point())))
paul@327 562
paul@458 563
        for p in periods:
paul@458 564
            if p.transp == "OPAQUE":
paul@529 565
                rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join(
paul@562 566
                    map(format_datetime, [p.get_start_point(), p.get_end_point()])
paul@529 567
                    )))
paul@222 568
paul@222 569
    return ("VFREEBUSY", {}, record)
paul@222 570
paul@213 571
def parse_object(f, encoding, objtype=None):
paul@213 572
paul@213 573
    """
paul@213 574
    Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is
paul@213 575
    given, only objects of that type will be returned. Otherwise, the root of
paul@213 576
    the content will be returned as a dictionary with a single key indicating
paul@213 577
    the object type.
paul@213 578
paul@213 579
    Return None if the content was not readable or suitable.
paul@213 580
    """
paul@213 581
paul@213 582
    try:
paul@213 583
        try:
paul@213 584
            doctype, attrs, elements = obj = parse(f, encoding=encoding)
paul@213 585
            if objtype and doctype == objtype:
paul@213 586
                return to_dict(obj)[objtype][0]
paul@213 587
            elif not objtype:
paul@213 588
                return to_dict(obj)
paul@213 589
        finally:
paul@213 590
            f.close()
paul@213 591
paul@213 592
    # NOTE: Handle parse errors properly.
paul@213 593
paul@213 594
    except (ParseError, ValueError):
paul@213 595
        pass
paul@213 596
paul@213 597
    return None
paul@213 598
paul@213 599
def to_part(method, calendar):
paul@213 600
paul@213 601
    """
paul@213 602
    Write using the given 'method', the 'calendar' details to a MIME
paul@213 603
    text/calendar part.
paul@213 604
    """
paul@213 605
paul@213 606
    encoding = "utf-8"
paul@213 607
    out = StringIO()
paul@213 608
    try:
paul@213 609
        to_stream(out, make_calendar(calendar, method), encoding)
paul@213 610
        part = MIMEText(out.getvalue(), "calendar", encoding)
paul@213 611
        part.set_param("method", method)
paul@213 612
        return part
paul@213 613
paul@213 614
    finally:
paul@213 615
        out.close()
paul@213 616
paul@213 617
def to_stream(out, fragment, encoding="utf-8"):
paul@213 618
    iterwrite(out, encoding=encoding).append(fragment)
paul@213 619
paul@213 620
# Structure access functions.
paul@213 621
paul@213 622
def get_items(d, name, all=True):
paul@213 623
paul@213 624
    """
paul@213 625
    Get all items from 'd' for the given 'name', returning single items if
paul@213 626
    'all' is specified and set to a false value and if only one value is
paul@213 627
    present for the name. Return None if no items are found for the name or if
paul@213 628
    many items are found but 'all' is set to a false value.
paul@213 629
    """
paul@213 630
paul@213 631
    if d.has_key(name):
paul@712 632
        items = [(value or None, attr) for value, attr in d[name]]
paul@213 633
        if all:
paul@462 634
            return items
paul@462 635
        elif len(items) == 1:
paul@462 636
            return items[0]
paul@213 637
        else:
paul@213 638
            return None
paul@213 639
    else:
paul@213 640
        return None
paul@213 641
paul@213 642
def get_item(d, name):
paul@213 643
    return get_items(d, name, False)
paul@213 644
paul@213 645
def get_value_map(d, name):
paul@213 646
paul@213 647
    """
paul@213 648
    Return a dictionary for all items in 'd' having the given 'name'. The
paul@213 649
    dictionary will map values for the name to any attributes or qualifiers
paul@213 650
    that may have been present.
paul@213 651
    """
paul@213 652
paul@213 653
    items = get_items(d, name)
paul@213 654
    if items:
paul@213 655
        return dict(items)
paul@213 656
    else:
paul@213 657
        return {}
paul@213 658
paul@462 659
def values_from_items(items):
paul@462 660
    return map(lambda x: x[0], items)
paul@462 661
paul@213 662
def get_values(d, name, all=True):
paul@213 663
    if d.has_key(name):
paul@462 664
        items = d[name]
paul@462 665
        if not all and len(items) == 1:
paul@462 666
            return items[0][0]
paul@213 667
        else:
paul@462 668
            return values_from_items(items)
paul@213 669
    else:
paul@213 670
        return None
paul@213 671
paul@213 672
def get_value(d, name):
paul@213 673
    return get_values(d, name, False)
paul@213 674
paul@417 675
def get_date_value_items(d, name, tzid=None):
paul@352 676
paul@352 677
    """
paul@389 678
    Obtain items from 'd' having the given 'name', where a single item yields
paul@389 679
    potentially many values. Return a list of tuples of the form (value,
paul@389 680
    attributes) where the attributes have been given for the property in 'd'.
paul@352 681
    """
paul@352 682
paul@403 683
    items = get_items(d, name)
paul@403 684
    if items:
paul@403 685
        all_items = []
paul@403 686
        for item in items:
paul@403 687
            values, attr = item
paul@417 688
            if not attr.has_key("TZID") and tzid:
paul@417 689
                attr["TZID"] = tzid
paul@403 690
            if not isinstance(values, list):
paul@403 691
                values = [values]
paul@403 692
            for value in values:
paul@403 693
                all_items.append((get_datetime(value, attr) or get_period(value, attr), attr))
paul@403 694
        return all_items
paul@352 695
    else:
paul@352 696
        return None
paul@352 697
paul@646 698
def get_period_values(d, name, tzid=None):
paul@630 699
paul@630 700
    """
paul@630 701
    Return period values from 'd' for the given property 'name', using 'tzid'
paul@646 702
    where specified to indicate the time zone.
paul@630 703
    """
paul@630 704
paul@630 705
    values = []
paul@630 706
    for value, attr in get_items(d, name) or []:
paul@630 707
        if not attr.has_key("TZID") and tzid:
paul@630 708
            attr["TZID"] = tzid
paul@630 709
        start, end = get_period(value, attr)
paul@646 710
        values.append(Period(start, end, tzid=tzid))
paul@630 711
    return values
paul@630 712
paul@506 713
def get_utc_datetime(d, name, date_tzid=None):
paul@506 714
paul@506 715
    """
paul@506 716
    Return the value provided by 'd' for 'name' as a datetime in the UTC zone
paul@506 717
    or as a date, converting any date to a datetime if 'date_tzid' is specified.
paul@720 718
    If no datetime or date is available, None is returned.
paul@506 719
    """
paul@506 720
paul@348 721
    t = get_datetime_item(d, name)
paul@348 722
    if not t:
paul@348 723
        return None
paul@348 724
    else:
paul@348 725
        dt, attr = t
paul@720 726
        return dt is not None and to_utc_datetime(dt, date_tzid) or None
paul@289 727
paul@289 728
def get_datetime_item(d, name):
paul@562 729
paul@562 730
    """
paul@562 731
    Return the value provided by 'd' for 'name' as a datetime or as a date,
paul@562 732
    together with the attributes describing it. Return None if no value exists
paul@562 733
    for 'name' in 'd'.
paul@562 734
    """
paul@562 735
paul@348 736
    t = get_item(d, name)
paul@348 737
    if not t:
paul@348 738
        return None
paul@348 739
    else:
paul@348 740
        value, attr = t
paul@613 741
        dt = get_datetime(value, attr)
paul@616 742
        tzid = get_datetime_tzid(dt)
paul@616 743
        if tzid:
paul@616 744
            attr["TZID"] = tzid
paul@613 745
        return dt, attr
paul@213 746
paul@528 747
# Conversion functions.
paul@528 748
paul@792 749
def get_address_parts(values):
paul@792 750
paul@792 751
    "Return name and address tuples for each of the given 'values'."
paul@792 752
paul@792 753
    l = []
paul@792 754
    for name, address in values and email.utils.getaddresses(values) or []:
paul@792 755
        if is_mailto_uri(name):
paul@792 756
            name = name[7:] # strip "mailto:"
paul@792 757
        l.append((name, address))
paul@792 758
    return l
paul@792 759
paul@213 760
def get_addresses(values):
paul@790 761
paul@790 762
    """
paul@790 763
    Return only addresses from the given 'values' which may be of the form
paul@790 764
    "Common Name <recipient@domain>", with the latter part being the address
paul@790 765
    itself.
paul@790 766
    """
paul@790 767
paul@792 768
    return [address for name, address in get_address_parts(values)]
paul@213 769
paul@213 770
def get_address(value):
paul@790 771
paul@790 772
    "Return an e-mail address from the given 'value'."
paul@790 773
paul@712 774
    if not value: return None
paul@792 775
    return get_addresses([value])[0]
paul@792 776
paul@792 777
def get_verbose_address(value, attr=None):
paul@792 778
paul@792 779
    """
paul@792 780
    Return a verbose e-mail address featuring any name from the given 'value'
paul@792 781
    and any accompanying 'attr' dictionary.
paul@792 782
    """
paul@792 783
paul@810 784
    l = get_address_parts([value])
paul@810 785
    if not l:
paul@810 786
        return value
paul@810 787
    name, address = l[0]
paul@792 788
    if not name:
paul@792 789
        name = attr and attr.get("CN")
paul@792 790
    if name and address:
paul@792 791
        return "%s <%s>" % (name, address)
paul@792 792
    else:
paul@792 793
        return address
paul@792 794
paul@792 795
def is_mailto_uri(value):
paul@792 796
    return value.lower().startswith("mailto:")
paul@213 797
paul@213 798
def get_uri(value):
paul@790 799
paul@790 800
    "Return a URI for the given 'value'."
paul@790 801
paul@712 802
    if not value: return None
paul@792 803
    return is_mailto_uri(value) and ("mailto:%s" % value[7:]) or \
paul@712 804
           ":" in value and value or \
paul@790 805
           "mailto:%s" % get_address(value)
paul@213 806
paul@792 807
def uri_parts(values):
paul@792 808
paul@792 809
    "Return any common name plus the URI for each of the given 'values'."
paul@792 810
paul@792 811
    return [(name, get_uri(address)) for name, address in get_address_parts(values)]
paul@792 812
paul@309 813
uri_value = get_uri
paul@309 814
paul@309 815
def uri_values(values):
paul@309 816
    return map(get_uri, values)
paul@309 817
paul@213 818
def uri_dict(d):
paul@213 819
    return dict([(get_uri(key), value) for key, value in d.items()])
paul@213 820
paul@213 821
def uri_item(item):
paul@213 822
    return get_uri(item[0]), item[1]
paul@213 823
paul@213 824
def uri_items(items):
paul@213 825
    return [(get_uri(value), attr) for value, attr in items]
paul@213 826
paul@220 827
# Operations on structure data.
paul@220 828
paul@682 829
def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp):
paul@220 830
paul@220 831
    """
paul@220 832
    Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and
paul@682 833
    'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object
paul@220 834
    providing the new information is really newer than the object providing the
paul@220 835
    old information.
paul@220 836
    """
paul@220 837
paul@220 838
    have_sequence = old_sequence is not None and new_sequence is not None
paul@220 839
    is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence)
paul@220 840
paul@220 841
    have_dtstamp = old_dtstamp and new_dtstamp
paul@220 842
    is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp
paul@220 843
paul@220 844
    is_old_sequence = have_sequence and (
paul@220 845
        int(new_sequence) < int(old_sequence) or
paul@220 846
        is_same_sequence and is_old_dtstamp
paul@220 847
        )
paul@220 848
paul@682 849
    return is_same_sequence and ignore_dtstamp or not is_old_sequence
paul@220 850
paul@630 851
def get_periods(obj, tzid, end=None, inclusive=False):
paul@256 852
paul@256 853
    """
paul@618 854
    Return periods for the given object 'obj', employing the given 'tzid' where
paul@618 855
    no time zone information is available (for whole day events, for example),
paul@630 856
    confining materialised periods to before the given 'end' datetime.
paul@618 857
paul@630 858
    If 'end' is omitted, only explicit recurrences and recurrences from
paul@630 859
    explicitly-terminated rules will be returned.
paul@630 860
paul@630 861
    If 'inclusive' is set to a true value, any period occurring at the 'end'
paul@630 862
    will be included.
paul@256 863
    """
paul@256 864
paul@318 865
    rrule = obj.get_value("RRULE")
paul@636 866
    parameters = rrule and get_parameters(rrule)
paul@318 867
paul@318 868
    # Use localised datetimes.
paul@318 869
paul@797 870
    main_period = obj.get_main_period(tzid)
paul@797 871
paul@797 872
    dtstart = main_period.get_start()
paul@797 873
    dtstart_attr = main_period.get_start_attr()
paul@797 874
    dtend = main_period.get_end()
paul@797 875
    dtend_attr = main_period.get_end_attr()
paul@797 876
paul@650 877
    duration = dtend - dtstart
paul@256 878
paul@618 879
    # Attempt to get time zone details from the object, using the supplied zone
paul@618 880
    # only as a fallback.
paul@618 881
paul@638 882
    obj_tzid = obj.get_tzid()
paul@256 883
paul@352 884
    if not rrule:
paul@797 885
        periods = [main_period]
paul@630 886
paul@636 887
    elif end or parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT"):
paul@630 888
paul@352 889
        # Recurrence rules create multiple instances to be checked.
paul@352 890
        # Conflicts may only be assessed within a period defined by policy
paul@352 891
        # for the agent, with instances outside that period being considered
paul@352 892
        # unchecked.
paul@352 893
paul@352 894
        selector = get_rule(dtstart, rrule)
paul@352 895
        periods = []
paul@352 896
paul@521 897
        until = parameters.get("UNTIL")
paul@521 898
        if until:
paul@650 899
            until_dt = to_timezone(get_datetime(until, dtstart_attr), obj_tzid)
paul@650 900
            end = end and min(until_dt, end) or until_dt
paul@521 901
            inclusive = True
paul@521 902
paul@630 903
        for recurrence_start in selector.materialise(dtstart, end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive):
paul@630 904
            create = len(recurrence_start) == 3 and date or datetime
paul@638 905
            recurrence_start = to_timezone(create(*recurrence_start), obj_tzid)
paul@630 906
            recurrence_end = recurrence_start + duration
paul@638 907
            periods.append(RecurringPeriod(recurrence_start, recurrence_end, tzid, "RRULE", dtstart_attr))
paul@352 908
paul@635 909
    else:
paul@635 910
        periods = []
paul@635 911
paul@352 912
    # Add recurrence dates.
paul@256 913
paul@494 914
    rdates = obj.get_date_value_items("RDATE", tzid)
paul@352 915
paul@352 916
    if rdates:
paul@494 917
        for rdate, rdate_attr in rdates:
paul@389 918
            if isinstance(rdate, tuple):
paul@541 919
                periods.append(RecurringPeriod(rdate[0], rdate[1], tzid, "RDATE", rdate_attr))
paul@389 920
            else:
paul@541 921
                periods.append(RecurringPeriod(rdate, rdate + duration, tzid, "RDATE", rdate_attr))
paul@424 922
paul@424 923
    # Return a sorted list of the periods.
paul@424 924
paul@542 925
    periods.sort()
paul@352 926
paul@352 927
    # Exclude exception dates.
paul@352 928
paul@638 929
    exdates = obj.get_date_value_items("EXDATE", tzid)
paul@256 930
paul@352 931
    if exdates:
paul@638 932
        for exdate, exdate_attr in exdates:
paul@389 933
            if isinstance(exdate, tuple):
paul@638 934
                period = RecurringPeriod(exdate[0], exdate[1], tzid, "EXDATE", exdate_attr)
paul@389 935
            else:
paul@638 936
                period = RecurringPeriod(exdate, exdate + duration, tzid, "EXDATE", exdate_attr)
paul@424 937
            i = bisect_left(periods, period)
paul@458 938
            while i < len(periods) and periods[i] == period:
paul@424 939
                del periods[i]
paul@256 940
paul@256 941
    return periods
paul@256 942
paul@606 943
def get_sender_identities(mapping):
paul@606 944
paul@606 945
    """
paul@606 946
    Return a mapping from actual senders to the identities for which they
paul@606 947
    have provided data, extracting this information from the given
paul@606 948
    'mapping'.
paul@606 949
    """
paul@606 950
paul@606 951
    senders = {}
paul@606 952
paul@606 953
    for value, attr in mapping.items():
paul@606 954
        sent_by = attr.get("SENT-BY")
paul@606 955
        if sent_by:
paul@606 956
            sender = get_uri(sent_by)
paul@606 957
        else:
paul@606 958
            sender = value
paul@606 959
paul@606 960
        if not senders.has_key(sender):
paul@606 961
            senders[sender] = []
paul@606 962
paul@606 963
        senders[sender].append(value)
paul@606 964
paul@606 965
    return senders
paul@606 966
paul@618 967
def get_window_end(tzid, days=100):
paul@606 968
paul@618 969
    """
paul@618 970
    Return a datetime in the time zone indicated by 'tzid' marking the end of a
paul@618 971
    window of the given number of 'days'.
paul@618 972
    """
paul@618 973
paul@618 974
    return to_timezone(datetime.now(), tzid) + timedelta(days)
paul@606 975
paul@213 976
# vim: tabstop=4 expandtab shiftwidth=4