imip-agent

Annotated imiptools/period.py

1066:b0c1acfc40c7
2016-03-05 Paul Boddie Removed overriding defaults when retrieving periods from the store, handling default attribute values in the period abstraction itself.
paul@48 1
#!/usr/bin/env python
paul@48 2
paul@146 3
"""
paul@146 4
Managing and presenting periods of time.
paul@146 5
paul@1043 6
Copyright (C) 2014, 2015, 2016 Paul Boddie <paul@boddie.org.uk>
paul@146 7
paul@146 8
This program is free software; you can redistribute it and/or modify it under
paul@146 9
the terms of the GNU General Public License as published by the Free Software
paul@146 10
Foundation; either version 3 of the License, or (at your option) any later
paul@146 11
version.
paul@146 12
paul@146 13
This program is distributed in the hope that it will be useful, but WITHOUT
paul@146 14
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
paul@146 15
FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
paul@146 16
details.
paul@146 17
paul@146 18
You should have received a copy of the GNU General Public License along with
paul@146 19
this program.  If not, see <http://www.gnu.org/licenses/>.
paul@146 20
"""
paul@146 21
paul@430 22
from bisect import bisect_left, bisect_right, insort_left
paul@529 23
from datetime import date, datetime, timedelta
paul@939 24
from imiptools.dates import check_permitted_values, correct_datetime, \
paul@939 25
                            format_datetime, get_datetime, \
paul@563 26
                            get_datetime_attributes, \
paul@563 27
                            get_recurrence_start, get_recurrence_start_point, \
paul@563 28
                            get_start_of_day, \
paul@563 29
                            get_tzid, \
paul@563 30
                            to_timezone, to_utc_datetime
paul@48 31
paul@924 32
def ifnone(x, y):
paul@924 33
    if x is None: return y
paul@924 34
    else: return x
paul@924 35
paul@874 36
class Comparable:
paul@874 37
paul@874 38
    "A date/datetime wrapper that allows comparisons with other types."
paul@874 39
paul@874 40
    def __init__(self, dt):
paul@874 41
        self.dt = dt
paul@874 42
paul@874 43
    def __cmp__(self, other):
paul@874 44
        dt = None
paul@874 45
        odt = None
paul@874 46
paul@874 47
        # Find any dates/datetimes.
paul@874 48
paul@874 49
        if isinstance(self.dt, date):
paul@874 50
            dt = self.dt
paul@874 51
        if isinstance(other, date):
paul@874 52
            odt = other
paul@874 53
        elif isinstance(other, Comparable):
paul@874 54
            if isinstance(other.dt, date):
paul@874 55
                odt = other.dt
paul@874 56
            else:
paul@874 57
                other = other.dt
paul@874 58
paul@874 59
        if dt and odt:
paul@874 60
            return cmp(dt, odt)
paul@874 61
        elif dt:
paul@874 62
            return other.__rcmp__(dt)
paul@874 63
        elif odt:
paul@874 64
            return self.dt.__cmp__(odt)
paul@874 65
        else:
paul@874 66
            return self.dt.__cmp__(other)
paul@874 67
paul@874 68
class PointInTime:
paul@874 69
paul@874 70
    "A base class for special values."
paul@874 71
paul@874 72
    pass
paul@874 73
paul@874 74
class StartOfTime(PointInTime):
paul@874 75
paul@874 76
    "A special value that compares earlier than other values."
paul@874 77
paul@874 78
    def __cmp__(self, other):
paul@874 79
        if isinstance(other, StartOfTime):
paul@874 80
            return 0
paul@874 81
        else:
paul@874 82
            return -1
paul@874 83
paul@874 84
    def __rcmp__(self, other):
paul@874 85
        return -self.__cmp__(other)
paul@874 86
paul@887 87
    def __nonzero__(self):
paul@887 88
        return False
paul@887 89
paul@944 90
    def __hash__(self):
paul@944 91
        return 0
paul@944 92
paul@874 93
class EndOfTime(PointInTime):
paul@874 94
paul@874 95
    "A special value that compares later than other values."
paul@874 96
paul@874 97
    def __cmp__(self, other):
paul@874 98
        if isinstance(other, EndOfTime):
paul@874 99
            return 0
paul@874 100
        else:
paul@874 101
            return 1
paul@874 102
paul@874 103
    def __rcmp__(self, other):
paul@874 104
        return -self.__cmp__(other)
paul@874 105
paul@887 106
    def __nonzero__(self):
paul@887 107
        return False
paul@887 108
paul@944 109
    def __hash__(self):
paul@944 110
        return 0
paul@944 111
paul@944 112
class Endless:
paul@944 113
paul@944 114
    "A special value indicating an endless period."
paul@944 115
paul@944 116
    def __cmp__(self, other):
paul@944 117
        if isinstance(other, Endless):
paul@944 118
            return 0
paul@944 119
        else:
paul@944 120
            return 1
paul@944 121
paul@944 122
    def __rcmp__(self, other):
paul@944 123
        return -self.__cmp__(other)
paul@944 124
paul@944 125
    def __nonzero__(self):
paul@944 126
        return True
paul@944 127
paul@646 128
class PeriodBase:
paul@458 129
paul@458 130
    "A basic period abstraction."
paul@458 131
paul@944 132
    def __init__(self, start, end):
paul@944 133
        if isinstance(start, (date, PointInTime)):
