imip-agent

Annotated imiptools/period.py

1063:67369fd525db
2016-03-03 Paul Boddie Introduced a common free/busy collection abstraction and a specific database collection class. 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@1063 457
class FreeBusyCollectionBase:
paul@1062 458
paul@1063 459
    "Common operations on free/busy period collections."
paul@1062 460
paul@1062 461
    # List emulation methods.
paul@48 462
paul@1062 463
    def __iadd__(self, other):
paul@1062 464
        for period in other:
paul@1062 465
            self.insert_period(period)
paul@1062 466
        return self
paul@1062 467
paul@1062 468
    # Operations.
paul@221 469
paul@1062 470
    def can_schedule(self, periods, uid, recurrenceid):
paul@1062 471
paul@1062 472
        """
paul@1062 473
        Return whether the collection can accommodate the given 'periods'
paul@1062 474
        employing the specified 'uid' and 'recurrenceid'.
paul@1062 475
        """
paul@1062 476
paul@1062 477
        for conflict in self.have_conflict(periods, True):
paul@1062 478
            if conflict.uid != uid or conflict.recurrenceid != recurrenceid:
paul@1062 479
                return False
paul@1062 480
paul@1062 481
        return True
paul@1062 482
paul@1062 483
    def have_conflict(self, periods, get_conflicts=False):
paul@221 484
paul@1062 485
        """
paul@1062 486
        Return whether any period in the collection overlaps with the given
paul@1062 487
        'periods', returning a collection of such overlapping periods if
paul@1062 488
        'get_conflicts' is set to a true value.
paul@1062 489
        """
paul@1062 490
paul@1062 491
        conflicts = set()
paul@1062 492
        for p in periods:
paul@1062 493
            overlapping = self.period_overlaps(p, get_conflicts)
paul@1062 494
            if overlapping:
paul@1062 495
                if get_conflicts:
paul@1062 496
                    conflicts.update(overlapping)
paul@1062 497
                else:
paul@1062 498
                    return True
paul@1062 499
paul@1062 500
        if get_conflicts:
paul@1062 501
            return conflicts
paul@1062 502
        else:
paul@221 503
            return False
paul@221 504
paul@1063 505
    def period_overlaps(self, period, get_periods=False):
paul@1063 506
paul@1063 507
        """
paul@1063 508
        Return whether any period in the collection overlaps with the given
paul@1063 509
        'period', returning a collection of overlapping periods if 'get_periods'
paul@1063 510
        is set to a true value.
paul@1063 511
        """
paul@1063 512
paul@1063 513
        overlapping = self.get_overlapping(period)
paul@1063 514
paul@1063 515
        if get_periods:
paul@1063 516
            return overlapping
paul@1063 517
        else:
paul@1063 518
            return len(overlapping) != 0
paul@1063 519
paul@1063 520
    def replace_overlapping(self, period, replacements):
paul@1063 521
paul@1063 522
        """
paul@1063 523
        Replace existing periods in the collection within the given 'period',
paul@1063 524
        using the given 'replacements'.
paul@1063 525
        """
paul@1063 526
paul@1063 527
        self.remove_overlapping(period)
paul@1063 528
        for replacement in replacements:
paul@1063 529
            self.insert_period(replacement)
paul@1063 530
paul@1063 531
    def coalesce_freebusy(self):
paul@1063 532
paul@1063 533
        "Coalesce the periods in the collection, returning a new collection."
paul@1063 534
paul@1063 535
        if not self:
paul@1063 536
            return FreeBusyCollection()
paul@1063 537
paul@1063 538
        fb = []
paul@1063 539
paul@1063 540
        it = iter(self)
paul@1063 541
        period = it.next()
paul@1063 542
paul@1063 543
        start = period.get_start_point()
paul@1063 544
        end = period.get_end_point()
paul@1063 545
paul@1063 546
        try:
paul@1063 547
            while True:
paul@1063 548
                period = it.next()
paul@1063 549
                if period.get_start_point() > end:
paul@1063 550
                    fb.append(FreeBusyPeriod(start, end))
paul@1063 551
                    start = period.get_start_point()
paul@1063 552
                    end = period.get_end_point()
paul@1063 553
                else:
paul@1063 554
                    end = max(end, period.get_end_point())
paul@1063 555
        except StopIteration:
paul@1063 556
            pass
paul@1063 557
paul@1063 558
        fb.append(FreeBusyPeriod(start, end))
paul@1063 559
        return FreeBusyCollection(fb)
paul@1063 560
paul@1063 561
    def invert_freebusy(self):
paul@1063 562
paul@1063 563
        "Return the free periods from the collection as a new collection."
paul@1063 564
paul@1063 565
        if not self:
