imip-agent

Annotated imiptools/period.py

466:8d2dfe43b629
2015-03-31 Paul Boddie Fixed free/busy period conversion to retain the original period instance types.
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@146 6
Copyright (C) 2014, 2015 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@279 23
from datetime import datetime, timedelta
paul@256 24
from imiptools.dates import get_datetime, get_start_of_day, to_timezone
paul@48 25
paul@458 26
class Period:
paul@458 27
paul@458 28
    "A basic period abstraction."
paul@458 29
paul@458 30
    def __init__(self, start, end=None):
paul@458 31
        self.start = start
paul@458 32
        self.end = end
paul@458 33
paul@458 34
    def as_tuple(self):
paul@458 35
        return self.start, self.end
paul@458 36
paul@458 37
    def __hash__(self):
paul@458 38
        return hash((self.start, self.end))
paul@458 39
paul@458 40
    def __cmp__(self, other):
paul@458 41
        if isinstance(other, Period):
paul@458 42
            return cmp((self.start, self.end), (other.start, other.end))
paul@458 43
        else:
paul@458 44
            return 1
paul@458 45
paul@458 46
    def get_key(self):
paul@458 47
        return self.start, self.end
paul@458 48
paul@458 49
    def __repr__(self):
paul@458 50
        return "Period(%r, %r)" % (self.start, self.end)
paul@458 51
paul@458 52
class FreeBusyPeriod(Period):
paul@458 53
paul@458 54
    "A free/busy record abstraction."
paul@458 55
paul@458 56
    def __init__(self, start, end=None, uid=None, transp=None, recurrenceid=None, summary=None, organiser=None):
paul@458 57
        Period.__init__(self, start, end)
paul@458 58
        self.uid = uid
paul@458 59
        self.transp = transp
paul@458 60
        self.recurrenceid = recurrenceid
paul@458 61
        self.summary = summary
paul@458 62
        self.organiser = organiser
paul@458 63
paul@458 64
    def as_tuple(self):
paul@458 65
        return self.start, self.end, self.uid, self.transp, self.recurrenceid, self.summary, self.organiser
paul@458 66
paul@458 67
    def __cmp__(self, other):
paul@458 68
        if isinstance(other, FreeBusyPeriod):
paul@458 69
            return cmp((self.start, self.end, self.uid), (other.start, other.end, other.uid))
paul@458 70
        else:
paul@458 71
            return Period.__cmp__(self, other)
paul@458 72
paul@458 73
    def get_key(self):
paul@458 74
        return self.uid, self.recurrenceid, self.start
paul@458 75
paul@458 76
    def __repr__(self):
paul@458 77
        return "FreeBusyPeriod(%r, %r, %r, %r, %r, %r, %r)" % (
paul@458 78
            self.start, self.end, self.uid, self.transp, self.recurrenceid,
paul@458 79
            self.summary, self.organiser)
paul@458 80
paul@291 81
# Time management with datetime strings in the UTC time zone.
paul@48 82
paul@343 83
def can_schedule(freebusy, periods, uid, recurrenceid):
paul@221 84
paul@221 85
    """
paul@221 86
    Return whether the 'freebusy' list can accommodate the given 'periods'
paul@343 87
    employing the specified 'uid' and 'recurrenceid'.
paul@221 88
    """
paul@221 89
paul@221 90
    for conflict in have_conflict(freebusy, periods, True):
paul@465 91
        if conflict.uid != uid or conflict.recurrenceid != recurrenceid:
paul@221 92
            return False
paul@221 93
paul@221 94
    return True
paul@221 95
paul@72 96
def have_conflict(freebusy, periods, get_conflicts=False):
paul@72 97
paul@72 98
    """
paul@72 99
    Return whether any period in 'freebusy' overlaps with the given 'periods',
paul@72 100
    returning a collection of such overlapping periods if 'get_conflicts' is
paul@72 101
    set to a true value.
paul@72 102
    """
paul@72 103
paul@458 104
    conflicts = set()
paul@458 105
    for p in periods:
paul@458 106
        overlapping = period_overlaps(freebusy, p, get_conflicts)
paul@74 107
        if overlapping:
paul@72 108
            if get_conflicts:
paul@458 109
                conflicts.update(overlapping)
paul@72 110
            else:
paul@72 111
                return True
paul@74 112
paul@72 113
    if get_conflicts:
paul@72 114
        return conflicts
