imip-agent

Annotated imiptools/period.py

408:c0b9a178ffd1
2015-03-10 Paul Boddie Separated event period details retrieval from datetime controls generation.
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@48 22
from bisect import bisect_left, 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@291 26
# Time management with datetime strings in the UTC time zone.
paul@48 27
paul@343 28
def can_schedule(freebusy, periods, uid, recurrenceid):
paul@221 29
paul@221 30
    """
paul@221 31
    Return whether the 'freebusy' list can accommodate the given 'periods'
paul@343 32
    employing the specified 'uid' and 'recurrenceid'.
paul@221 33
    """
paul@221 34
paul@221 35
    for conflict in have_conflict(freebusy, periods, True):
paul@395 36
        start, end, found_uid, found_transp, found_recurrenceid = conflict[:5]
paul@343 37
        if found_uid != uid and found_recurrenceid != recurrenceid:
paul@221 38
            return False
paul@221 39
paul@221 40
    return True
paul@221 41
paul@72 42
def have_conflict(freebusy, periods, get_conflicts=False):
paul@72 43
paul@72 44
    """
paul@72 45
    Return whether any period in 'freebusy' overlaps with the given 'periods',
paul@72 46
    returning a collection of such overlapping periods if 'get_conflicts' is
paul@72 47
    set to a true value.
paul@72 48
    """
paul@72 49
paul@72 50
    conflicts = []
paul@72 51
    for start, end in periods:
paul@74 52
        overlapping = period_overlaps(freebusy, (start, end), get_conflicts)
paul@74 53
        if overlapping:
paul@72 54
            if get_conflicts:
paul@74 55
                conflicts += overlapping
paul@72 56
            else:
paul@72 57
                return True
paul@74 58
paul@72 59
    if get_conflicts:
paul@72 60
        return conflicts
paul@72 61
    else:
paul@72 62
        return False
paul@72 63
paul@48 64
def insert_period(freebusy, period):
paul@362 65
paul@362 66
    "Insert into 'freebusy' the given 'period'."
paul@362 67
paul@48 68
    insort_left(freebusy, period)
paul@48 69
paul@343 70
def remove_period(freebusy, uid, recurrenceid=None):
paul@362 71
paul@362 72
    """
paul@362 73
    Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid'
paul@362 74
    (which if omitted causes the "parent" object's periods to be referenced).
paul@362 75
    """
paul@362 76
paul@48 77
    i = 0
paul@48 78
    while i < len(freebusy):
paul@48 79
        t = freebusy[i]
paul@343 80
        if len(t) >= 5 and t[2] == uid and t[4] == recurrenceid:
paul@48 81
            del freebusy[i]
paul@48 82
        else:
paul@48 83
            i += 1
paul@48 84
paul@382 85
def remove_additional_periods(freebusy, uid, recurrenceids=None):
paul@381 86
paul@381 87
    """
paul@381 88
    Remove from 'freebusy' all periods associated with 'uid' having a
paul@381 89
    recurrence identifier indicating an additional or modified period.
paul@382 90
paul@382 91
    If 'recurrenceids' is specified, remove all periods associated with 'uid'
paul@382 92
    that do not have a recurrence identifier in the given list.
paul@381 93
    """
paul@381 94
paul@381 95
    i = 0
paul@381 96
    while i < len(freebusy):
paul@381 97
        t = freebusy[i]
paul@382 98
        if len(t) >= 5 and t[2] == uid and t[4] and (
paul@382 99
            recurrenceids is None or
paul@382 100
            recurrenceids is not None and t[4] not in recurrenceids
paul@382 101
            ):
paul@381 102
            del freebusy[i]
paul@381 103
        else:
paul@381 104
            i += 1
paul@381 105
paul@361 106
def remove_affected_period(freebusy, uid, recurrenceid):
paul@362 107
paul@362 108
    """
paul@362 109
    Remove from 'freebusy' a period associated with 'uid' that provides an
paul@362 110
    occurrence starting at the given 'recurrenceid', where the recurrence
paul@362 111
    identifier is used to provide an alternative time period whilst also acting
paul@362 112
    as a reference to the originally-defined occurrence.
paul@362 113
    """
paul@362 114
paul@361 115
    found = bisect_left(freebusy, (recurrenceid,))
paul@376 116
    while found < len(freebusy):