paul@1063 566
            return FreeBusyCollection([FreeBusyPeriod(None, None)])
paul@1063 567
paul@1063 568
        # Coalesce periods that overlap or are adjacent.
paul@1063 569
paul@1063 570
        fb = self.coalesce_freebusy()
paul@1063 571
        free = []
paul@1063 572
paul@1063 573
        # Add a start-of-time period if appropriate.
paul@1063 574
paul@1063 575
        first = fb[0].get_start_point()
paul@1063 576
        if first:
paul@1063 577
            free.append(FreeBusyPeriod(None, first))
paul@1063 578
paul@1063 579
        start = fb[0].get_end_point()
paul@1063 580
paul@1063 581
        for period in fb[1:]:
paul@1063 582
            free.append(FreeBusyPeriod(start, period.get_start_point()))
paul@1063 583
            start = period.get_end_point()
paul@1063 584
paul@1063 585
        # Add an end-of-time period if appropriate.
paul@1063 586
paul@1063 587
        if start:
paul@1063 588
            free.append(FreeBusyPeriod(start, None))
paul@1063 589
paul@1063 590
        return FreeBusyCollection(free)
paul@1063 591
paul@1063 592
    def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None):
paul@1063 593
paul@1063 594
        """
paul@1063 595
        Update the free/busy details with the given 'periods', 'transp' setting,
paul@1063 596
        'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.
paul@1063 597
paul@1063 598
        An optional 'expires' datetime string indicates the expiry time of any
paul@1063 599
        free/busy offer.
paul@1063 600
        """
paul@1063 601
paul@1063 602
        self.remove_event_periods(uid, recurrenceid)
paul@1063 603
paul@1063 604
        for p in periods:
paul@1063 605
            self.insert_period(FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires))
paul@1063 606
paul@1063 607
class FreeBusyCollection(FreeBusyCollectionBase):
paul@1063 608
paul@1063 609
    "An abstraction for a collection of free/busy periods."
paul@1063 610
paul@1063 611
    def __init__(self, periods=None):
paul@1063 612
paul@1063 613
        """
paul@1063 614
        Initialise the collection with the given list of 'periods', or start an
paul@1063 615
        empty collection if no list is given.
paul@1063 616
        """
paul@1063 617
paul@1063 618
        self.periods = periods or []
paul@1063 619
paul@1063 620
    # List emulation methods.
paul@1063 621
paul@1063 622
    def __nonzero__(self):
paul@1063 623
        return bool(self.periods)
paul@1063 624
paul@1063 625
    def __iter__(self):
paul@1063 626
        return iter(self.periods)
paul@1063 627
paul@1063 628
    def __len__(self):
paul@1063 629
        return len(self.periods)
paul@1063 630
paul@1063 631
    def __getitem__(self, i):
paul@1063 632
        return self.periods[i]
paul@1063 633
paul@1063 634
    # Operations.
paul@1063 635
paul@1062 636
    def insert_period(self, period):
paul@221 637
paul@1062 638
        "Insert the given 'period' into the collection."
paul@72 639
paul@1062 640
        i = bisect_left(self.periods, period)
paul@1062 641
        if i == len(self.periods):
paul@1062 642
            self.periods.append(period)
paul@1062 643
        elif self.periods[i] != period:
paul@1062 644
            self.periods.insert(i, period)
paul@1062 645
paul@1062 646
    def remove_periods(self, periods):
paul@72 647
paul@1062 648
        "Remove the given 'periods' from the collection."
paul@1062 649
paul@1062 650
        for period in periods:
paul@1062 651
            i = bisect_left(self.periods, period)
paul@1062 652
            if i < len(self.periods) and self.periods[i] == period:
paul@1062 653
                del self.periods[i]
paul@74 654
paul@1062 655
    def remove_event_periods(self, uid, recurrenceid=None):
paul@72 656
paul@1062 657
        """
paul@1062 658
        Remove from the collection all periods associated with 'uid' and
paul@1062 659
        'recurrenceid' (which if omitted causes the "parent" object's periods to
paul@1062 660
        be referenced).
paul@362 661
paul@1062 662
        Return the removed periods.
paul@1062 663
        """
paul@957 664
paul@1062 665
        removed = []
paul@1062 666
        i = 0
paul@1062 667
        while i < len(self.periods):
paul@1062 668
            fb = self.periods[i]
paul@1062 669
            if fb.uid == uid and fb.recurrenceid == recurrenceid:
paul@1062 670
                removed.append(self.periods[i])
paul@1062 671
                del self.periods[i]
paul@1062 672
            else:
paul@1062 673
                i += 1
paul@362 674
paul@1062 675
        return removed
