imip-agent

Annotated imiptools/period.py

1062:2264ab469f6d
2016-03-02 Paul Boddie Introduced a free/busy collection abstraction for potential access and representation efficiency improvements. freebusy-collections
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@458 353
        self.transp = transp
paul@458 354
        self.recurrenceid = recurrenceid
paul@458 355
        self.summary = summary
paul@458 356
        self.organiser = organiser
paul@710 357
        self.expires = expires
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@1062 457
class FreeBusyCollection:
paul@1062 458
paul@1062 459
    "An abstraction for a collection of free/busy periods."
paul@1062 460
paul@1062 461
    def __init__(self, periods=None):
paul@1062 462
paul@1062 463
        """
paul@1062 464
        Initialise the collection with the given list of 'periods', or start an
paul@1062 465
        empty collection if no list is given.
paul@1062 466
        """
paul@1062 467
paul@1062 468
        self.periods = periods or []
paul@1062 469
paul@1062 470
    # List emulation methods.
paul@48 471
paul@1062 472
    def __list__(self):
paul@1062 473
        return self.periods
paul@1062 474
paul@1062 475
    def __iter__(self):
paul@1062 476
        return iter(self.periods)
paul@1062 477
paul@1062 478
    def __len__(self):
paul@1062 479
        return len(self.periods)
paul@1062 480
paul@1062 481
    def __getitem__(self, i):
paul@1062 482
        return self.periods[i]
paul@1062 483
paul@1062 484
    def __iadd__(self, other):
paul@1062 485
        for period in other:
paul@1062 486
            self.insert_period(period)
paul@1062 487
        return self
paul@1062 488
paul@1062 489
    # Operations.
paul@221 490
paul@1062 491
    def can_schedule(self, periods, uid, recurrenceid):
paul@1062 492
paul@1062 493
        """
paul@1062 494
        Return whether the collection can accommodate the given 'periods'
paul@1062 495
        employing the specified 'uid' and 'recurrenceid'.
paul@1062 496
        """
paul@1062 497
paul@1062 498
        for conflict in self.have_conflict(periods, True):
paul@1062 499
            if conflict.uid != uid or conflict.recurrenceid != recurrenceid:
paul@1062 500
                return False
paul@1062 501
paul@1062 502
        return True
paul@1062 503
paul@1062 504
    def have_conflict(self, periods, get_conflicts=False):
paul@221 505
paul@1062 506
        """
paul@1062 507
        Return whether any period in the collection overlaps with the given
paul@1062 508
        'periods', returning a collection of such overlapping periods if
paul@1062 509
        'get_conflicts' is set to a true value.
paul@1062 510
        """
paul@1062 511
paul@1062 512
        conflicts = set()
paul@1062 513
        for p in periods:
paul@1062 514
            overlapping = self.period_overlaps(p, get_conflicts)
paul@1062 515
            if overlapping:
paul@1062 516
                if get_conflicts:
paul@1062 517
                    conflicts.update(overlapping)
paul@1062 518
                else:
paul@1062 519
                    return True
paul@1062 520
paul@1062 521
        if get_conflicts:
paul@1062 522
            return conflicts
paul@1062 523
        else:
paul@221 524
            return False
paul@221 525
paul@1062 526
    def insert_period(self, period):
paul@221 527
paul@1062 528
        "Insert the given 'period' into the collection."
paul@72 529
paul@1062 530
        i = bisect_left(self.periods, period)
paul@1062 531
        if i == len(self.periods):
paul@1062 532
            self.periods.append(period)
paul@1062 533
        elif self.periods[i] != period:
paul@1062 534
            self.periods.insert(i, period)
paul@1062 535
paul@1062 536
    def remove_periods(self, periods):
paul@72 537
paul@1062 538
        "Remove the given 'periods' from the collection."
paul@1062 539
paul@1062 540
        for period in periods:
paul@1062 541
            i = bisect_left(self.periods, period)
paul@1062 542
            if i < len(self.periods) and self.periods[i] == period:
paul@1062 543
                del self.periods[i]
paul@74 544
paul@1062 545
    def remove_event_periods(self, uid, recurrenceid=None):