paul@944 134
            self.start = start
paul@944 135
        else:
paul@944 136
            self.start = get_datetime(start) or StartOfTime()
paul@944 137
        if isinstance(end, (date, PointInTime)):
paul@944 138
            self.end = end
paul@944 139
        else:
paul@944 140
            self.end = get_datetime(end) or EndOfTime()
paul@944 141
paul@646 142
    def as_tuple(self):
paul@646 143
        return self.start, self.end
paul@646 144
paul@646 145
    def __hash__(self):
paul@646 146
        return hash((self.get_start(), self.get_end()))
paul@646 147
paul@646 148
    def __cmp__(self, other):
paul@646 149
paul@646 150
        "Return a comparison result against 'other' using points in time."
paul@646 151
paul@646 152
        if isinstance(other, PeriodBase):
paul@646 153
            return cmp(
paul@924 154
                (Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(self.get_end_point(), EndOfTime()))),
paul@924 155
                (Comparable(ifnone(other.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime())))
paul@646 156
                )
paul@646 157
        else:
paul@646 158
            return 1
paul@646 159
paul@874 160
    def overlaps(self, other):
paul@924 161
        return Comparable(ifnone(self.get_end_point(), EndOfTime())) > Comparable(ifnone(other.get_start_point(), StartOfTime())) and \
paul@924 162
               Comparable(ifnone(self.get_start_point(), StartOfTime())) < Comparable(ifnone(other.get_end_point(), EndOfTime()))
paul@874 163
paul@941 164
    def within(self, other):
paul@941 165
        return Comparable(ifnone(self.get_start_point(), StartOfTime())) >= Comparable(ifnone(other.get_start_point(), StartOfTime())) and \
paul@941 166
               Comparable(ifnone(self.get_end_point(), EndOfTime())) <= Comparable(ifnone(other.get_end_point(), EndOfTime()))
paul@941 167
paul@944 168
    def common(self, other):
paul@944 169
        start = max(Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_start_point(), StartOfTime())))
paul@944 170
        end = min(Comparable(ifnone(self.get_end_point(), EndOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime())))
paul@944 171
        if start <= end:
paul@944 172
            return self.make_corrected(start.dt, end.dt)
paul@944 173
        else:
paul@944 174
            return None
paul@944 175
paul@646 176
    def get_key(self):
paul@646 177
        return self.get_start(), self.get_end()
paul@646 178
paul@646 179
    # Datetime and metadata methods.
paul@646 180
paul@646 181
    def get_start(self):
paul@646 182
        return self.start
paul@646 183
paul@646 184
    def get_end(self):
paul@646 185
        return self.end
paul@646 186
paul@646 187
    def get_start_attr(self):
paul@646 188
        return get_datetime_attributes(self.start, self.tzid)
paul@646 189
paul@646 190
    def get_end_attr(self):
paul@646 191
        return get_datetime_attributes(self.end, self.tzid)
paul@646 192
paul@646 193
    def get_start_item(self):
paul@646 194
        return self.get_start(), self.get_start_attr()
paul@646 195
paul@646 196
    def get_end_item(self):
paul@646 197
        return self.get_end(), self.get_end_attr()
paul@646 198
paul@646 199
    def get_start_point(self):
paul@646 200
        return self.start
paul@646 201
paul@646 202
    def get_end_point(self):
paul@646 203
        return self.end
paul@646 204
paul@659 205
    def get_duration(self):
paul@944 206
        start = self.get_start_point()
paul@944 207
        end = self.get_end_point()
paul@944 208
        if start and end:
paul@944 209
            return end - start
paul@944 210
        else:
paul@944 211
            return Endless()
paul@659 212
paul@646 213
class Period(PeriodBase):
paul@646 214
paul@646 215
    "A simple period abstraction."
paul@646 216
paul@541 217
    def __init__(self, start, end, tzid=None, origin=None):
paul@541 218
paul@541 219
        """
paul@541 220
        Initialise a period with the given 'start' and 'end', having a
paul@541 221
        contextual 'tzid', if specified, and an indicated 'origin'.
paul@620 222
paul@620 223
        All metadata from the start and end points are derived from the supplied
paul@620 224
        dates/datetimes.
paul@541 225
        """
paul@541 226
paul@944 227
        PeriodBase.__init__(self, start, end)
paul@541 228
        self.tzid = tzid
paul@528 229
        self.origin = origin
paul@458 230
paul@458 231
    def as_tuple(self):
paul@541 232
        return self.start, self.end, self.tzid, self.origin
paul@458 233
paul@458 234
    def __repr__(self):
paul@630 235
        return "Period%r" % (self.as_tuple(),)
paul@458 236
paul@646 237
    # Datetime and metadata methods.
paul@528 238
paul@541 239
    def get_tzid(self):
paul@620 240
        return get_tzid(self.get_start_attr(), self.get_end_attr()) or self.tzid
paul@620 241
paul@557 242
    def get_start_point(self):
paul@874 243
        start = self.get_start()
paul@924 244
        if isinstance(start, PointInTime): return start
paul@924 245
        else: return to_utc_datetime(start, self.get_tzid())
paul@557 246
paul@557 247
    def get_end_point(self):
paul@874 248
        end = self.get_end()
paul@924 249
        if isinstance(end, PointInTime): return end
paul@924 250
        else: return to_utc_datetime(end, self.get_tzid())