paul@957 676
paul@1062 677
    def remove_additional_periods(self, uid, recurrenceids=None):
paul@362 678
paul@1062 679
        """
paul@1062 680
        Remove from the collection all periods associated with 'uid' having a
paul@1062 681
        recurrence identifier indicating an additional or modified period.
paul@48 682
paul@1062 683
        If 'recurrenceids' is specified, remove all periods associated with
paul@1062 684
        'uid' that do not have a recurrence identifier in the given list.
paul@1062 685
paul@1062 686
        Return the removed periods.
paul@1062 687
        """
paul@523 688
paul@1062 689
        removed = []
paul@1062 690
        i = 0
paul@1062 691
        while i < len(self.periods):
paul@1062 692
            fb = self.periods[i]
paul@1062 693
            if fb.uid == uid and fb.recurrenceid and (
paul@1062 694
                recurrenceids is None or
paul@1062 695
                recurrenceids is not None and fb.recurrenceid not in recurrenceids
paul@1062 696
                ):
paul@1062 697
                removed.append(self.periods[i])
paul@1062 698
                del self.periods[i]
paul@1062 699
            else:
paul@1062 700
                i += 1
paul@382 701
paul@1062 702
        return removed
paul@1043 703
paul@1062 704
    def remove_affected_period(self, uid, start):
paul@381 705
paul@1062 706
        """
paul@1062 707
        Remove from the collection the period associated with 'uid' that
paul@1062 708
        provides an occurrence starting at the given 'start' (provided by a
paul@1062 709
        recurrence identifier, converted to a datetime). A recurrence identifier
paul@1062 710
        is used to provide an alternative time period whilst also acting as a
paul@1062 711
        reference to the originally-defined occurrence.
paul@1062 712
paul@1062 713
        Return any removed period in a list.
paul@1062 714
        """
paul@381 715
paul@1062 716
        removed = []
paul@1062 717
paul@1062 718
        search = Period(start, start)
paul@1062 719
        found = bisect_left(self.periods, search)
paul@1043 720
paul@1062 721
        while found < len(self.periods):
paul@1062 722
            fb = self.periods[found]
paul@1062 723
paul@1062 724
            # Stop looking if the start no longer matches the recurrence identifier.
paul@362 725
paul@1062 726
            if fb.get_start_point() != search.get_start_point():
paul@1062 727
                break
paul@1062 728
paul@1062 729
            # If the period belongs to the parent object, remove it and return.
paul@1043 730
paul@1062 731
            if not fb.recurrenceid and uid == fb.uid:
paul@1062 732
                removed.append(self.periods[found])
paul@1062 733
                del self.periods[found]
paul@1062 734
                break
paul@1043 735
paul@1062 736
            # Otherwise, keep looking for a matching period.
paul@1062 737
paul@1062 738
            found += 1
paul@558 739
paul@1062 740
        return removed
paul@1062 741
paul@1062 742
    def periods_from(self, period):
paul@376 743
paul@1062 744
        "Return the entries in the collection at or after 'period'."
paul@376 745
paul@1062 746
        first = bisect_left(self.periods, period)
paul@1062 747
        return self.periods[first:]
paul@376 748
paul@1062 749
    def periods_until(self, period):
paul@1062 750
paul@1062 751
        "Return the entries in the collection before 'period'."
paul@376 752
paul@1062 753
        last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid()))
paul@1062 754
        return self.periods[:last]
paul@1062 755
paul@1062 756
    def get_overlapping(self, period):
paul@376 757
paul@1062 758
        """
paul@1062 759
        Return the entries in the collection providing periods overlapping with
paul@1062 760
        'period'.
paul@1062 761
        """
paul@658 762
paul@1062 763
        # Find the range of periods potentially overlapping the period in the
paul@1062 764
        # free/busy collection.
paul@658 765
paul@1062 766
        startpoints = self.periods_until(period)
paul@658 767
paul@1062 768
        # Find the range of periods potentially overlapping the period in a version
paul@1062 769
        # of the free/busy collection sorted according to end datetimes.
paul@658 770
paul@1062 771
        endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints]
paul@1062 772
        endpoints.sort()
paul@1062 773
        first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),))
paul@1062 774
        endpoints = endpoints[first:]
paul@658 775
paul@1062 776
        overlapping = set()
paul@658 777
paul@1062 778
        for p, fb in endpoints:
paul@1062 779
            if fb.overlaps(period):
paul@1062 780
                overlapping.add(fb)
paul@327 781
paul@1062 782
        overlapping = list(overlapping)
paul@1062 783
        overlapping.sort()
paul@1062 784
        return overlapping
paul@327 785
paul@1062 786
    def remove_overlapping(self, period):