paul@361 117
        start, end, _uid, transp, _recurrenceid = freebusy[found][:5]
paul@376 118
paul@376 119
        # Stop looking if the start no longer matches the recurrence identifier.
paul@376 120
paul@376 121
        if start != recurrenceid:
paul@376 122
            return
paul@376 123
paul@376 124
        # If the period belongs to the parent object, remove it and return.
paul@376 125
paul@376 126
        if not _recurrenceid and uid == _uid:
paul@361 127
            del freebusy[found]
paul@376 128
            break
paul@376 129
paul@376 130
        # Otherwise, keep looking for a matching period.
paul@376 131
paul@376 132
        found += 1
paul@361 133
paul@327 134
def get_overlapping(freebusy, period):
paul@327 135
paul@327 136
    """
paul@327 137
    Return the indexes in 'freebusy' providing periods overlapping with
paul@327 138
    'period'.
paul@327 139
    """
paul@327 140
paul@327 141
    dtstart, dtend = period[:2]
paul@327 142
    found = bisect_left(freebusy, (dtstart, dtend))
paul@327 143
paul@327 144
    # Find earlier overlapping periods.
paul@327 145
paul@327 146
    start = found
paul@327 147
paul@327 148
    while start > 0 and freebusy[start - 1][1] > dtstart:
paul@327 149
        start -= 1
paul@327 150
paul@327 151
    # Find later overlapping periods.
paul@327 152
paul@327 153
    end = found
paul@327 154
paul@327 155
    while end < len(freebusy) and (dtend is None or freebusy[end][0] < dtend):
paul@327 156
        end += 1
paul@327 157
paul@327 158
    return start, end
paul@327 159
paul@74 160
def period_overlaps(freebusy, period, get_periods=False):
paul@72 161
paul@72 162
    """
paul@74 163
    Return whether any period in 'freebusy' overlaps with the given 'period',
paul@74 164
    returning a collection of overlapping periods if 'get_periods' is set to a
paul@74 165
    true value.
paul@72 166
    """
paul@72 167
paul@327 168
    start, end = get_overlapping(freebusy, period)
paul@74 169
paul@74 170
    if get_periods:
paul@327 171
        return freebusy[start:end]
paul@74 172
    else:
paul@327 173
        return start != end
paul@327 174
paul@327 175
def remove_overlapping(freebusy, period):
paul@327 176
paul@327 177
    "Remove from 'freebusy' all periods overlapping with 'period'."
paul@327 178
paul@327 179
    start, end = get_overlapping(freebusy, period)
paul@327 180
paul@327 181
    if start != end:
paul@327 182
        del freebusy[start:end]
paul@327 183
paul@327 184
def replace_overlapping(freebusy, period, replacements):
paul@327 185
paul@327 186
    """
paul@327 187
    Replace existing periods in 'freebusy' within the given 'period', using the
paul@327 188
    given 'replacements'.
paul@327 189
    """
paul@327 190
paul@327 191
    remove_overlapping(freebusy, period)
paul@327 192
    for replacement in replacements:
paul@327 193
        insert_period(freebusy, replacement)
paul@48 194
paul@240 195
# Period layout mostly with datetime objects.
paul@113 196
paul@162 197
def convert_periods(periods, tzid):
paul@162 198
paul@162 199
    "Convert 'periods' to use datetime objects employing the given 'tzid'."
paul@162 200
paul@162 201
    l = []
paul@162 202
paul@162 203
    for t in periods:
paul@162 204
        start, end = t[:2]
paul@204 205
paul@204 206
        # NOTE: This only really works if the datetimes are UTC already.
paul@232 207
        # NOTE: Since the periods should originate from the free/busy data,
paul@232 208
        # NOTE: and since that data should employ UTC times, this should not be
paul@232 209
        # NOTE: an immediate problem.
paul@204 210
paul@232 211
        start = get_datetime(start)
paul@232 212
        end = get_datetime(end)
paul@232 213
paul@232 214
        start = isinstance(start, datetime) and to_timezone(start, tzid) or get_start_of_day(start, tzid)
paul@232 215
        end = isinstance(end, datetime) and to_timezone(end, tzid) or get_start_of_day(end, tzid)
paul@232 216
paul@162 217
        l.append((start, end) + tuple(t[2:]))
paul@162 218
paul@162 219
    return l