paul@72 546
paul@1062 547
        """
paul@1062 548
        Remove from the collection all periods associated with 'uid' and
paul@1062 549
        'recurrenceid' (which if omitted causes the "parent" object's periods to
paul@1062 550
        be referenced).
paul@362 551
paul@1062 552
        Return the removed periods.
paul@1062 553
        """
paul@957 554
paul@1062 555
        removed = []
paul@1062 556
        i = 0
paul@1062 557
        while i < len(self.periods):
paul@1062 558
            fb = self.periods[i]
paul@1062 559
            if fb.uid == uid and fb.recurrenceid == recurrenceid:
paul@1062 560
                removed.append(self.periods[i])
paul@1062 561
                del self.periods[i]
paul@1062 562
            else:
paul@1062 563
                i += 1
paul@362 564
paul@1062 565
        return removed
paul@957 566
paul@1062 567
    def remove_additional_periods(self, uid, recurrenceids=None):
paul@362 568
paul@1062 569
        """
paul@1062 570
        Remove from the collection all periods associated with 'uid' having a
paul@1062 571
        recurrence identifier indicating an additional or modified period.
paul@48 572
paul@1062 573
        If 'recurrenceids' is specified, remove all periods associated with
paul@1062 574
        'uid' that do not have a recurrence identifier in the given list.
paul@1062 575
paul@1062 576
        Return the removed periods.
paul@1062 577
        """
paul@523 578
paul@1062 579
        removed = []
paul@1062 580
        i = 0
paul@1062 581
        while i < len(self.periods):
paul@1062 582
            fb = self.periods[i]
paul@1062 583
            if fb.uid == uid and fb.recurrenceid and (
paul@1062 584
                recurrenceids is None or
paul@1062 585
                recurrenceids is not None and fb.recurrenceid not in recurrenceids
paul@1062 586
                ):
paul@1062 587
                removed.append(self.periods[i])
paul@1062 588
                del self.periods[i]
paul@1062 589
            else:
paul@1062 590
                i += 1
paul@382 591
paul@1062 592
        return removed
paul@1043 593
paul@1062 594
    def remove_affected_period(self, uid, start):
paul@381 595
paul@1062 596
        """
paul@1062 597
        Remove from the collection the period associated with 'uid' that
paul@1062 598
        provides an occurrence starting at the given 'start' (provided by a
paul@1062 599
        recurrence identifier, converted to a datetime). A recurrence identifier
paul@1062 600
        is used to provide an alternative time period whilst also acting as a
paul@1062 601
        reference to the originally-defined occurrence.
paul@1062 602
paul@1062 603
        Return any removed period in a list.
paul@1062 604
        """
paul@381 605
paul@1062 606
        removed = []
paul@1062 607
paul@1062 608
        search = Period(start, start)
paul@1062 609
        found = bisect_left(self.periods, search)
paul@1043 610
paul@1062 611
        while found < len(self.periods):
paul@1062 612
            fb = self.periods[found]
paul@1062 613
paul@1062 614
            # Stop looking if the start no longer matches the recurrence identifier.
paul@362 615
paul@1062 616
            if fb.get_start_point() != search.get_start_point():
paul@1062 617
                break
paul@1062 618
paul@1062 619
            # If the period belongs to the parent object, remove it and return.
paul@1043 620
paul@1062 621
            if not fb.recurrenceid and uid == fb.uid:
paul@1062 622
                removed.append(self.periods[found])
paul@1062 623
                del self.periods[found]
paul@1062 624
                break
paul@1043 625
paul@1062 626
            # Otherwise, keep looking for a matching period.
paul@1062 627
paul@1062 628
            found += 1
paul@558 629
paul@1062 630
        return removed
paul@1062 631
paul@1062 632
    def periods_from(self, period):
paul@376 633
paul@1062 634
        "Return the entries in the collection at or after 'period'."
paul@376 635
paul@1062 636
        first = bisect_left(self.periods, period)
paul@1062 637
        return self.periods[first:]
paul@376 638
paul@1062 639
    def periods_until(self, period):
paul@1062 640
paul@1062 641
        "Return the entries in the collection before 'period'."
paul@376 642
paul@1062 643
        last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid()))
paul@1062 644
        return self.periods[:last]
paul@1062 645
paul@1062 646
    def get_overlapping(self, period):