paul@557 251
paul@633 252
    # Period and event recurrence logic.
paul@633 253
paul@633 254
    def is_replaced(self, recurrenceids):
paul@633 255
paul@633 256
        """
paul@633 257
        Return whether this period refers to one of the 'recurrenceids'.
paul@633 258
        The 'recurrenceids' should be normalised to UTC datetimes according to
paul@633 259
        time zone information provided by their objects or be floating dates or
paul@633 260
        datetimes requiring conversion using contextual time zone information.
paul@633 261
        """
paul@633 262
paul@633 263
        for recurrenceid in recurrenceids:
paul@647 264
            if self.is_affected(recurrenceid):
paul@633 265
                return recurrenceid
paul@633 266
        return None
paul@633 267
paul@633 268
    def is_affected(self, recurrenceid):
paul@633 269
paul@633 270
        """
paul@633 271
        Return whether this period refers to 'recurrenceid'. The 'recurrenceid'
paul@633 272
        should be normalised to UTC datetimes according to time zone information
paul@633 273
        provided by their objects. Otherwise, this period's contextual time zone
paul@633 274
        information is used to convert any date or floating datetime
paul@633 275
        representation to a point in time.
paul@633 276
        """
paul@633 277
paul@633 278
        if not recurrenceid:
paul@633 279
            return None
paul@633 280
        d = get_recurrence_start(recurrenceid)
paul@633 281
        dt = get_recurrence_start_point(recurrenceid, self.tzid)
paul@633 282
        if self.get_start() == d or self.get_start_point() == dt:
paul@633 283
            return recurrenceid
paul@633 284
        return None
paul@633 285
paul@939 286
    # Value correction methods.
paul@939 287
paul@941 288
    def with_duration(self, duration):
paul@939 289
paul@941 290
        """
paul@941 291
        Return a version of this period with the same start point but with the
paul@941 292
        given 'duration'.
paul@941 293
        """
paul@941 294
paul@941 295
        return self.make_corrected(self.get_start(), self.get_start() + duration)
paul@941 296
paul@941 297
    def check_permitted(self, permitted_values):
paul@941 298
paul@941 299
        "Check the period against the given 'permitted_values'."
paul@939 300
paul@939 301
        start = self.get_start()
paul@939 302
        end = self.get_end()
paul@939 303
        start_errors = check_permitted_values(start, permitted_values)
paul@939 304
        end_errors = check_permitted_values(end, permitted_values)
paul@939 305
paul@939 306
        if not (start_errors or end_errors):
paul@941 307
            return None
paul@941 308
paul@941 309
        return start_errors, end_errors
paul@941 310
paul@941 311
    def get_corrected(self, permitted_values):
paul@941 312
paul@941 313
        "Return a corrected version of this period."
paul@941 314
paul@941 315
        errors = self.check_permitted(permitted_values)
paul@941 316
paul@941 317
        if not errors:
paul@939 318
            return self
paul@939 319
paul@941 320
        start_errors, end_errors = errors
paul@941 321
paul@952 322
        start = self.get_start()
paul@952 323
        end = self.get_end()
paul@952 324
paul@939 325
        if start_errors:
paul@939 326
            start = correct_datetime(start, permitted_values)
paul@939 327
        if end_errors:
paul@939 328
            end = correct_datetime(end, permitted_values)
paul@939 329
paul@939 330
        return self.make_corrected(start, end)
paul@939 331
paul@939 332
    def make_corrected(self, start, end):
paul@939 333
        return self.__class__(start, end, self.tzid, self.origin)
paul@939 334
paul@646 335
class FreeBusyPeriod(PeriodBase):
paul@458 336
paul@458 337
    "A free/busy record abstraction."
paul@458 338
paul@710 339
    def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, summary=None, organiser=None, expires=None):
paul@529 340
paul@529 341
        """
paul@643 342
        Initialise a free/busy period with the given 'start' and 'end' points,
paul@646 343
        plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser'
paul@646 344
        details.
paul@710 345
paul@710 346
        An additional 'expires' parameter can be used to indicate an expiry
paul@710 347
        datetime in conjunction with free/busy offers made when countering
paul@710 348
        event proposals.
paul@529 349
        """
paul@529 350
paul@944 351
        PeriodBase.__init__(self, start, end)
paul@458 352
        self.uid = uid
paul@1066 353
        self.transp = transp or None
paul@1066 354
        self.recurrenceid = recurrenceid or None
paul@1066 355
        self.summary = summary or None
paul@1066 356
        self.organiser = organiser or None
paul@1066 357
        self.expires = expires or None
paul@458 358
paul@563 359
    def as_tuple(self, strings_only=False):
paul@563 360
paul@563 361
        """
paul@563 362
        Return the initialisation parameter tuple, converting false value
paul@563 363
        parameters to strings if 'strings_only' is set to a true value.
paul@563 364
        """
paul@563 365
paul@563 366
        null = lambda x: (strings_only and [""] or [x])[0]
paul@563 367
        return (
paul@630 368
            strings_only and format_datetime(self.get_start_point()) or self.start,
paul@630 369
            strings_only and format_datetime(self.get_end_point()) or self.end,
paul@563 370
            self.uid or null(self.uid),
paul@563 371
            self.transp or strings_only and "OPAQUE" or None,
paul@563 372
            self.recurrenceid or null(self.recurrenceid),
paul@563 373
            self.summary or null(self.summary),
paul@710 374
            self.organiser or null(self.organiser),
paul@710 375
            self.expires or null(self.expires)
paul@563 376
            )