paul@327 787
paul@1062 788
        "Remove all periods overlapping with 'period' from the collection."
paul@1062 789
paul@1062 790
        overlapping = self.get_overlapping(period)
paul@72 791
paul@1062 792
        if overlapping:
paul@1062 793
            for fb in overlapping:
paul@1062 794
                self.periods.remove(fb)
paul@72 795
paul@1063 796
class FreeBusyDatabaseCollection(FreeBusyCollectionBase):
paul@1063 797
paul@1063 798
    """
paul@1063 799
    An abstraction for a collection of free/busy periods stored in a database
paul@1063 800
    system.
paul@1063 801
    """
paul@1063 802
paul@1063 803
    def __init__(self, cursor, table_name):
paul@74 804
paul@1062 805
        """
paul@1063 806
        Initialise the collection with the given 'cursor' and 'table_name'.
paul@1063 807
        """
paul@1063 808
paul@1063 809
        self.cursor = cursor
paul@1063 810
        self.table_name = table_name
paul@1063 811
paul@1063 812
    # Special database-related operations.
paul@1063 813
paul@1063 814
    def placeholders(self, values):
paul@1063 815
        return ", ".join(["?"] * len(values))
paul@1063 816
paul@1063 817
    def initialise(self):
paul@1063 818
paul@1063 819
        "Create the database table required to hold the collection."
paul@1063 820
paul@1063 821
        query = """\
paul@1063 822
create table %(table)s (
paul@1063 823
    start varchar not null,
paul@1063 824
    end varchar not null,
paul@1063 825
    uid varchar,
paul@1063 826
    transp varchar,
paul@1063 827
    recurrenceid varchar,
paul@1063 828
    summary varchar,
paul@1063 829
    organiser varchar,
paul@1063 830
    expires varchar
paul@1063 831
    )""" % {"table" : self.table_name}
paul@1063 832
paul@1063 833
        self.cursor.execute(query)
paul@1063 834
paul@1063 835
    # List emulation methods.
paul@1063 836
paul@1063 837
    def __nonzero__(self):
paul@1063 838
        query = "select count(*) from %(table)s" % {"table" : self.table_name}
paul@1063 839
        self.cursor.execute(query)
paul@1063 840
        result = self.cursor.fetchone()
paul@1063 841
        return result[0]
paul@1063 842
paul@1063 843
    def __iter__(self):
paul@1063 844
        query = "select * from %(table)s" % {"table" : self.table_name}
paul@1063 845
        self.cursor.execute(query)
paul@1063 846
        return iter(map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall()))
paul@1063 847
paul@1063 848
    def __len__(self):
paul@1063 849
        return len(list(iter(self)))
paul@1063 850
paul@1063 851
    def __getitem__(self, i):
paul@1063 852
        return list(iter(self))[i]
paul@1063 853
paul@1063 854
    # Operations.
paul@1063 855
paul@1063 856
    def insert_period(self, period):
paul@1063 857
paul@1063 858
        "Insert the given 'period' into the collection."
paul@1063 859
paul@1063 860
        values = period.as_tuple(strings_only=True)
paul@1063 861
        query = "insert into %(table)s values (%(columns)s)" % {
paul@1063 862
            "table" : self.table_name,
paul@1063 863
            "columns" : self.placeholders(values)
paul@1063 864
            }
paul@1063 865
        self.cursor.execute(query, values)
paul@1063 866
paul@1063 867
    def remove_periods(self, periods):
paul@1063 868
paul@1063 869
        "Remove the given 'periods' from the collection."
paul@1063 870
paul@1063 871
        for period in periods:
paul@1063 872
            values = period.as_tuple(strings_only=True)
paul@1063 873
            query = """\
paul@1063 874
delete from %(table)s
paul@1063 875
where start = ? and end = ? and uid = ? and transp = ? and recurrenceid = ? and summary = ? and organiser = ? and expires = ?
paul@1063 876
""" % {"table" : self.table_name}
paul@1063 877
            self.cursor.execute(query, values)
paul@1063 878
paul@1063 879
    def remove_event_periods(self, uid, recurrenceid=None):
paul@1063 880
paul@1063 881
        """
paul@1063 882
        Remove from the collection all periods associated with 'uid' and
paul@1063 883
        'recurrenceid' (which if omitted causes the "parent" object's periods to
paul@1063 884
        be referenced).
paul@1063 885
paul@1063 886
        Return the removed periods.
paul@1062 887
        """
paul@327 888
paul@1063 889
        if recurrenceid:
paul@1063 890
            condition = "where uid = ? and recurrenceid = ?"
paul@1063 891
            values = (uid, recurrenceid)
paul@1063 892
        else:
paul@1063 893
            condition = "where uid = ?"
