imip-agent

Annotated imiptools/period.py

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