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