paul@376 647
paul@1062 648
        """
paul@1062 649
        Return the entries in the collection providing periods overlapping with
paul@1062 650
        'period'.
paul@1062 651
        """
paul@658 652
paul@1062 653
        # Find the range of periods potentially overlapping the period in the
paul@1062 654
        # free/busy collection.
paul@658 655
paul@1062 656
        startpoints = self.periods_until(period)
paul@658 657
paul@1062 658
        # Find the range of periods potentially overlapping the period in a version
paul@1062 659
        # of the free/busy collection sorted according to end datetimes.
paul@658 660
paul@1062 661
        endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints]
paul@1062 662
        endpoints.sort()
paul@1062 663
        first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),))
paul@1062 664
        endpoints = endpoints[first:]
paul@658 665
paul@1062 666
        overlapping = set()
paul@658 667
paul@1062 668
        for p, fb in endpoints:
paul@1062 669
            if fb.overlaps(period):
paul@1062 670
                overlapping.add(fb)
paul@327 671
paul@1062 672
        overlapping = list(overlapping)
paul@1062 673
        overlapping.sort()
paul@1062 674
        return overlapping
paul@327 675
paul@1062 676
    def period_overlaps(self, period, get_periods=False):
paul@327 677
paul@1062 678
        """
paul@1062 679
        Return whether any period in the collection overlaps with the given
paul@1062 680
        'period', returning a collection of overlapping periods if 'get_periods'
paul@1062 681
        is set to a true value.
paul@1062 682
        """
paul@327 683
paul@1062 684
        overlapping = self.get_overlapping(period)
paul@327 685
paul@1062 686
        if get_periods:
paul@1062 687
            return overlapping
paul@1062 688
        else:
paul@1062 689
            return len(overlapping) != 0
paul@327 690
paul@1062 691
    def remove_overlapping(self, period):
paul@327 692
paul@1062 693
        "Remove all periods overlapping with 'period' from the collection."
paul@1062 694
paul@1062 695
        overlapping = self.get_overlapping(period)
paul@72 696
paul@1062 697
        if overlapping:
paul@1062 698
            for fb in overlapping:
paul@1062 699
                self.periods.remove(fb)
paul@72 700
paul@1062 701
    def replace_overlapping(self, period, replacements):
paul@74 702
paul@1062 703
        """
paul@1062 704
        Replace existing periods in the collection within the given 'period',
paul@1062 705
        using the given 'replacements'.
paul@1062 706
        """
paul@327 707
paul@1062 708
        self.remove_overlapping(period)
paul@1062 709
        for replacement in replacements:
paul@1062 710
            self.insert_period(replacement)
paul@327 711
paul@1062 712
    def coalesce_freebusy(self):
paul@327 713
paul@1062 714
        "Coalesce the periods in the collection, returning a new collection."
paul@327 715
paul@1062 716
        if not self.periods:
paul@1062 717
            return FreeBusyCollection(self.periods)
paul@327 718
paul@1062 719
        fb = []
paul@1062 720
        start = self.periods[0].get_start_point()
paul@1062 721
        end = self.periods[0].get_end_point()
paul@327 722
paul@1062 723
        for period in self.periods[1:]:
paul@1062 724
            if period.get_start_point() > end:
paul@1062 725
                fb.append(FreeBusyPeriod(start, end))
paul@1062 726
                start = period.get_start_point()
paul@1062 727
                end = period.get_end_point()
paul@1062 728
            else:
paul@1062 729
                end = max(end, period.get_end_point())
paul@658 730
paul@1062 731
        fb.append(FreeBusyPeriod(start, end))
paul@1062 732
        return FreeBusyCollection(fb)
paul@658 733
paul@1062 734
    def invert_freebusy(self):
paul@1062 735
paul@1062 736
        "Return the free periods from the collection as a new collection."
paul@658 737
paul@1062 738
        if not self.periods:
paul@1062 739
            return FreeBusyCollection([FreeBusyPeriod(None, None)])
paul@1062 740
paul@1062 741
        # Coalesce periods that overlap or are adjacent.
paul@658 742
paul@1062 743
        fb = self.coalesce_freebusy()
paul@1062 744
        free = []
paul@1062 745
paul@1062 746
        # Add a start-of-time period if appropriate.
paul@658 747
paul@1062 748
        first = fb[0].get_start_point()