paul@72 115
    else:
paul@72 116
        return False
paul@72 117
paul@48 118
def insert_period(freebusy, period):
paul@362 119
paul@362 120
    "Insert into 'freebusy' the given 'period'."
paul@362 121
paul@48 122
    insort_left(freebusy, period)
paul@48 123
paul@343 124
def remove_period(freebusy, uid, recurrenceid=None):
paul@362 125
paul@362 126
    """
paul@362 127
    Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid'
paul@362 128
    (which if omitted causes the "parent" object's periods to be referenced).
paul@362 129
    """
paul@362 130
paul@48 131
    i = 0
paul@48 132
    while i < len(freebusy):
paul@458 133
        fb = freebusy[i]
paul@458 134
        if fb.uid == uid and fb.recurrenceid == recurrenceid:
paul@48 135
            del freebusy[i]
paul@48 136
        else:
paul@48 137
            i += 1
paul@48 138
paul@382 139
def remove_additional_periods(freebusy, uid, recurrenceids=None):
paul@381 140
paul@381 141
    """
paul@381 142
    Remove from 'freebusy' all periods associated with 'uid' having a
paul@381 143
    recurrence identifier indicating an additional or modified period.
paul@382 144
paul@382 145
    If 'recurrenceids' is specified, remove all periods associated with 'uid'
paul@382 146
    that do not have a recurrence identifier in the given list.
paul@381 147
    """
paul@381 148
paul@381 149
    i = 0
paul@381 150
    while i < len(freebusy):
paul@458 151
        fb = freebusy[i]
paul@458 152
        if fb.uid == uid and fb.recurrenceid and (
paul@382 153
            recurrenceids is None or
paul@458 154
            recurrenceids is not None and fb.recurrenceid not in recurrenceids
paul@382 155
            ):
paul@381 156
            del freebusy[i]
paul@381 157
        else:
paul@381 158
            i += 1
paul@381 159
paul@361 160
def remove_affected_period(freebusy, uid, recurrenceid):
paul@362 161
paul@362 162
    """
paul@362 163
    Remove from 'freebusy' a period associated with 'uid' that provides an
paul@362 164
    occurrence starting at the given 'recurrenceid', where the recurrence
paul@362 165
    identifier is used to provide an alternative time period whilst also acting
paul@362 166
    as a reference to the originally-defined occurrence.
paul@362 167
    """
paul@362 168
paul@458 169
    found = bisect_left(freebusy, Period(recurrenceid))
paul@376 170
    while found < len(freebusy):
paul@458 171
        fb = freebusy[found]
paul@376 172
paul@376 173
        # Stop looking if the start no longer matches the recurrence identifier.
paul@376 174
paul@458 175
        if fb.start != recurrenceid:
paul@376 176
            return
paul@376 177
paul@376 178
        # If the period belongs to the parent object, remove it and return.
paul@376 179
paul@458 180
        if not fb.recurrenceid and uid == fb.uid:
paul@361 181
            del freebusy[found]
paul@376 182
            break
paul@376 183
paul@376 184
        # Otherwise, keep looking for a matching period.
paul@376 185
paul@376 186
        found += 1
paul@361 187
paul@327 188
def get_overlapping(freebusy, period):
paul@327 189
paul@327 190
    """
paul@430 191
    Return the entries in 'freebusy' providing periods overlapping with
paul@327 192
    'period'.
paul@327 193
    """
paul@327 194
paul@430 195
    # Find the range of periods potentially overlapping the period in the
paul@430 196
    # free/busy collection.
paul@327 197
paul@458 198
    last = bisect_right(freebusy, Period(period.end))
paul@430 199
    startpoints = freebusy[:last]
paul@327 200
paul@430 201
    # Find the range of periods potentially overlapping the period in a version
paul@430 202
    # of the free/busy collection sorted according to end datetimes.
paul@327 203
paul@458 204
    endpoints = [(fb.end, fb.start, fb) for fb in startpoints]
paul@430 205
    endpoints.sort()
paul@458 206
    first = bisect_left(endpoints, (period.start,))
paul@430 207
    endpoints = endpoints[first:]
paul@327 208
paul@430 209
    overlapping = set()
paul@327 210
paul@458 211
    for end, start, fb in endpoints:
paul@458 212
        if end > period.start and start < period.end:
paul@458 213
            overlapping.add(fb)
paul@327 214
paul@430 215
    overlapping = list(overlapping)