paul@458 377
paul@458 378
    def __cmp__(self, other):
paul@541 379
paul@541 380
        """
paul@541 381
        Compare this object to 'other', employing the uid if the periods
paul@541 382
        involved are the same.
paul@541 383
        """
paul@541 384
paul@646 385
        result = PeriodBase.__cmp__(self, other)
paul@541 386
        if result == 0 and isinstance(other, FreeBusyPeriod):
paul@653 387
            return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid))
paul@458 388
        else:
paul@541 389
            return result
paul@458 390
paul@458 391
    def get_key(self):
paul@541 392
        return self.uid, self.recurrenceid, self.get_start()
paul@458 393
paul@458 394
    def __repr__(self):
paul@630 395
        return "FreeBusyPeriod%r" % (self.as_tuple(),)
paul@458 396
paul@944 397
    def get_tzid(self):
paul@944 398
        return "UTC"
paul@944 399
paul@679 400
    # Period and event recurrence logic.
paul@679 401
paul@679 402
    def is_replaced(self, recurrences):
paul@679 403
paul@679 404
        """
paul@679 405
        Return whether this period refers to one of the 'recurrences'.
paul@679 406
        The 'recurrences' must be UTC datetimes corresponding to the start of
paul@679 407
        the period described by a recurrence.
paul@679 408
        """
paul@679 409
paul@679 410
        for recurrence in recurrences:
paul@679 411
            if self.is_affected(recurrence):
paul@679 412
                return True
paul@679 413
        return False
paul@679 414
paul@679 415
    def is_affected(self, recurrence):
paul@679 416
paul@679 417
        """
paul@679 418
        Return whether this period refers to 'recurrence'. The 'recurrence' must
paul@679 419
        be a UTC datetime corresponding to the start of the period described by
paul@679 420
        a recurrence.
paul@679 421
        """
paul@679 422
paul@679 423
        return recurrence and self.get_start_point() == recurrence
paul@679 424
paul@944 425
    # Value correction methods.
paul@944 426
paul@944 427
    def make_corrected(self, start, end):
paul@944 428
        return self.__class__(start, end)
paul@944 429
paul@543 430
class RecurringPeriod(Period):
paul@543 431
    
paul@620 432
    """
paul@620 433
    A period with iCalendar metadata attributes and origin information from an
paul@620 434
    object.
paul@620 435
    """
paul@543 436
    
paul@543 437
    def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None):
paul@543 438
        Period.__init__(self, start, end, tzid, origin)
paul@543 439
        self.start_attr = start_attr
paul@543 440
        self.end_attr = end_attr
paul@543 441
paul@620 442
    def get_start_attr(self):
paul@620 443
        return self.start_attr
paul@543 444
paul@620 445
    def get_end_attr(self):
paul@620 446
        return self.end_attr
paul@543 447
paul@543 448
    def as_tuple(self):
paul@543 449
        return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr
paul@543 450
    
paul@543 451
    def __repr__(self):
paul@630 452
        return "RecurringPeriod%r" % (self.as_tuple(),)
paul@543 453
paul@939 454
    def make_corrected(self, start, end):
paul@939 455
        return self.__class__(start, end, self.tzid, self.origin, self.get_start_attr(), self.get_end_attr())
paul@939 456
paul@529 457
# Time and period management.
paul@48 458
paul@343 459
def can_schedule(freebusy, periods, uid, recurrenceid):
paul@221 460
paul@221 461
    """
paul@221 462
    Return whether the 'freebusy' list can accommodate the given 'periods'
paul@343 463
    employing the specified 'uid' and 'recurrenceid'.
paul@221 464
    """
paul@221 465
paul@221 466
    for conflict in have_conflict(freebusy, periods, True):
paul@465 467
        if conflict.uid != uid or conflict.recurrenceid != recurrenceid:
paul@221 468
            return False
paul@221 469
paul@221 470
    return True
paul@221 471
paul@72 472
def have_conflict(freebusy, periods, get_conflicts=False):
paul@72 473
paul@72 474
    """
paul@72 475
    Return whether any period in 'freebusy' overlaps with the given 'periods',
paul@72 476
    returning a collection of such overlapping periods if 'get_conflicts' is
paul@72 477
    set to a true value.
paul@72 478
    """
paul@72 479
paul@458 480
    conflicts = set()
paul@458 481
    for p in periods:
paul@458 482
        overlapping = period_overlaps(freebusy, p, get_conflicts)
paul@74 483
        if overlapping:
paul@72 484
            if get_conflicts:
paul@458 485
                conflicts.update(overlapping)
paul@72 486
            else:
paul@72 487
                return True
paul@74 488
paul@72 489
    if get_conflicts:
paul@72 490
        return conflicts
paul@72 491
    else:
paul@72 492
        return False
paul@72 493
paul@48 494
def insert_period(freebusy, period):
paul@362 495
paul@362 496
    "Insert into 'freebusy' the given 'period'."
paul@362 497
paul@653 498
    i = bisect_left(freebusy, period)
paul@653 499
    if i == len(freebusy):
paul@653 500
        freebusy.append(period)
paul@653 501
    elif freebusy[i] != period:
paul@653 502
        freebusy.insert(i, period)