paul@1063 894
            values = (uid,)
paul@327 895
paul@1063 896
        query = "select * from %(table)s for update %(condition)s" % {
paul@1063 897
            "table" : self.table_name,
paul@1063 898
            "condition" : condition
paul@1063 899
            }
paul@1063 900
        self.cursor.execute(query, values)
paul@1063 901
        removed = self.cursor.fetchall()
paul@327 902
paul@1063 903
        query = "delete from %(table)s %(condition)s" % {
paul@1063 904
            "table" : self.table_name,
paul@1063 905
            "condition" : condition
paul@1063 906
            }
paul@1063 907
        self.cursor.execute(query, values)
paul@327 908
paul@1063 909
        return map(lambda t: FreeBusyPeriod(*t), removed)
paul@1063 910
paul@1063 911
    def remove_additional_periods(self, uid, recurrenceids=None):
paul@658 912
paul@1063 913
        """
paul@1063 914
        Remove from the collection all periods associated with 'uid' having a
paul@1063 915
        recurrence identifier indicating an additional or modified period.
paul@1062 916
paul@1063 917
        If 'recurrenceids' is specified, remove all periods associated with
paul@1063 918
        'uid' that do not have a recurrence identifier in the given list.
paul@658 919
paul@1063 920
        Return the removed periods.
paul@1063 921
        """
paul@1062 922
paul@1063 923
        if recurrenceids is None:
paul@1063 924
            condition = "where uid = ? and recurrenceid is not null"
paul@1063 925
            values = (uid,)
paul@1063 926
        else:
paul@1063 927
            condition = "where uid = ? and recurrenceid is not null and recurrenceid not in ?"
paul@1063 928
            values = (uid, recurrenceid)
paul@658 929
paul@1063 930
        query = "select * from %(table)s for update %(condition)s" % {
paul@1063 931
            "table" : self.table_name,
paul@1063 932
            "condition" : condition
paul@1063 933
            }
paul@1063 934
        self.cursor.execute(query, values)
paul@1063 935
        removed = self.cursor.fetchall()
paul@658 936
paul@1063 937
        query = "delete from %(table)s %(condition)s" % {
paul@1063 938
            "table" : self.table_name,
paul@1063 939
            "condition" : condition
paul@1063 940
            }
paul@1063 941
        self.cursor.execute(query, values)
paul@944 942
paul@1063 943
        return map(lambda t: FreeBusyPeriod(*t), removed)
paul@944 944
paul@1063 945
    def remove_affected_period(self, uid, start):
paul@944 946
paul@1062 947
        """
paul@1063 948
        Remove from the collection the period associated with 'uid' that
paul@1063 949
        provides an occurrence starting at the given 'start' (provided by a
paul@1063 950
        recurrence identifier, converted to a datetime). A recurrence identifier
paul@1063 951
        is used to provide an alternative time period whilst also acting as a
paul@1063 952
        reference to the originally-defined occurrence.
paul@658 953
paul@1063 954
        Return any removed period in a list.
paul@1062 955
        """
paul@658 956
paul@1063 957
        condition = "where uid = ? and start = ? and recurrenceid is null"
paul@1063 958
        values = (uid, start)
paul@1063 959
paul@1063 960
        query = "select * from %(table)s %(condition)s" % {
paul@1063 961
            "table" : self.table_name,
paul@1063 962
            "condition" : condition
paul@1063 963
            }
paul@1063 964
        self.cursor.execute(query, values)
paul@1063 965
        removed = self.cursor.fetchall()
paul@1063 966
paul@1063 967
        query = "delete from %(table)s %(condition)s" % {
paul@1063 968
            "table" : self.table_name,
paul@1063 969
            "condition" : condition
paul@1063 970
            }
paul@1063 971
        self.cursor.execute(query, values)
paul@1063 972
paul@1063 973
        return map(lambda t: FreeBusyPeriod(*t), removed)
paul@1063 974
paul@1063 975
    def periods_from(self, period):
paul@1063 976
paul@1063 977
        "Return the entries in the collection at or after 'period'."
paul@1063 978
paul@1063 979
        condition = "where start >= ?"
paul@1063 980
        values = (format_datetime(period.get_start_point()),)
paul@1063 981
paul@1063 982
        query = "select * from %(table)s %(condition)s" % {
paul@1063 983
            "table" : self.table_name,
paul@1063 984
            "condition" : condition
paul@1063 985
            }
paul@1063 986
        self.cursor.execute(query, values)
paul@1063 987
paul@1063 988
        return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())
paul@1063 989
paul@1063 990
    def periods_until(self, period):
paul@1063 991
paul@1063 992
        "Return the entries in the collection before 'period'."