paul@430 216
    overlapping.sort()
paul@430 217
    return overlapping
paul@327 218
paul@74 219
def period_overlaps(freebusy, period, get_periods=False):
paul@72 220
paul@72 221
    """
paul@74 222
    Return whether any period in 'freebusy' overlaps with the given 'period',
paul@74 223
    returning a collection of overlapping periods if 'get_periods' is set to a
paul@74 224
    true value.
paul@72 225
    """
paul@72 226
paul@430 227
    overlapping = get_overlapping(freebusy, period)
paul@74 228
paul@74 229
    if get_periods:
paul@430 230
        return overlapping
paul@74 231
    else:
paul@430 232
        return len(overlapping) != 0
paul@327 233
paul@327 234
def remove_overlapping(freebusy, period):
paul@327 235
paul@327 236
    "Remove from 'freebusy' all periods overlapping with 'period'."
paul@327 237
paul@437 238
    overlapping = get_overlapping(freebusy, period)
paul@327 239
paul@437 240
    if overlapping:
paul@437 241
        for fb in overlapping:
paul@437 242
            freebusy.remove(fb)
paul@327 243
paul@327 244
def replace_overlapping(freebusy, period, replacements):
paul@327 245
paul@327 246
    """
paul@327 247
    Replace existing periods in 'freebusy' within the given 'period', using the
paul@327 248
    given 'replacements'.
paul@327 249
    """
paul@327 250
paul@327 251
    remove_overlapping(freebusy, period)
paul@327 252
    for replacement in replacements:
paul@327 253
        insert_period(freebusy, replacement)
paul@48 254
paul@240 255
# Period layout mostly with datetime objects.
paul@113 256
paul@162 257
def convert_periods(periods, tzid):
paul@162 258
paul@162 259
    "Convert 'periods' to use datetime objects employing the given 'tzid'."
paul@162 260
paul@458 261
    for p in periods:
paul@204 262
paul@204 263
        # NOTE: This only really works if the datetimes are UTC already.
paul@232 264
        # NOTE: Since the periods should originate from the free/busy data,
paul@232 265
        # NOTE: and since that data should employ UTC times, this should not be
paul@232 266
        # NOTE: an immediate problem.
paul@204 267
paul@458 268
        start = get_datetime(p.start)
paul@458 269
        end = get_datetime(p.end)
paul@232 270
paul@232 271
        start = isinstance(start, datetime) and to_timezone(start, tzid) or get_start_of_day(start, tzid)
paul@232 272
        end = isinstance(end, datetime) and to_timezone(end, tzid) or get_start_of_day(end, tzid)
paul@232 273
paul@458 274
        p.start = start
paul@458 275
        p.end = end
paul@162 276
paul@162 277
def get_scale(periods):
paul@113 278
paul@113 279
    """
paul@458 280
    Return an ordered time scale from the given list of 'periods'.
paul@153 281
paul@162 282
    The given 'tzid' is used to make sure that the times are defined according
paul@162 283
    to the chosen time zone.
paul@162 284
paul@162 285
    The returned scale is a mapping from time to (starting, ending) tuples,
paul@458 286
    where starting and ending are collections of periods.
paul@113 287
    """
paul@113 288
paul@113 289
    scale = {}
paul@113 290
paul@458 291
    for p in periods:
paul@113 292
paul@113 293
        # Add a point and this event to the starting list.
paul@113 294
paul@458 295
        if not scale.has_key(p.start):
paul@458 296
            scale[p.start] = [], []
paul@458 297
        scale[p.start][0].append(p)
paul@113 298
paul@113 299
        # Add a point and this event to the ending list.
paul@113 300
paul@458 301
        if not scale.has_key(p.end):
paul@458 302
            scale[p.end] = [], []
paul@458 303
        scale[p.end][1].append(p)
paul@113 304
paul@113 305
    return scale
paul@113 306
paul@455 307
class Point:
paul@455 308
paul@455 309
    "A qualified point in time."
paul@455 310
paul@455 311
    PRINCIPAL, REPEATED = 0, 1
paul@455 312
paul@455 313
    def __init__(self, point, indicator=None):
paul@455 314
        self.point = point
paul@455 315
        self.indicator = indicator or self.PRINCIPAL
paul@455 316
paul@455 317
    def __hash__(self):
paul@455 318
        return hash((self.point, self.indicator))