paul@1062 749
        if first:
paul@1062 750
            free.append(FreeBusyPeriod(None, first))
paul@658 751
paul@1062 752
        start = fb[0].get_end_point()
paul@658 753
paul@1062 754
        for period in fb[1:]:
paul@1062 755
            free.append(FreeBusyPeriod(start, period.get_start_point()))
paul@1062 756
            start = period.get_end_point()
paul@658 757
paul@1062 758
        # Add an end-of-time period if appropriate.
paul@944 759
paul@1062 760
        if start:
paul@1062 761
            free.append(FreeBusyPeriod(start, None))
paul@944 762
paul@1062 763
        return FreeBusyCollection(free)
paul@1062 764
paul@1062 765
    def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None):
paul@944 766
paul@1062 767
        """
paul@1062 768
        Update the free/busy details with the given 'periods', 'transp' setting,
paul@1062 769
        'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.
paul@658 770
paul@1062 771
        An optional 'expires' datetime string indicates the expiry time of any
paul@1062 772
        free/busy offer.
paul@1062 773
        """
paul@658 774
paul@1062 775
        self.remove_event_periods(uid, recurrenceid)
paul@944 776
paul@1062 777
        for p in periods:
paul@1062 778
            self.insert_period(FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires))
paul@658 779
paul@529 780
# Period layout.
paul@204 781
paul@884 782
def get_scale(periods, tzid, view_period=None):
paul@113 783
paul@113 784
    """
paul@925 785
    Return a time scale from the given list of 'periods'.
paul@153 786
paul@162 787
    The given 'tzid' is used to make sure that the times are defined according
paul@162 788
    to the chosen time zone.
paul@162 789
paul@884 790
    An optional 'view_period' is used to constrain the scale to the given
paul@884 791
    period.
paul@884 792
paul@162 793
    The returned scale is a mapping from time to (starting, ending) tuples,
paul@458 794
    where starting and ending are collections of periods.
paul@113 795
    """
paul@113 796
paul@113 797
    scale = {}
paul@884 798
    view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None
paul@884 799
    view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None
paul@113 800
paul@458 801
    for p in periods:
paul@113 802
paul@113 803
        # Add a point and this event to the starting list.
paul@113 804
paul@536 805
        start = to_timezone(p.get_start(), tzid)
paul@884 806
        start = view_start and max(start, view_start) or start
paul@536 807
        if not scale.has_key(start):
paul@536 808
            scale[start] = [], []
paul@536 809
        scale[start][0].append(p)
paul@113 810
paul@113 811
        # Add a point and this event to the ending list.
paul@113 812
paul@536 813
        end = to_timezone(p.get_end(), tzid)
paul@931 814
        end = view_end and min(end, view_end) or end
paul@931 815
        if not scale.has_key(end):
paul@931 816
            scale[end] = [], []
paul@931 817
        scale[end][1].append(p)
paul@113 818
paul@113 819
    return scale
paul@113 820
paul@455 821
class Point:
paul@455 822
paul@455 823
    "A qualified point in time."
paul@455 824
paul@455 825
    PRINCIPAL, REPEATED = 0, 1
paul@455 826
paul@455 827
    def __init__(self, point, indicator=None):
paul@455 828
        self.point = point
paul@455 829
        self.indicator = indicator or self.PRINCIPAL
paul@455 830
paul@455 831
    def __hash__(self):
paul@455 832
        return hash((self.point, self.indicator))
paul@455 833
paul@455 834
    def __cmp__(self, other):
paul@455 835
        if isinstance(other, Point):
paul@455 836
            return cmp((self.point, self.indicator), (other.point, other.indicator))
paul@455 837
        elif isinstance(other, datetime):
paul@455 838
            return cmp(self.point, other)
paul@455 839
        else:
paul@455 840
            return 1
paul@455 841
paul@455 842
    def __eq__(self, other):
paul@455 843
        return self.__cmp__(other) == 0
paul@455 844
paul@455 845
    def __ne__(self, other):
paul@455 846
        return not self == other
paul@455 847
paul@455 848
    def __lt__(self, other):
paul@455 849
        return self.__cmp__(other) < 0
paul@455 850
paul@455 851
    def __le__(self, other):
paul@455 852
        return self.__cmp__(other) <= 0