paul@48 503
paul@957 504
def remove_periods(freebusy, periods):
paul@957 505
paul@957 506
    "Remove from 'freebusy' the given 'periods'."
paul@957 507
paul@957 508
    for period in periods:
paul@957 509
        i = bisect_left(freebusy, period)
paul@957 510
        if i < len(freebusy) and freebusy[i] == period:
paul@957 511
            del freebusy[i]
paul@957 512
paul@959 513
def remove_event_periods(freebusy, uid, recurrenceid=None):
paul@362 514
paul@362 515
    """
paul@362 516
    Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid'
paul@362 517
    (which if omitted causes the "parent" object's periods to be referenced).
paul@957 518
paul@957 519
    Return the removed periods.
paul@362 520
    """
paul@362 521
paul@957 522
    removed = []
paul@48 523
    i = 0
paul@48 524
    while i < len(freebusy):
paul@458 525
        fb = freebusy[i]
paul@458 526
        if fb.uid == uid and fb.recurrenceid == recurrenceid:
paul@957 527
            removed.append(freebusy[i])
paul@48 528
            del freebusy[i]
paul@48 529
        else:
paul@48 530
            i += 1
paul@48 531
paul@523 532
    return removed
paul@523 533
paul@382 534
def remove_additional_periods(freebusy, uid, recurrenceids=None):
paul@381 535
paul@381 536
    """
paul@381 537
    Remove from 'freebusy' all periods associated with 'uid' having a
paul@381 538
    recurrence identifier indicating an additional or modified period.
paul@382 539
paul@382 540
    If 'recurrenceids' is specified, remove all periods associated with 'uid'
paul@382 541
    that do not have a recurrence identifier in the given list.
paul@1043 542
paul@1043 543
    Return the removed periods.
paul@381 544
    """
paul@381 545
paul@1043 546
    removed = []
paul@381 547
    i = 0
paul@381 548
    while i < len(freebusy):
paul@458 549
        fb = freebusy[i]
paul@458 550
        if fb.uid == uid and fb.recurrenceid and (
paul@382 551
            recurrenceids is None or
paul@458 552
            recurrenceids is not None and fb.recurrenceid not in recurrenceids
paul@382 553
            ):
paul@1043 554
            removed.append(freebusy[i])
paul@381 555
            del freebusy[i]
paul@381 556
        else:
paul@381 557
            i += 1
paul@381 558
paul@1043 559
    return removed
paul@1043 560
paul@511 561
def remove_affected_period(freebusy, uid, start):
paul@362 562
paul@362 563
    """
paul@362 564
    Remove from 'freebusy' a period associated with 'uid' that provides an
paul@511 565
    occurrence starting at the given 'start' (provided by a recurrence
paul@511 566
    identifier, converted to a datetime). A recurrence identifier is used to
paul@511 567
    provide an alternative time period whilst also acting as a reference to the
paul@511 568
    originally-defined occurrence.
paul@1043 569
paul@1043 570
    Return any removed period in a list.
paul@362 571
    """
paul@362 572
paul@1043 573
    removed = []
paul@1043 574
paul@558 575
    search = Period(start, start)
paul@558 576
    found = bisect_left(freebusy, search)
paul@558 577
paul@376 578
    while found < len(freebusy):
paul@458 579
        fb = freebusy[found]
paul@376 580
paul@376 581
        # Stop looking if the start no longer matches the recurrence identifier.
paul@376 582
paul@558 583
        if fb.get_start_point() != search.get_start_point():
paul@1043 584
            break
paul@376 585
paul@376 586
        # If the period belongs to the parent object, remove it and return.
paul@376 587
paul@458 588
        if not fb.recurrenceid and uid == fb.uid:
paul@1043 589
            removed.append(freebusy[found])
paul@361 590
            del freebusy[found]
paul@376 591
            break
paul@376 592
paul@376 593
        # Otherwise, keep looking for a matching period.
paul@376 594
paul@376 595
        found += 1
paul@361 596
paul@1043 597
    return removed
paul@1043 598
paul@658 599
def periods_from(freebusy, period):
paul@658 600
paul@658 601
    "Return the entries in 'freebusy' at or after 'period'."
paul@658 602
paul@658 603
    first = bisect_left(freebusy, period)
paul@658 604
    return freebusy[first:]
paul@658 605
paul@658 606
def periods_until(freebusy, period):
paul@658 607
paul@658 608
    "Return the entries in 'freebusy' before 'period'."
paul@658 609
paul@658 610
    last = bisect_right(freebusy, Period(period.get_end(), period.get_end(), period.get_tzid()))
paul@658 611
    return freebusy[:last]
paul@658 612
paul@327 613
def get_overlapping(freebusy, period):
paul@327 614
paul@327 615
    """
paul@430 616
    Return the entries in 'freebusy' providing periods overlapping with
paul@327 617
    'period'.
paul@327 618
    """
paul@327 619
paul@430 620
    # Find the range of periods potentially overlapping the period in the
paul@430 621
    # free/busy collection.
paul@327 622
paul@658 623
    startpoints = periods_until(freebusy, period)
paul@327 624
paul@430 625
    # Find the range of periods potentially overlapping the period in a version
paul@430 626
    # of the free/busy collection sorted according to end datetimes.
paul@327 627
paul@874 628
    endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints]
paul@430 629
    endpoints.sort()
paul@874 630
    first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),))