paul@162 220
paul@162 221
def get_scale(periods):
paul@113 222
paul@113 223
    """
paul@162 224
    Return an ordered time scale from the given list 'periods', with the first
paul@162 225
    two elements of each tuple being start and end times.
paul@153 226
paul@162 227
    The given 'tzid' is used to make sure that the times are defined according
paul@162 228
    to the chosen time zone.
paul@162 229
paul@162 230
    The returned scale is a mapping from time to (starting, ending) tuples,
paul@162 231
    where starting and ending are collections of tuples from 'periods'.
paul@113 232
    """
paul@113 233
paul@113 234
    scale = {}
paul@113 235
paul@162 236
    for t in periods:
paul@113 237
        start, end = t[:2]
paul@113 238
paul@113 239
        # Add a point and this event to the starting list.
paul@113 240
paul@113 241
        if not scale.has_key(start):
paul@113 242
            scale[start] = [], []
paul@113 243
        scale[start][0].append(t)
paul@113 244
paul@113 245
        # Add a point and this event to the ending list.
paul@113 246
paul@113 247
        if not scale.has_key(end):
paul@113 248
            scale[end] = [], []
paul@113 249
        scale[end][1].append(t)
paul@113 250
paul@113 251
    return scale
paul@113 252
paul@162 253
def get_slots(scale):
paul@113 254
paul@113 255
    """
paul@162 256
    Return an ordered list of time slots from the given 'scale'.
paul@113 257
paul@113 258
    Each slot is a tuple containing a point in time for the start of the slot,
paul@113 259
    together with a list of parallel event tuples, each tuple containing the
paul@113 260
    original details of an event.
paul@113 261
    """
paul@113 262
paul@113 263
    slots = []
paul@113 264
    active = []
paul@113 265
paul@162 266
    points = scale.items()
paul@162 267
    points.sort()
paul@162 268
paul@162 269
    for point, (starting, ending) in points:
paul@113 270
paul@113 271
        # Discard all active events ending at or before this start time.
paul@161 272
        # Free up the position in the active list.
paul@113 273
paul@113 274
        for t in ending:
paul@113 275
            i = active.index(t)
paul@113 276
            active[i] = None
paul@113 277
paul@161 278
        # For each event starting at the current point, fill any newly-vacated
paul@161 279
        # position or add to the end of the active list.
paul@161 280
paul@113 281
        for t in starting:
paul@113 282
            try:
paul@113 283
                i = active.index(None)
paul@113 284
                active[i] = t
paul@113 285
            except ValueError:
paul@113 286
                active.append(t)
paul@113 287
paul@161 288
        # Discard vacant positions from the end of the active list.
paul@161 289
paul@113 290
        while active and active[-1] is None:
paul@113 291
            active.pop()
paul@113 292
paul@113 293
        slots.append((point, active[:]))
paul@113 294
paul@113 295
    return slots
paul@113 296
paul@244 297
def add_day_start_points(slots, tzid):
paul@153 298
paul@153 299
    """
paul@162 300
    Introduce into the 'slots' any day start points required by multi-day
paul@244 301
    periods. The 'tzid' is required to make sure that appropriate time zones
paul@244 302
    are chosen and not necessarily those provided by the existing time points.
paul@153 303
    """
paul@153 304
paul@162 305
    new_slots = []
paul@153 306
    current_date = None
paul@200 307
    previously_active = []
paul@153 308
paul@153 309
    for point, active in slots:
paul@244 310
        start_of_day = get_start_of_day(point, tzid)
paul@162 311
        this_date = point.date()
paul@153 312
paul@198 313
        # For each new day, add a slot for the start of the day where periods
paul@198 314
        # are active and where no such slot already exists.
paul@153 315
paul@153 316
        if this_date != current_date:
paul@153 317
            current_date = this_date
paul@153 318
paul@153 319
            # Add any continuing periods.
paul@153 320
paul@200 321
            if point != start_of_day:
paul@162 322
                new_slots.append((start_of_day, previously_active))
paul@153 323
paul@153 324
        # Add the currently active periods at this point in time.
paul@153 325
paul@153 326
        previously_active = active
paul@153 327
paul@162 328
    for t in new_slots:
paul@162 329
        insort_left(slots, t)
paul@162 330
paul@162 331
def add_slots(slots, points):
paul@162 332
paul@162 333
    """
paul@162 334
    Introduce into the 'slots' entries for those in 'points' that are not
paul@170 335
    already present, propagating active periods from time points preceding
paul@170 336
    those added.
paul@162 337
    """