paul@455 853
paul@455 854
    def __gt__(self, other):
paul@455 855
        return not self <= other
paul@455 856
paul@455 857
    def __ge__(self, other):
paul@455 858
        return not self < other
paul@455 859
paul@455 860
    def __repr__(self):
paul@455 861
        return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL")
paul@452 862
paul@162 863
def get_slots(scale):
paul@113 864
paul@113 865
    """
paul@162 866
    Return an ordered list of time slots from the given 'scale'.
paul@113 867
paul@452 868
    Each slot is a tuple containing details of a point in time for the start of
paul@458 869
    the slot, together with a list of parallel event periods.
paul@452 870
paul@455 871
    Each point in time is described as a Point representing the actual point in
paul@455 872
    time together with an indicator of the nature of the point in time (as a
paul@455 873
    principal point in a time scale or as a repeated point used to terminate
paul@455 874
    events occurring for an instant in time).
paul@113 875
    """
paul@113 876
paul@113 877
    slots = []
paul@113 878
    active = []
paul@113 879
paul@162 880
    points = scale.items()
paul@162 881
    points.sort()
paul@162 882
paul@162 883
    for point, (starting, ending) in points:
paul@449 884
        ending = set(ending)
paul@449 885
        instants = ending.intersection(starting)
paul@113 886
paul@113 887
        # Discard all active events ending at or before this start time.
paul@161 888
        # Free up the position in the active list.
paul@113 889
paul@449 890
        for t in ending.difference(instants):
paul@113 891
            i = active.index(t)
paul@113 892
            active[i] = None
paul@113 893
paul@161 894
        # For each event starting at the current point, fill any newly-vacated
paul@161 895
        # position or add to the end of the active list.
paul@161 896
paul@113 897
        for t in starting:
paul@113 898
            try:
paul@113 899
                i = active.index(None)
paul@113 900
                active[i] = t
paul@113 901
            except ValueError:
paul@113 902
                active.append(t)
paul@113 903
paul@161 904
        # Discard vacant positions from the end of the active list.
paul@161 905
paul@113 906
        while active and active[-1] is None:
paul@113 907
            active.pop()
paul@113 908
paul@452 909
        # Add an entry for the time point before "instants".
paul@452 910
paul@455 911
        slots.append((Point(point), active[:]))
paul@113 912
paul@449 913
        # Discard events ending at the same time as they began.
paul@449 914
paul@449 915
        if instants:
paul@449 916
            for t in instants:
paul@449 917
                i = active.index(t)
paul@449 918
                active[i] = None
paul@449 919
paul@449 920
            # Discard vacant positions from the end of the active list.
paul@449 921
paul@449 922
            while active and active[-1] is None:
paul@449 923
                active.pop()
paul@449 924
paul@452 925
            # Add another entry for the time point after "instants".
paul@449 926
paul@455 927
            slots.append((Point(point, Point.REPEATED), active[:]))
paul@449 928
paul@113 929
    return slots
paul@113 930
paul@244 931
def add_day_start_points(slots, tzid):
paul@153 932
paul@153 933
    """
paul@162 934
    Introduce into the 'slots' any day start points required by multi-day
paul@244 935
    periods. The 'tzid' is required to make sure that appropriate time zones
paul@244 936
    are chosen and not necessarily those provided by the existing time points.
paul@153 937
    """
paul@153 938
paul@162 939
    new_slots = []
paul@153 940
    current_date = None
paul@200 941
    previously_active = []
paul@153 942
paul@455 943
    for point, active in slots:
paul@455 944
        start_of_day = get_start_of_day(point.point, tzid)
paul@455 945
        this_date = point.point.date()
paul@153 946
paul@198 947
        # For each new day, add a slot for the start of the day where periods
paul@198 948
        # are active and where no such slot already exists.
paul@153 949
paul@153 950
        if this_date != current_date:
paul@414 951
paul@414 952
            # Fill in days where events remain active.
paul@414 953
paul@414 954
            if current_date:
paul@414 955
                current_date += timedelta(1)
paul@414 956
                while current_date < this_date:
paul@455 957
                    new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active))
paul@414 958
                    current_date += timedelta(1)
paul@414 959
            else:
paul@414 960
                current_date = this_date
paul@153 961
paul@153 962
            # Add any continuing periods.