paul@430 631
    endpoints = endpoints[first:]
paul@327 632
paul@430 633
    overlapping = set()
paul@327 634
paul@874 635
    for p, fb in endpoints:
paul@874 636
        if fb.overlaps(period):
paul@458 637
            overlapping.add(fb)
paul@327 638
paul@430 639
    overlapping = list(overlapping)
paul@430 640
    overlapping.sort()
paul@430 641
    return overlapping
paul@327 642
paul@74 643
def period_overlaps(freebusy, period, get_periods=False):
paul@72 644
paul@72 645
    """
paul@74 646
    Return whether any period in 'freebusy' overlaps with the given 'period',
paul@74 647
    returning a collection of overlapping periods if 'get_periods' is set to a
paul@74 648
    true value.
paul@72 649
    """
paul@72 650
paul@430 651
    overlapping = get_overlapping(freebusy, period)
paul@74 652
paul@74 653
    if get_periods:
paul@430 654
        return overlapping
paul@74 655
    else:
paul@430 656
        return len(overlapping) != 0
paul@327 657
paul@327 658
def remove_overlapping(freebusy, period):
paul@327 659
paul@327 660
    "Remove from 'freebusy' all periods overlapping with 'period'."
paul@327 661
paul@437 662
    overlapping = get_overlapping(freebusy, period)
paul@327 663
paul@437 664
    if overlapping:
paul@437 665
        for fb in overlapping:
paul@437 666
            freebusy.remove(fb)
paul@327 667
paul@327 668
def replace_overlapping(freebusy, period, replacements):
paul@327 669
paul@327 670
    """
paul@327 671
    Replace existing periods in 'freebusy' within the given 'period', using the
paul@327 672
    given 'replacements'.
paul@327 673
    """
paul@327 674
paul@327 675
    remove_overlapping(freebusy, period)
paul@327 676
    for replacement in replacements:
paul@327 677
        insert_period(freebusy, replacement)
paul@48 678
paul@658 679
def coalesce_freebusy(freebusy):
paul@658 680
paul@658 681
    "Coalesce the periods in 'freebusy'."
paul@658 682
paul@658 683
    if not freebusy:
paul@658 684
        return freebusy
paul@658 685
paul@658 686
    fb = []
paul@658 687
    start = freebusy[0].get_start_point()
paul@658 688
    end = freebusy[0].get_end_point()
paul@658 689
paul@658 690
    for period in freebusy[1:]:
paul@658 691
        if period.get_start_point() > end:
paul@658 692
            fb.append(FreeBusyPeriod(start, end))
paul@658 693
            start = period.get_start_point()
paul@658 694
            end = period.get_end_point()
paul@658 695
        else:
paul@658 696
            end = max(end, period.get_end_point())
paul@658 697
paul@658 698
    fb.append(FreeBusyPeriod(start, end))
paul@658 699
    return fb
paul@658 700
paul@658 701
def invert_freebusy(freebusy):
paul@658 702
paul@658 703
    "Return the free periods from 'freebusy'."
paul@658 704
paul@658 705
    if not freebusy:
paul@944 706
        return [FreeBusyPeriod(None, None)]
paul@944 707
paul@944 708
    # Coalesce periods that overlap or are adjacent.
paul@658 709
paul@658 710
    fb = coalesce_freebusy(freebusy)
paul@658 711
    free = []
paul@944 712
paul@944 713
    # Add a start-of-time period if appropriate.
paul@944 714
paul@944 715
    first = fb[0].get_start_point()
paul@944 716
    if first:
paul@944 717
        free.append(FreeBusyPeriod(None, first))
paul@944 718
paul@658 719
    start = fb[0].get_end_point()
paul@658 720
paul@658 721
    for period in fb[1:]:
paul@658 722
        free.append(FreeBusyPeriod(start, period.get_start_point()))
paul@658 723
        start = period.get_end_point()
paul@658 724
paul@944 725
    # Add an end-of-time period if appropriate.
paul@944 726
paul@944 727
    if start:
paul@944 728
        free.append(FreeBusyPeriod(start, None))
paul@944 729
paul@658 730
    return free
paul@658 731
paul@529 732
# Period layout.
paul@204 733
paul@884 734
def get_scale(periods, tzid, view_period=None):
paul@113 735
paul@113 736
    """
paul@925 737
    Return a time scale from the given list of 'periods'.
paul@153 738
paul@162 739
    The given 'tzid' is used to make sure that the times are defined according
paul@162 740
    to the chosen time zone.
paul@162 741
paul@884 742
    An optional 'view_period' is used to constrain the scale to the given
paul@884 743
    period.
paul@884 744
paul@162 745
    The returned scale is a mapping from time to (starting, ending) tuples,
paul@458 746
    where starting and ending are collections of periods.
paul@113 747
    """
paul@113 748
paul@113 749
    scale = {}
paul@884 750
    view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None
paul@884 751
    view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None
paul@113 752
paul@458 753
    for p in periods:
paul@113 754
paul@113 755
        # Add a point and this event to the starting list.
paul@113 756
paul@536 757
        start = to_timezone(p.get_start(), tzid)
paul@884 758
        start = view_start and max(start, view_start) or start
paul@536 759
        if not scale.has_key(start):
paul@536 760
            scale[start] = [], []
paul@536 761
        scale[start][0].append(p)