paul@455 319
paul@455 320
    def __cmp__(self, other):
paul@455 321
        if isinstance(other, Point):
paul@455 322
            return cmp((self.point, self.indicator), (other.point, other.indicator))
paul@455 323
        elif isinstance(other, datetime):
paul@455 324
            return cmp(self.point, other)
paul@455 325
        else:
paul@455 326
            return 1
paul@455 327
paul@455 328
    def __eq__(self, other):
paul@455 329
        return self.__cmp__(other) == 0
paul@455 330
paul@455 331
    def __ne__(self, other):
paul@455 332
        return not self == other
paul@455 333
paul@455 334
    def __lt__(self, other):
paul@455 335
        return self.__cmp__(other) < 0
paul@455 336
paul@455 337
    def __le__(self, other):
paul@455 338
        return self.__cmp__(other) <= 0
paul@455 339
paul@455 340
    def __gt__(self, other):
paul@455 341
        return not self <= other
paul@455 342
paul@455 343
    def __ge__(self, other):
paul@455 344
        return not self < other
paul@455 345
paul@455 346
    def __repr__(self):
paul@455 347
        return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL")
paul@452 348
paul@162 349
def get_slots(scale):
paul@113 350
paul@113 351
    """
paul@162 352
    Return an ordered list of time slots from the given 'scale'.
paul@113 353
paul@452 354
    Each slot is a tuple containing details of a point in time for the start of
paul@458 355
    the slot, together with a list of parallel event periods.
paul@452 356
paul@455 357
    Each point in time is described as a Point representing the actual point in
paul@455 358
    time together with an indicator of the nature of the point in time (as a
paul@455 359
    principal point in a time scale or as a repeated point used to terminate
paul@455 360
    events occurring for an instant in time).
paul@113 361
    """
paul@113 362
paul@113 363
    slots = []
paul@113 364
    active = []
paul@113 365
paul@162 366
    points = scale.items()
paul@162 367
    points.sort()
paul@162 368
paul@162 369
    for point, (starting, ending) in points:
paul@449 370
        ending = set(ending)
paul@449 371
        instants = ending.intersection(starting)
paul@113 372
paul@113 373
        # Discard all active events ending at or before this start time.
paul@161 374
        # Free up the position in the active list.
paul@113 375
paul@449 376
        for t in ending.difference(instants):
paul@113 377
            i = active.index(t)
paul@113 378
            active[i] = None
paul@113 379
paul@161 380
        # For each event starting at the current point, fill any newly-vacated
paul@161 381
        # position or add to the end of the active list.
paul@161 382
paul@113 383
        for t in starting:
paul@113 384
            try:
paul@113 385
                i = active.index(None)
paul@113 386
                active[i] = t
paul@113 387
            except ValueError:
paul@113 388
                active.append(t)
paul@113 389
paul@161 390
        # Discard vacant positions from the end of the active list.
paul@161 391
paul@113 392
        while active and active[-1] is None:
paul@113 393
            active.pop()
paul@113 394
paul@452 395
        # Add an entry for the time point before "instants".
paul@452 396
paul@455 397
        slots.append((Point(point), active[:]))
paul@113 398
paul@449 399
        # Discard events ending at the same time as they began.
paul@449 400
paul@449 401
        if instants:
paul@449 402
            for t in instants:
paul@449 403
                i = active.index(t)
paul@449 404
                active[i] = None
paul@449 405
paul@449 406
            # Discard vacant positions from the end of the active list.
paul@449 407
paul@449 408
            while active and active[-1] is None:
paul@449 409
                active.pop()
paul@449 410
paul@452 411
            # Add another entry for the time point after "instants".
paul@449 412
paul@455 413
            slots.append((Point(point, Point.REPEATED), active[:]))
paul@449 414
paul@113 415
    return slots
paul@113 416
paul@244 417
def add_day_start_points(slots, tzid):
paul@153 418
paul@153 419
    """
paul@162 420
    Introduce into the 'slots' any day start points required by multi-day
paul@244 421
    periods. The 'tzid' is required to make sure that appropriate time zones
paul@244 422
    are chosen and not necessarily those provided by the existing time points.
paul@153 423
    """
paul@153 424
paul@162 425
    new_slots = []
paul@153 426
    current_date = None
paul@200 427
    previously_active = []
paul@153 428
paul@455 429
    for point, active in slots:
paul@455 430
        start_of_day = get_start_of_day(point.point, tzid)
