imip-agent

Annotated imiptools/period.py

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