paul@153 963
paul@455 964
            if point.point != start_of_day:
paul@455 965
                new_slots.append((Point(start_of_day), previously_active))
paul@153 966
paul@153 967
        # Add the currently active periods at this point in time.
paul@153 968
paul@153 969
        previously_active = active
paul@153 970
paul@162 971
    for t in new_slots:
paul@162 972
        insort_left(slots, t)
paul@162 973
paul@931 974
def remove_end_slot(slots, view_period):
paul@931 975
paul@931 976
    """
paul@931 977
    Remove from 'slots' any slot situated at the end of the given 'view_period'.
paul@931 978
    """
paul@931 979
paul@931 980
    end = view_period.get_end_point()
paul@931 981
    if not end or not slots:
paul@931 982
        return
paul@931 983
    i = bisect_left(slots, (Point(end), None))
paul@931 984
    if i < len(slots):
paul@931 985
        del slots[i:]
paul@931 986
paul@162 987
def add_slots(slots, points):
paul@162 988
paul@162 989
    """
paul@162 990
    Introduce into the 'slots' entries for those in 'points' that are not
paul@170 991
    already present, propagating active periods from time points preceding
paul@170 992
    those added.
paul@162 993
    """
paul@162 994
paul@162 995
    new_slots = []
paul@162 996
paul@162 997
    for point in points:
paul@452 998
        i = bisect_left(slots, (point,)) # slots is [(point, active)...]
paul@162 999
        if i < len(slots) and slots[i][0] == point:
paul@162 1000
            continue
paul@162 1001
paul@170 1002
        new_slots.append((point, i > 0 and slots[i-1][1] or []))
paul@162 1003
paul@162 1004
    for t in new_slots:
paul@162 1005
        insort_left(slots, t)
paul@162 1006
paul@162 1007
def partition_by_day(slots):
paul@162 1008
paul@162 1009
    """
paul@162 1010
    Return a mapping from dates to time points provided by 'slots'.
paul@162 1011
    """
paul@162 1012
paul@162 1013
    d = {}
paul@162 1014
paul@455 1015
    for point, value in slots:
paul@455 1016
        day = point.point.date()
paul@162 1017
        if not d.has_key(day):
paul@162 1018
            d[day] = []
paul@455 1019
        d[day].append((point, value))
paul@162 1020
paul@162 1021
    return d
paul@153 1022
paul@876 1023
def add_empty_days(days, tzid, start=None, end=None):
paul@279 1024
paul@876 1025
    """
paul@876 1026
    Add empty days to 'days' between busy days, and optionally from the given
paul@876 1027
    'start' day and until the given 'end' day.
paul@876 1028
    """
paul@279 1029
paul@888 1030
    last_day = start - timedelta(1)
paul@279 1031
    all_days = days.keys()
paul@279 1032
    all_days.sort()
paul@279 1033
paul@279 1034
    for day in all_days:
paul@279 1035
        if last_day:
paul@279 1036
            empty_day = last_day + timedelta(1)
paul@279 1037
            while empty_day < day:
paul@455 1038
                days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]
paul@279 1039
                empty_day += timedelta(1)
paul@876 1040
        last_day = day
paul@876 1041
paul@876 1042
    if end:
paul@876 1043
        empty_day = last_day + timedelta(1)
paul@876 1044
        while empty_day < end:
paul@876 1045
            days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]
paul@876 1046
            empty_day += timedelta(1)
paul@279 1047
paul@114 1048
def get_spans(slots):
paul@114 1049
paul@533 1050
    "Inspect the given 'slots', returning a mapping of period keys to spans."
paul@114 1051
paul@455 1052
    points = [point for point, active in slots]
paul@114 1053
    spans = {}
paul@114 1054
paul@449 1055
    for _point, active in slots:
paul@458 1056
        for p in active:
paul@458 1057
            if p:
paul@458 1058
                key = p.get_key()
paul@529 1059
                start_slot = bisect_left(points, p.get_start())
paul@529 1060
                end_slot = bisect_left(points, p.get_end())
paul@185 1061
                spans[key] = end_slot - start_slot
paul@114 1062
paul@114 1063
    return spans
paul@114 1064
paul@48 1065
# vim: tabstop=4 expandtab shiftwidth=4