paul@944 993
paul@1063 994
        condition = "where start < ?"
paul@1063 995
        values = (format_datetime(period.get_end_point()),)
paul@1063 996
paul@1063 997
        query = "select * from %(table)s %(condition)s" % {
paul@1063 998
            "table" : self.table_name,
paul@1063 999
            "condition" : condition
paul@1063 1000
            }
paul@1063 1001
        self.cursor.execute(query, values)
paul@1063 1002
paul@1063 1003
        return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())
paul@1063 1004
paul@1063 1005
    def get_overlapping(self, period):
paul@1063 1006
paul@1063 1007
        """
paul@1063 1008
        Return the entries in the collection providing periods overlapping with
paul@1063 1009
        'period'.
paul@1063 1010
        """
paul@1063 1011
paul@1063 1012
        condition = "where start < ? and end > ?"
paul@1063 1013
        values = (format_datetime(period.get_end_point()), format_datetime(period.get_start_point()))
paul@1063 1014
paul@1063 1015
        query = "select * from %(table)s %(condition)s" % {
paul@1063 1016
            "table" : self.table_name,
paul@1063 1017
            "condition" : condition
paul@1063 1018
            }
paul@1063 1019
        self.cursor.execute(query, values)
paul@1063 1020
paul@1063 1021
        return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())
paul@1063 1022
paul@1063 1023
    def remove_overlapping(self, period):
paul@1063 1024
paul@1063 1025
        "Remove all periods overlapping with 'period' from the collection."
paul@1063 1026
paul@1063 1027
        condition = "where start < ? and end > ?"
paul@1063 1028
        values = (format_datetime(period.get_end_point()), format_datetime(period.get_start_point()))
paul@1063 1029
paul@1063 1030
        query = "delete from %(table)s %(condition)s" % {
paul@1063 1031
            "table" : self.table_name,
paul@1063 1032
            "condition" : condition
paul@1063 1033
            }
paul@1063 1034
        self.cursor.execute(query, values)
paul@658 1035
paul@529 1036
# Period layout.
paul@204 1037
paul@884 1038
def get_scale(periods, tzid, view_period=None):
paul@113 1039
paul@113 1040
    """
paul@925 1041
    Return a time scale from the given list of 'periods'.
paul@153 1042
paul@162 1043
    The given 'tzid' is used to make sure that the times are defined according
paul@162 1044
    to the chosen time zone.
paul@162 1045
paul@884 1046
    An optional 'view_period' is used to constrain the scale to the given
paul@884 1047
    period.
paul@884 1048
paul@162 1049
    The returned scale is a mapping from time to (starting, ending) tuples,
paul@458 1050
    where starting and ending are collections of periods.
paul@113 1051
    """
paul@113 1052
paul@113 1053
    scale = {}
paul@884 1054
    view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None
paul@884 1055
    view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None
paul@113 1056
paul@458 1057
    for p in periods:
paul@113 1058
paul@113 1059
        # Add a point and this event to the starting list.
paul@113 1060
paul@536 1061
        start = to_timezone(p.get_start(), tzid)
paul@884 1062
        start = view_start and max(start, view_start) or start
paul@536 1063
        if not scale.has_key(start):
paul@536 1064
            scale[start] = [], []
paul@536 1065
        scale[start][0].append(p)
paul@113 1066
paul@113 1067
        # Add a point and this event to the ending list.
paul@113 1068
paul@536 1069
        end = to_timezone(p.get_end(), tzid)
paul@931 1070
        end = view_end and min(end, view_end) or end
paul@931 1071
        if not scale.has_key(end):
paul@931 1072
            scale[end] = [], []
paul@931 1073
        scale[end][1].append(p)
paul@113 1074
paul@113 1075
    return scale
paul@113 1076
paul@455 1077
class Point:
paul@455 1078
paul@455 1079
    "A qualified point in time."
paul@455 1080
paul@455 1081
    PRINCIPAL, REPEATED = 0, 1
paul@455 1082
paul@455 1083
    def __init__(self, point, indicator=None):
paul@455 1084
        self.point = point
paul@455 1085
        self.indicator = indicator or self.PRINCIPAL
paul@455 1086
paul@455 1087
    def __hash__(self):
paul@455 1088
        return hash((self.point, self.indicator))
paul@455 1089
paul@455 1090
    def __cmp__(self, other):
paul@455 1091
        if isinstance(other, Point):
paul@455 1092
            return cmp((self.point, self.indicator), (other.point, other.indicator))
paul@455 1093
        elif isinstance(other, datetime):
paul@455 1094
            return cmp(self.point, other)
paul@455 1095
        else:
paul@455 1096
            return 1