paul@162 338
paul@162 339
    new_slots = []
paul@162 340
paul@162 341
    for point in points:
paul@162 342
        i = bisect_left(slots, (point, None))
paul@162 343
        if i < len(slots) and slots[i][0] == point:
paul@162 344
            continue
paul@162 345
paul@170 346
        new_slots.append((point, i > 0 and slots[i-1][1] or []))
paul@162 347
paul@162 348
    for t in new_slots:
paul@162 349
        insort_left(slots, t)
paul@162 350
paul@162 351
def partition_by_day(slots):
paul@162 352
paul@162 353
    """
paul@162 354
    Return a mapping from dates to time points provided by 'slots'.
paul@162 355
    """
paul@162 356
paul@162 357
    d = {}
paul@162 358
paul@162 359
    for point, value in slots:
paul@162 360
        day = point.date()
paul@162 361
        if not d.has_key(day):
paul@162 362
            d[day] = []
paul@162 363
        d[day].append((point, value))
paul@162 364
paul@162 365
    return d
paul@153 366
paul@283 367
def add_empty_days(days, tzid):
paul@279 368
paul@279 369
    "Add empty days to 'days' between busy days."
paul@279 370
paul@279 371
    last_day = None
paul@279 372
    all_days = days.keys()
paul@279 373
    all_days.sort()
paul@279 374
paul@279 375
    for day in all_days:
paul@279 376
        if last_day:
paul@279 377
            empty_day = last_day + timedelta(1)
paul@279 378
            while empty_day < day:
paul@279 379
                days[empty_day] = [(get_start_of_day(empty_day, tzid), None)]
paul@279 380
                empty_day += timedelta(1)
paul@279 381
        last_day = day 
paul@279 382
paul@114 383
def get_spans(slots):
paul@114 384
paul@114 385
    "Inspect the given 'slots', returning a mapping of event uids to spans."
paul@114 386
paul@114 387
    points = [point for point, active in slots]
paul@114 388
    spans = {}
paul@114 389
paul@114 390
    for point, active in slots:
paul@114 391
        for t in active:
paul@185 392
            if t and len(t) >= 2:
paul@395 393
                start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t)
paul@185 394
paul@153 395
                try:
paul@153 396
                    start_slot = points.index(start)
paul@153 397
                except ValueError:
paul@153 398
                    start_slot = 0
paul@153 399
                try:
paul@153 400
                    end_slot = points.index(end)
paul@153 401
                except ValueError:
paul@153 402
                    end_slot = len(slots)
paul@185 403
                spans[key] = end_slot - start_slot
paul@114 404
paul@114 405
    return spans
paul@114 406
paul@185 407
def get_freebusy_details(t):
paul@185 408
paul@395 409
    """
paul@395 410
    Return a tuple of the form (start, end, uid, recurrenceid, summary,
paul@395 411
    organiser, key) from 't'.
paul@395 412
    """
paul@185 413
paul@185 414
    # Handle both complete free/busy details...
paul@185 415
paul@395 416
    if len(t) >= 7:
paul@395 417
        start, end, uid, transp, recurrenceid, summary, organiser = t[:7]
paul@343 418
        key = uid, recurrenceid
paul@185 419
paul@185 420
    # ...and published details without specific event details.
paul@185 421
paul@185 422
    else:
paul@185 423
        start, end = t[:2]
paul@185 424
        uid = None
paul@343 425
        recurrenceid = None
paul@395 426
        summary = None
paul@395 427
        organiser = None
paul@185 428
        key = (start, end)
paul@185 429
paul@395 430
    return start, end, uid, recurrenceid, summary, organiser, key
paul@185 431
paul@395 432
def update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser):
paul@250 433
paul@250 434
    """
paul@395 435
    Update the free/busy details with the given 'periods', 'transp' setting,
paul@395 436
    'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.
paul@250 437
    """
paul@250 438
paul@343 439
    remove_period(freebusy, uid, recurrenceid)
paul@250 440
paul@250 441
    for start, end in periods:
paul@395 442
        insert_period(freebusy, (start, end, uid, transp, recurrenceid, summary, organiser))
paul@250 443
paul@48 444
# vim: tabstop=4 expandtab shiftwidth=4