paul@113 762
paul@113 763
        # Add a point and this event to the ending list.
paul@113 764
paul@536 765
        end = to_timezone(p.get_end(), tzid)
paul@931 766
        end = view_end and min(end, view_end) or end
paul@931 767
        if not scale.has_key(end):
paul@931 768
            scale[end] = [], []
paul@931 769
        scale[end][1].append(p)
paul@113 770
paul@113 771
    return scale
paul@113 772
paul@455 773
class Point:
paul@455 774
paul@455 775
    "A qualified point in time."
paul@455 776
paul@455 777
    PRINCIPAL, REPEATED = 0, 1
paul@455 778
paul@455 779
    def __init__(self, point, indicator=None):
paul@455 780
        self.point = point
paul@455 781
        self.indicator = indicator or self.PRINCIPAL
paul@455 782
paul@455 783
    def __hash__(self):
paul@455 784
        return hash((self.point, self.indicator))
paul@455 785
paul@455 786
    def __cmp__(self, other):
paul@455 787
        if isinstance(other, Point):
paul@455 788
            return cmp((self.point, self.indicator), (other.point, other.indicator))
paul@455 789
        elif isinstance(other, datetime):
paul@455 790
            return cmp(self.point, other)
paul@455 791
        else:
paul@455 792
            return 1
paul@455 793
paul@455 794
    def __eq__(self, other):
paul@455 795
        return self.__cmp__(other) == 0
paul@455 796
paul@455 797
    def __ne__(self, other):
paul@455 798
        return not self == other
paul@455 799
paul@455 800
    def __lt__(self, other):
paul@455 801
        return self.__cmp__(other) < 0
paul@455 802
paul@455 803
    def __le__(self, other):
paul@455 804
        return self.__cmp__(other) <= 0
paul@455 805
paul@455 806
    def __gt__(self, other):
paul@455 807
        return not self <= other
paul@455 808
paul@455 809
    def __ge__(self, other):
paul@455 810
        return not self < other
paul@455 811
paul@455 812
    def __repr__(self):
paul@455 813
        return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL")
paul@452 814
paul@162 815
def get_slots(scale):
paul@113 816
paul@113 817
    """
paul@162 818
    Return an ordered list of time slots from the given 'scale'.
paul@113 819
paul@452 820
    Each slot is a tuple containing details of a point in time for the start of
paul@458 821
    the slot, together with a list of parallel event periods.
paul@452 822
paul@455 823
    Each point in time is described as a Point representing the actual point in
paul@455 824
    time together with an indicator of the nature of the point in time (as a
paul@455 825
    principal point in a time scale or as a repeated point used to terminate
paul@455 826
    events occurring for an instant in time).
paul@113 827
    """
paul@113 828
paul@113 829
    slots = []
paul@113 830
    active = []
paul@113 831
paul@162 832
    points = scale.items()
paul@162 833
    points.sort()
paul@162 834
paul@162 835
    for point, (starting, ending) in points:
paul@449 836
        ending = set(ending)
paul@449 837
        instants = ending.intersection(starting)
paul@113 838
paul@113 839
        # Discard all active events ending at or before this start time.
paul@161 840
        # Free up the position in the active list.
paul@113 841
paul@449 842
        for t in ending.difference(instants):
paul@113 843
            i = active.index(t)
paul@113 844
            active[i] = None
paul@113 845
paul@161 846
        # For each event starting at the current point, fill any newly-vacated
paul@161 847
        # position or add to the end of the active list.
paul@161 848
paul@113 849
        for t in starting:
paul@113 850
            try:
paul@113 851
                i = active.index(None)
paul@113 852
                active[i] = t
paul@113 853
            except ValueError:
paul@113 854
                active.append(t)
paul@113 855
paul@161 856
        # Discard vacant positions from the end of the active list.
paul@161 857
paul@113 858
        while active and active[-1] is None:
paul@113 859
            active.pop()
paul@113 860
paul@452 861
        # Add an entry for the time point before "instants".
paul@452 862
paul@455 863
        slots.append((Point(point), active[:]))
paul@113 864
paul@449 865
        # Discard events ending at the same time as they began.
paul@449 866
paul@449 867
        if instants:
paul@449 868
            for t in instants:
paul@449 869
                i = active.index(t)
paul@449 870
                active[i] = None
paul@449 871
paul@449 872
            # Discard vacant positions from the end of the active list.
paul@449 873
paul@449 874
            while active and active[-1] is None:
paul@449 875
                active.pop()
paul@449 876
paul@452 877
            # Add another entry for the time point after "instants".
paul@449 878
paul@455 879
            slots.append((Point(point, Point.REPEATED), active[:]))
paul@449 880
paul@113 881
    return slots
paul@113 882
paul@244 883
def add_day_start_points(slots, tzid):
paul@153 884
paul@153 885
    """
paul@162 886
    Introduce into the 'slots' any day start points required by multi-day
paul@244 887
    periods. The 'tzid' is required to make sure that appropriate time zones
paul@244 888
    are chosen and not necessarily those provided by the existing time points.
paul@153 889
    """
paul@153 890
paul@162 891
    new_slots = []
paul@153 892
    current_date = None
paul@200 893
    previously_active = []
paul@153 894
paul@455 895
    for point, active in slots:
paul@455 896
        start_of_day = get_start_of_day(point.point, tzid)
paul@455 897
        this_date = point.point.date()
