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