paul@455 431
        this_date = point.point.date()
paul@153 432
paul@198 433
        # For each new day, add a slot for the start of the day where periods
paul@198 434
        # are active and where no such slot already exists.
paul@153 435
paul@153 436
        if this_date != current_date:
paul@414 437
paul@414 438
            # Fill in days where events remain active.
paul@414 439
paul@414 440
            if current_date:
paul@414 441
                current_date += timedelta(1)
paul@414 442
                while current_date < this_date:
paul@455 443
                    new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active))
paul@414 444
                    current_date += timedelta(1)
paul@414 445
            else:
paul@414 446
                current_date = this_date
paul@153 447
paul@153 448
            # Add any continuing periods.
paul@153 449
paul@455 450
            if point.point != start_of_day:
paul@455 451
                new_slots.append((Point(start_of_day), previously_active))
paul@153 452
paul@153 453
        # Add the currently active periods at this point in time.
paul@153 454
paul@153 455
        previously_active = active
paul@153 456
paul@162 457
    for t in new_slots:
paul@162 458
        insort_left(slots, t)
paul@162 459
paul@162 460
def add_slots(slots, points):
paul@162 461
paul@162 462
    """
paul@162 463
    Introduce into the 'slots' entries for those in 'points' that are not
paul@170 464
    already present, propagating active periods from time points preceding
paul@170 465
    those added.
paul@162 466
    """
paul@162 467
paul@162 468
    new_slots = []
paul@162 469
paul@162 470
    for point in points:
paul@452 471
        i = bisect_left(slots, (point,)) # slots is [(point, active)...]
paul@162 472
        if i < len(slots) and slots[i][0] == point:
paul@162 473
            continue
paul@162 474
paul@170 475
        new_slots.append((point, i > 0 and slots[i-1][1] or []))
paul@162 476
paul@162 477
    for t in new_slots:
paul@162 478
        insort_left(slots, t)
paul@162 479
paul@162 480
def partition_by_day(slots):
paul@162 481
paul@162 482
    """
paul@162 483
    Return a mapping from dates to time points provided by 'slots'.
paul@162 484
    """
paul@162 485
paul@162 486
    d = {}
paul@162 487
paul@455 488
    for point, value in slots:
paul@455 489
        day = point.point.date()
paul@162 490
        if not d.has_key(day):
paul@162 491
            d[day] = []
paul@455 492
        d[day].append((point, value))
paul@162 493
paul@162 494
    return d
paul@153 495
paul@283 496
def add_empty_days(days, tzid):
paul@279 497
paul@279 498
    "Add empty days to 'days' between busy days."
paul@279 499
paul@279 500
    last_day = None
paul@279 501
    all_days = days.keys()
paul@279 502
    all_days.sort()
paul@279 503
paul@279 504
    for day in all_days:
paul@279 505
        if last_day:
paul@279 506
            empty_day = last_day + timedelta(1)
paul@279 507
            while empty_day < day:
paul@455 508
                days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]
paul@279 509
                empty_day += timedelta(1)
paul@279 510
        last_day = day 
paul@279 511
paul@114 512
def get_spans(slots):
paul@114 513
paul@114 514
    "Inspect the given 'slots', returning a mapping of event uids to spans."
paul@114 515
paul@455 516
    points = [point for point, active in slots]
paul@114 517
    spans = {}
paul@114 518
paul@449 519
    for _point, active in slots:
paul@458 520
        for p in active:
paul@458 521
            if p:
paul@458 522
                key = p.get_key()
paul@458 523
                start_slot = bisect_left(points, p.start)
paul@458 524
                end_slot = bisect_left(points, p.end)
paul@185 525
                spans[key] = end_slot - start_slot
paul@114 526
paul@114 527
    return spans
paul@114 528
paul@395 529
def update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser):
paul@250 530
paul@250 531
    """
paul@395 532
    Update the free/busy details with the given 'periods', 'transp' setting,
paul@395 533
    'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.
paul@250 534
    """
paul@250 535
paul@343 536
    remove_period(freebusy, uid, recurrenceid)
paul@250 537
paul@458 538
    for p in periods:
paul@458 539
        insert_period(freebusy, FreeBusyPeriod(p.start, p.end, uid, transp, recurrenceid, summary, organiser))
paul@250 540
paul@48 541
# vim: tabstop=4 expandtab shiftwidth=4