paul@455 1097
paul@455 1098
    def __eq__(self, other):
paul@455 1099
        return self.__cmp__(other) == 0
paul@455 1100
paul@455 1101
    def __ne__(self, other):
paul@455 1102
        return not self == other
paul@455 1103
paul@455 1104
    def __lt__(self, other):
paul@455 1105
        return self.__cmp__(other) < 0
paul@455 1106
paul@455 1107
    def __le__(self, other):
paul@455 1108
        return self.__cmp__(other) <= 0
paul@455 1109
paul@455 1110
    def __gt__(self, other):
paul@455 1111
        return not self <= other
paul@455 1112
paul@455 1113
    def __ge__(self, other):
paul@455 1114
        return not self < other
paul@455 1115
paul@455 1116
    def __repr__(self):
paul@455 1117
        return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL")
paul@452 1118
paul@162 1119
def get_slots(scale):
paul@113 1120
paul@113 1121
    """
paul@162 1122
    Return an ordered list of time slots from the given 'scale'.
paul@113 1123
paul@452 1124
    Each slot is a tuple containing details of a point in time for the start of
paul@458 1125
    the slot, together with a list of parallel event periods.
paul@452 1126
paul@455 1127
    Each point in time is described as a Point representing the actual point in
paul@455 1128
    time together with an indicator of the nature of the point in time (as a
paul@455 1129
    principal point in a time scale or as a repeated point used to terminate
paul@455 1130
    events occurring for an instant in time).
paul@113 1131
    """
paul@113 1132
paul@113 1133
    slots = []
paul@113 1134
    active = []
paul@113 1135
paul@162 1136
    points = scale.items()
paul@162 1137
    points.sort()
paul@162 1138
paul@162 1139
    for point, (starting, ending) in points:
paul@449 1140
        ending = set(ending)
paul@449 1141
        instants = ending.intersection(starting)
paul@113 1142
paul@113 1143
        # Discard all active events ending at or before this start time.
paul@161 1144
        # Free up the position in the active list.
paul@113 1145
paul@449 1146
        for t in ending.difference(instants):
paul@113 1147
            i = active.index(t)
paul@113 1148
            active[i] = None
paul@113 1149
paul@161 1150
        # For each event starting at the current point, fill any newly-vacated
paul@161 1151
        # position or add to the end of the active list.
paul@161 1152
paul@113 1153
        for t in starting:
paul@113 1154
            try:
paul@113 1155
                i = active.index(None)
paul@113 1156
                active[i] = t
paul@113 1157
            except ValueError:
paul@113 1158
                active.append(t)
paul@113 1159
paul@161 1160
        # Discard vacant positions from the end of the active list.
paul@161 1161
paul@113 1162
        while active and active[-1] is None:
paul@113 1163
            active.pop()
paul@113 1164
paul@452 1165
        # Add an entry for the time point before "instants".
paul@452 1166
paul@455 1167
        slots.append((Point(point), active[:]))
paul@113 1168
paul@449 1169
        # Discard events ending at the same time as they began.
paul@449 1170
paul@449 1171
        if instants:
paul@449 1172
            for t in instants:
paul@449 1173
                i = active.index(t)
paul@449 1174
                active[i] = None
paul@449 1175
paul@449 1176
            # Discard vacant positions from the end of the active list.
paul@449 1177
paul@449 1178
            while active and active[-1] is None:
paul@449 1179
                active.pop()
paul@449 1180
paul@452 1181
            # Add another entry for the time point after "instants".
paul@449 1182
paul@455 1183
            slots.append((Point(point, Point.REPEATED), active[:]))
paul@449 1184
paul@113 1185
    return slots
paul@113 1186
paul@244 1187
def add_day_start_points(slots, tzid):
paul@153 1188
paul@153 1189
    """
paul@162 1190
    Introduce into the 'slots' any day start points required by multi-day
paul@244 1191
    periods. The 'tzid' is required to make sure that appropriate time zones
paul@244 1192
    are chosen and not necessarily those provided by the existing time points.
paul@153 1193
    """
paul@153 1194
paul@162 1195
    new_slots = []
paul@153 1196
    current_date = None
paul@200 1197
    previously_active = []
paul@153 1198
paul@455 1199
    for point, active in slots:
paul@455 1200
        start_of_day = get_start_of_day(point.point, tzid)
paul@455 1201
        this_date = point.point.date()
paul@153 1202
paul@198 1203
        # For each new day, add a slot for the start of the day where periods
paul@198 1204
        # are active and where no such slot already exists.
paul@153 1205
paul@153 1206
        if this_date != current_date:
paul@414 1207
paul@414 1208
            # Fill in days where events remain active.
paul@414 1209
paul@414 1210
            if current_date:
