imip-agent

Annotated imiptools/period.py

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