paul@153 898
paul@198 899
        # For each new day, add a slot for the start of the day where periods
paul@198 900
        # are active and where no such slot already exists.
paul@153 901
paul@153 902
        if this_date != current_date:
paul@414 903
paul@414 904
            # Fill in days where events remain active.
paul@414 905
paul@414 906
            if current_date:
paul@414 907
                current_date += timedelta(1)
paul@414 908
                while current_date < this_date:
paul@455 909
                    new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active))
paul@414 910
                    current_date += timedelta(1)
paul@414 911
            else:
paul@414 912
                current_date = this_date
paul@153 913
paul@153 914
            # Add any continuing periods.
paul@153 915
paul@455 916
            if point.point != start_of_day:
paul@455 917
                new_slots.append((Point(start_of_day), previously_active))
paul@153 918
paul@153 919
        # Add the currently active periods at this point in time.
paul@153 920
paul@153 921
        previously_active = active
paul@153 922
paul@162 923
    for t in new_slots:
paul@162 924
        insort_left(slots, t)
paul@162 925
paul@931 926
def remove_end_slot(slots, view_period):
paul@931 927
paul@931 928
    """
paul@931 929
    Remove from 'slots' any slot situated at the end of the given 'view_period'.
paul@931 930
    """
paul@931 931
paul@931 932
    end = view_period.get_end_point()
paul@931 933
    if not end or not slots:
paul@931 934
        return
paul@931 935
    i = bisect_left(slots, (Point(end), None))
paul@931 936
    if i < len(slots):
paul@931 937
        del slots[i:]
paul@931 938
paul@162 939
def add_slots(slots, points):
paul@162 940
paul@162 941
    """
paul@162 942
    Introduce into the 'slots' entries for those in 'points' that are not
paul@170 943
    already present, propagating active periods from time points preceding
paul@170 944
    those added.
paul@162 945
    """
paul@162 946
paul@162 947
    new_slots = []
paul@162 948
paul@162 949
    for point in points:
paul@452 950
        i = bisect_left(slots, (point,)) # slots is [(point, active)...]
paul@162 951
        if i < len(slots) and slots[i][0] == point:
paul@162 952
            continue
paul@162 953
paul@170 954
        new_slots.append((point, i > 0 and slots[i-1][1] or []))
paul@162 955
paul@162 956
    for t in new_slots:
paul@162 957
        insort_left(slots, t)
paul@162 958
paul@162 959
def partition_by_day(slots):
paul@162 960
paul@162 961
    """
paul@162 962
    Return a mapping from dates to time points provided by 'slots'.
paul@162 963
    """
paul@162 964
paul@162 965
    d = {}
paul@162 966
paul@455 967
    for point, value in slots:
paul@455 968
        day = point.point.date()
paul@162 969
        if not d.has_key(day):
paul@162 970
            d[day] = []
paul@455 971
        d[day].append((point, value))
paul@162 972
paul@162 973
    return d
paul@153 974
paul@876 975
def add_empty_days(days, tzid, start=None, end=None):
paul@279 976
paul@876 977
    """
paul@876 978
    Add empty days to 'days' between busy days, and optionally from the given
paul@876 979
    'start' day and until the given 'end' day.
paul@876 980
    """
paul@279 981
paul@888 982
    last_day = start - timedelta(1)
paul@279 983
    all_days = days.keys()
paul@279 984
    all_days.sort()
paul@279 985
paul@279 986
    for day in all_days:
paul@279 987
        if last_day:
paul@279 988
            empty_day = last_day + timedelta(1)
paul@279 989
            while empty_day < day:
paul@455 990
                days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]
paul@279 991
                empty_day += timedelta(1)
paul@876 992
        last_day = day
paul@876 993
paul@876 994
    if end:
paul@876 995
        empty_day = last_day + timedelta(1)
paul@876 996
        while empty_day < end:
paul@876 997
            days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]
paul@876 998
            empty_day += timedelta(1)
paul@279 999
paul@114 1000
def get_spans(slots):
paul@114 1001
paul@533 1002
    "Inspect the given 'slots', returning a mapping of period keys to spans."
paul@114 1003
paul@455 1004
    points = [point for point, active in slots]
paul@114 1005
    spans = {}
paul@114 1006
paul@449 1007
    for _point, active in slots:
paul@458 1008
        for p in active:
paul@458 1009
            if p:
paul@458 1010
                key = p.get_key()
paul@529 1011
                start_slot = bisect_left(points, p.get_start())
paul@529 1012
                end_slot = bisect_left(points, p.get_end())
paul@185 1013
                spans[key] = end_slot - start_slot
paul@114 1014
paul@114 1015
    return spans
paul@114 1016
paul@740 1017
def update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None):
paul@250 1018
paul@250 1019
    """
paul@395 1020
    Update the free/busy details with the given 'periods', 'transp' setting,
paul@395 1021
    'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.
paul@740 1022
paul@740 1023
    An optional 'expires' datetime string indicates the expiry time of any
paul@740 1024
    free/busy offer.
paul@250 1025
    """
paul@250 1026
paul@959 1027
    remove_event_periods(freebusy, uid, recurrenceid)
paul@250 1028
paul@458 1029
    for p in periods:
paul@740 1030
        insert_period(freebusy, FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires))
paul@250 1031
paul@48 1032
# vim: tabstop=4 expandtab shiftwidth=4