paul@414 1211
                current_date += timedelta(1)
paul@414 1212
                while current_date < this_date:
paul@455 1213
                    new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active))
paul@414 1214
                    current_date += timedelta(1)
paul@414 1215
            else:
paul@414 1216
                current_date = this_date
paul@153 1217
paul@153 1218
            # Add any continuing periods.
paul@153 1219
paul@455 1220
            if point.point != start_of_day:
paul@455 1221
                new_slots.append((Point(start_of_day), previously_active))
paul@153 1222
paul@153 1223
        # Add the currently active periods at this point in time.
paul@153 1224
paul@153 1225
        previously_active = active
paul@153 1226
paul@162 1227
    for t in new_slots:
paul@162 1228
        insort_left(slots, t)
paul@162 1229
paul@931 1230
def remove_end_slot(slots, view_period):
paul@931 1231
paul@931 1232
    """
paul@931 1233
    Remove from 'slots' any slot situated at the end of the given 'view_period'.
paul@931 1234
    """
paul@931 1235
paul@931 1236
    end = view_period.get_end_point()
paul@931 1237
    if not end or not slots:
paul@931 1238
        return
paul@931 1239
    i = bisect_left(slots, (Point(end), None))
paul@931 1240
    if i < len(slots):
paul@931 1241
        del slots[i:]
paul@931 1242
paul@162 1243
def add_slots(slots, points):
paul@162 1244
paul@162 1245
    """
paul@162 1246
    Introduce into the 'slots' entries for those in 'points' that are not
paul@170 1247
    already present, propagating active periods from time points preceding
paul@170 1248
    those added.
paul@162 1249
    """
paul@162 1250
paul@162 1251
    new_slots = []
paul@162 1252
paul@162 1253
    for point in points:
paul@452 1254
        i = bisect_left(slots, (point,)) # slots is [(point, active)...]
paul@162 1255
        if i < len(slots) and slots[i][0] == point:
paul@162 1256
            continue
paul@162 1257
paul@170 1258
        new_slots.append((point, i > 0 and slots[i-1][1] or []))
paul@162 1259
paul@162 1260
    for t in new_slots:
paul@162 1261
        insort_left(slots, t)
paul@162 1262
paul@162 1263
def partition_by_day(slots):
paul@162 1264
paul@162 1265
    """
paul@162 1266
    Return a mapping from dates to time points provided by 'slots'.
paul@162 1267
    """
paul@162 1268
paul@162 1269
    d = {}
paul@162 1270
paul@455 1271
    for point, value in slots:
paul@455 1272
        day = point.point.date()
paul@162 1273
        if not d.has_key(day):
paul@162 1274
            d[day] = []
paul@455 1275
        d[day].append((point, value))
paul@162 1276
paul@162 1277
    return d
paul@153 1278
paul@876 1279
def add_empty_days(days, tzid, start=None, end=None):
paul@279 1280
paul@876 1281
    """
paul@876 1282
    Add empty days to 'days' between busy days, and optionally from the given
paul@876 1283
    'start' day and until the given 'end' day.
paul@876 1284
    """
paul@279 1285
paul@888 1286
    last_day = start - timedelta(1)
paul@279 1287
    all_days = days.keys()
paul@279 1288
    all_days.sort()
paul@279 1289
paul@279 1290
    for day in all_days:
paul@279 1291
        if last_day:
paul@279 1292
            empty_day = last_day + timedelta(1)
paul@279 1293
            while empty_day < day:
paul@455 1294
                days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]
paul@279 1295
                empty_day += timedelta(1)
paul@876 1296
        last_day = day
paul@876 1297
paul@876 1298
    if end:
paul@876 1299
        empty_day = last_day + timedelta(1)
paul@876 1300
        while empty_day < end:
paul@876 1301
            days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]
paul@876 1302
            empty_day += timedelta(1)
paul@279 1303
paul@114 1304
def get_spans(slots):
paul@114 1305
paul@533 1306
    "Inspect the given 'slots', returning a mapping of period keys to spans."
paul@114 1307
paul@455 1308
    points = [point for point, active in slots]
paul@114 1309
    spans = {}
paul@114 1310
paul@449 1311
    for _point, active in slots:
paul@458 1312
        for p in active:
paul@458 1313
            if p:
paul@458 1314
                key = p.get_key()
paul@529 1315
                start_slot = bisect_left(points, p.get_start())
paul@529 1316
                end_slot = bisect_left(points, p.get_end())
paul@185 1317
                spans[key] = end_slot - start_slot
paul@114 1318
paul@114 1319
    return spans
paul@114 1320
paul@48 1321
# vim: tabstop=4 expandtab shiftwidth=4