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 check_permitted_values, correct_datetime, \ 25 format_datetime, get_datetime, \ 26 get_datetime_attributes, \ 27 get_recurrence_start, get_recurrence_start_point, \ 28 get_start_of_day, \ 29 get_tzid, \ 30 to_timezone, to_utc_datetime 31 32 def ifnone(x, y): 33 if x is None: return y 34 else: return x 35 36 class Comparable: 37 38 "A date/datetime wrapper that allows comparisons with other types." 39 40 def __init__(self, dt): 41 self.dt = dt 42 43 def __cmp__(self, other): 44 dt = None 45 odt = None 46 47 # Find any dates/datetimes. 48 49 if isinstance(self.dt, date): 50 dt = self.dt 51 if isinstance(other, date): 52 odt = other 53 elif isinstance(other, Comparable): 54 if isinstance(other.dt, date): 55 odt = other.dt 56 else: 57 other = other.dt 58 59 if dt and odt: 60 return cmp(dt, odt) 61 elif dt: 62 return other.__rcmp__(dt) 63 elif odt: 64 return self.dt.__cmp__(odt) 65 else: 66 return self.dt.__cmp__(other) 67 68 class PointInTime: 69 70 "A base class for special values." 71 72 pass 73 74 class StartOfTime(PointInTime): 75 76 "A special value that compares earlier than other values." 77 78 def __cmp__(self, other): 79 if isinstance(other, StartOfTime): 80 return 0 81 else: 82 return -1 83 84 def __rcmp__(self, other): 85 return -self.__cmp__(other) 86 87 def __nonzero__(self): 88 return False 89 90 class EndOfTime(PointInTime): 91 92 "A special value that compares later than other values." 93 94 def __cmp__(self, other): 95 if isinstance(other, EndOfTime): 96 return 0 97 else: 98 return 1 99 100 def __rcmp__(self, other): 101 return -self.__cmp__(other) 102 103 def __nonzero__(self): 104 return False 105 106 class PeriodBase: 107 108 "A basic period abstraction." 109 110 def as_tuple(self): 111 return self.start, self.end 112 113 def __hash__(self): 114 return hash((self.get_start(), self.get_end())) 115 116 def __cmp__(self, other): 117 118 "Return a comparison result against 'other' using points in time." 119 120 if isinstance(other, PeriodBase): 121 return cmp( 122 (Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(self.get_end_point(), EndOfTime()))), 123 (Comparable(ifnone(other.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime()))) 124 ) 125 else: 126 return 1 127 128 def overlaps(self, other): 129 return Comparable(ifnone(self.get_end_point(), EndOfTime())) > Comparable(ifnone(other.get_start_point(), StartOfTime())) and \ 130 Comparable(ifnone(self.get_start_point(), StartOfTime())) < Comparable(ifnone(other.get_end_point(), EndOfTime())) 131 132 def get_key(self): 133 return self.get_start(), self.get_end() 134 135 # Datetime and metadata methods. 136 137 def get_start(self): 138 return self.start 139 140 def get_end(self): 141 return self.end 142 143 def get_start_attr(self): 144 return get_datetime_attributes(self.start, self.tzid) 145 146 def get_end_attr(self): 147 return get_datetime_attributes(self.end, self.tzid) 148 149 def get_start_item(self): 150 return self.get_start(), self.get_start_attr() 151 152 def get_end_item(self): 153 return self.get_end(), self.get_end_attr() 154 155 def get_start_point(self): 156 return self.start 157 158 def get_end_point(self): 159 return self.end 160 161 def get_duration(self): 162 return self.get_end_point() - self.get_start_point() 163 164 class Period(PeriodBase): 165 166 "A simple period abstraction." 167 168 def __init__(self, start, end, tzid=None, origin=None): 169 170 """ 171 Initialise a period with the given 'start' and 'end', having a 172 contextual 'tzid', if specified, and an indicated 'origin'. 173 174 All metadata from the start and end points are derived from the supplied 175 dates/datetimes. 176 """ 177 178 if isinstance(start, (date, PointInTime)): self.start = start 179 else: self.start = get_datetime(start) or StartOfTime() 180 if isinstance(end, (date, PointInTime)): self.end = end 181 else: self.end = get_datetime(end) or EndOfTime() 182 self.tzid = tzid 183 self.origin = origin 184 185 def as_tuple(self): 186 return self.start, self.end, self.tzid, self.origin 187 188 def __repr__(self): 189 return "Period%r" % (self.as_tuple(),) 190 191 # Datetime and metadata methods. 192 193 def get_tzid(self): 194 return get_tzid(self.get_start_attr(), self.get_end_attr()) or self.tzid 195 196 def get_start_point(self): 197 start = self.get_start() 198 if isinstance(start, PointInTime): return start 199 else: return to_utc_datetime(start, self.get_tzid()) 200 201 def get_end_point(self): 202 end = self.get_end() 203 if isinstance(end, PointInTime): return end 204 else: return to_utc_datetime(end, self.get_tzid()) 205 206 # Period and event recurrence logic. 207 208 def is_replaced(self, recurrenceids): 209 210 """ 211 Return whether this period refers to one of the 'recurrenceids'. 212 The 'recurrenceids' should be normalised to UTC datetimes according to 213 time zone information provided by their objects or be floating dates or 214 datetimes requiring conversion using contextual time zone information. 215 """ 216 217 for recurrenceid in recurrenceids: 218 if self.is_affected(recurrenceid): 219 return recurrenceid 220 return None 221 222 def is_affected(self, recurrenceid): 223 224 """ 225 Return whether this period refers to 'recurrenceid'. The 'recurrenceid' 226 should be normalised to UTC datetimes according to time zone information 227 provided by their objects. Otherwise, this period's contextual time zone 228 information is used to convert any date or floating datetime 229 representation to a point in time. 230 """ 231 232 if not recurrenceid: 233 return None 234 d = get_recurrence_start(recurrenceid) 235 dt = get_recurrence_start_point(recurrenceid, self.tzid) 236 if self.get_start() == d or self.get_start_point() == dt: 237 return recurrenceid 238 return None 239 240 # Value correction methods. 241 242 def get_corrected(self, permitted_values): 243 244 "Return a corrected version of this period." 245 246 start = self.get_start() 247 end = self.get_end() 248 start_errors = check_permitted_values(start, permitted_values) 249 end_errors = check_permitted_values(end, permitted_values) 250 251 if not (start_errors or end_errors): 252 return self 253 254 if start_errors: 255 start = correct_datetime(start, permitted_values) 256 if end_errors: 257 end = correct_datetime(end, permitted_values) 258 259 return self.make_corrected(start, end) 260 261 def make_corrected(self, start, end): 262 return self.__class__(start, end, self.tzid, self.origin) 263 264 class FreeBusyPeriod(PeriodBase): 265 266 "A free/busy record abstraction." 267 268 def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, summary=None, organiser=None, expires=None): 269 270 """ 271 Initialise a free/busy period with the given 'start' and 'end' points, 272 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 273 details. 274 275 An additional 'expires' parameter can be used to indicate an expiry 276 datetime in conjunction with free/busy offers made when countering 277 event proposals. 278 """ 279 280 self.start = isinstance(start, datetime) and start or get_datetime(start) 281 self.end = isinstance(end, datetime) and end or get_datetime(end) 282 self.uid = uid 283 self.transp = transp 284 self.recurrenceid = recurrenceid 285 self.summary = summary 286 self.organiser = organiser 287 self.expires = expires 288 289 def as_tuple(self, strings_only=False): 290 291 """ 292 Return the initialisation parameter tuple, converting false value 293 parameters to strings if 'strings_only' is set to a true value. 294 """ 295 296 null = lambda x: (strings_only and [""] or [x])[0] 297 return ( 298 strings_only and format_datetime(self.get_start_point()) or self.start, 299 strings_only and format_datetime(self.get_end_point()) or self.end, 300 self.uid or null(self.uid), 301 self.transp or strings_only and "OPAQUE" or None, 302 self.recurrenceid or null(self.recurrenceid), 303 self.summary or null(self.summary), 304 self.organiser or null(self.organiser), 305 self.expires or null(self.expires) 306 ) 307 308 def __cmp__(self, other): 309 310 """ 311 Compare this object to 'other', employing the uid if the periods 312 involved are the same. 313 """ 314 315 result = PeriodBase.__cmp__(self, other) 316 if result == 0 and isinstance(other, FreeBusyPeriod): 317 return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid)) 318 else: 319 return result 320 321 def get_key(self): 322 return self.uid, self.recurrenceid, self.get_start() 323 324 def __repr__(self): 325 return "FreeBusyPeriod%r" % (self.as_tuple(),) 326 327 # Period and event recurrence logic. 328 329 def is_replaced(self, recurrences): 330 331 """ 332 Return whether this period refers to one of the 'recurrences'. 333 The 'recurrences' must be UTC datetimes corresponding to the start of 334 the period described by a recurrence. 335 """ 336 337 for recurrence in recurrences: 338 if self.is_affected(recurrence): 339 return True 340 return False 341 342 def is_affected(self, recurrence): 343 344 """ 345 Return whether this period refers to 'recurrence'. The 'recurrence' must 346 be a UTC datetime corresponding to the start of the period described by 347 a recurrence. 348 """ 349 350 return recurrence and self.get_start_point() == recurrence 351 352 class RecurringPeriod(Period): 353 354 """ 355 A period with iCalendar metadata attributes and origin information from an 356 object. 357 """ 358 359 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None): 360 Period.__init__(self, start, end, tzid, origin) 361 self.start_attr = start_attr 362 self.end_attr = end_attr 363 364 def get_start_attr(self): 365 return self.start_attr 366 367 def get_end_attr(self): 368 return self.end_attr 369 370 def as_tuple(self): 371 return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr 372 373 def __repr__(self): 374 return "RecurringPeriod%r" % (self.as_tuple(),) 375 376 def make_corrected(self, start, end): 377 return self.__class__(start, end, self.tzid, self.origin, self.get_start_attr(), self.get_end_attr()) 378 379 # Time and period management. 380 381 def can_schedule(freebusy, periods, uid, recurrenceid): 382 383 """ 384 Return whether the 'freebusy' list can accommodate the given 'periods' 385 employing the specified 'uid' and 'recurrenceid'. 386 """ 387 388 for conflict in have_conflict(freebusy, periods, True): 389 if conflict.uid != uid or conflict.recurrenceid != recurrenceid: 390 return False 391 392 return True 393 394 def have_conflict(freebusy, periods, get_conflicts=False): 395 396 """ 397 Return whether any period in 'freebusy' overlaps with the given 'periods', 398 returning a collection of such overlapping periods if 'get_conflicts' is 399 set to a true value. 400 """ 401 402 conflicts = set() 403 for p in periods: 404 overlapping = period_overlaps(freebusy, p, get_conflicts) 405 if overlapping: 406 if get_conflicts: 407 conflicts.update(overlapping) 408 else: 409 return True 410 411 if get_conflicts: 412 return conflicts 413 else: 414 return False 415 416 def insert_period(freebusy, period): 417 418 "Insert into 'freebusy' the given 'period'." 419 420 i = bisect_left(freebusy, period) 421 if i == len(freebusy): 422 freebusy.append(period) 423 elif freebusy[i] != period: 424 freebusy.insert(i, period) 425 426 def remove_period(freebusy, uid, recurrenceid=None): 427 428 """ 429 Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid' 430 (which if omitted causes the "parent" object's periods to be referenced). 431 """ 432 433 removed = False 434 i = 0 435 while i < len(freebusy): 436 fb = freebusy[i] 437 if fb.uid == uid and fb.recurrenceid == recurrenceid: 438 del freebusy[i] 439 removed = True 440 else: 441 i += 1 442 443 return removed 444 445 def remove_additional_periods(freebusy, uid, recurrenceids=None): 446 447 """ 448 Remove from 'freebusy' all periods associated with 'uid' having a 449 recurrence identifier indicating an additional or modified period. 450 451 If 'recurrenceids' is specified, remove all periods associated with 'uid' 452 that do not have a recurrence identifier in the given list. 453 """ 454 455 i = 0 456 while i < len(freebusy): 457 fb = freebusy[i] 458 if fb.uid == uid and fb.recurrenceid and ( 459 recurrenceids is None or 460 recurrenceids is not None and fb.recurrenceid not in recurrenceids 461 ): 462 del freebusy[i] 463 else: 464 i += 1 465 466 def remove_affected_period(freebusy, uid, start): 467 468 """ 469 Remove from 'freebusy' a period associated with 'uid' that provides an 470 occurrence starting at the given 'start' (provided by a recurrence 471 identifier, converted to a datetime). A recurrence identifier is used to 472 provide an alternative time period whilst also acting as a reference to the 473 originally-defined occurrence. 474 """ 475 476 search = Period(start, start) 477 found = bisect_left(freebusy, search) 478 479 while found < len(freebusy): 480 fb = freebusy[found] 481 482 # Stop looking if the start no longer matches the recurrence identifier. 483 484 if fb.get_start_point() != search.get_start_point(): 485 return 486 487 # If the period belongs to the parent object, remove it and return. 488 489 if not fb.recurrenceid and uid == fb.uid: 490 del freebusy[found] 491 break 492 493 # Otherwise, keep looking for a matching period. 494 495 found += 1 496 497 def periods_from(freebusy, period): 498 499 "Return the entries in 'freebusy' at or after 'period'." 500 501 first = bisect_left(freebusy, period) 502 return freebusy[first:] 503 504 def periods_until(freebusy, period): 505 506 "Return the entries in 'freebusy' before 'period'." 507 508 last = bisect_right(freebusy, Period(period.get_end(), period.get_end(), period.get_tzid())) 509 return freebusy[:last] 510 511 def get_overlapping(freebusy, period): 512 513 """ 514 Return the entries in 'freebusy' providing periods overlapping with 515 'period'. 516 """ 517 518 # Find the range of periods potentially overlapping the period in the 519 # free/busy collection. 520 521 startpoints = periods_until(freebusy, period) 522 523 # Find the range of periods potentially overlapping the period in a version 524 # of the free/busy collection sorted according to end datetimes. 525 526 endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints] 527 endpoints.sort() 528 first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),)) 529 endpoints = endpoints[first:] 530 531 overlapping = set() 532 533 for p, fb in endpoints: 534 if fb.overlaps(period): 535 overlapping.add(fb) 536 537 overlapping = list(overlapping) 538 overlapping.sort() 539 return overlapping 540 541 def period_overlaps(freebusy, period, get_periods=False): 542 543 """ 544 Return whether any period in 'freebusy' overlaps with the given 'period', 545 returning a collection of overlapping periods if 'get_periods' is set to a 546 true value. 547 """ 548 549 overlapping = get_overlapping(freebusy, period) 550 551 if get_periods: 552 return overlapping 553 else: 554 return len(overlapping) != 0 555 556 def remove_overlapping(freebusy, period): 557 558 "Remove from 'freebusy' all periods overlapping with 'period'." 559 560 overlapping = get_overlapping(freebusy, period) 561 562 if overlapping: 563 for fb in overlapping: 564 freebusy.remove(fb) 565 566 def replace_overlapping(freebusy, period, replacements): 567 568 """ 569 Replace existing periods in 'freebusy' within the given 'period', using the 570 given 'replacements'. 571 """ 572 573 remove_overlapping(freebusy, period) 574 for replacement in replacements: 575 insert_period(freebusy, replacement) 576 577 def coalesce_freebusy(freebusy): 578 579 "Coalesce the periods in 'freebusy'." 580 581 if not freebusy: 582 return freebusy 583 584 fb = [] 585 start = freebusy[0].get_start_point() 586 end = freebusy[0].get_end_point() 587 588 for period in freebusy[1:]: 589 if period.get_start_point() > end: 590 fb.append(FreeBusyPeriod(start, end)) 591 start = period.get_start_point() 592 end = period.get_end_point() 593 else: 594 end = max(end, period.get_end_point()) 595 596 fb.append(FreeBusyPeriod(start, end)) 597 return fb 598 599 def invert_freebusy(freebusy): 600 601 "Return the free periods from 'freebusy'." 602 603 if not freebusy: 604 return None 605 606 fb = coalesce_freebusy(freebusy) 607 free = [] 608 start = fb[0].get_end_point() 609 610 for period in fb[1:]: 611 free.append(FreeBusyPeriod(start, period.get_start_point())) 612 start = period.get_end_point() 613 614 return free 615 616 # Period layout. 617 618 def get_scale(periods, tzid, view_period=None): 619 620 """ 621 Return a time scale from the given list of 'periods'. 622 623 The given 'tzid' is used to make sure that the times are defined according 624 to the chosen time zone. 625 626 An optional 'view_period' is used to constrain the scale to the given 627 period. 628 629 The returned scale is a mapping from time to (starting, ending) tuples, 630 where starting and ending are collections of periods. 631 """ 632 633 scale = {} 634 view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None 635 view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None 636 637 for p in periods: 638 639 # Add a point and this event to the starting list. 640 641 start = to_timezone(p.get_start(), tzid) 642 start = view_start and max(start, view_start) or start 643 if not scale.has_key(start): 644 scale[start] = [], [] 645 scale[start][0].append(p) 646 647 # Add a point and this event to the ending list. 648 649 end = to_timezone(p.get_end(), tzid) 650 end = view_end and min(end, view_end) or end 651 if not scale.has_key(end): 652 scale[end] = [], [] 653 scale[end][1].append(p) 654 655 return scale 656 657 class Point: 658 659 "A qualified point in time." 660 661 PRINCIPAL, REPEATED = 0, 1 662 663 def __init__(self, point, indicator=None): 664 self.point = point 665 self.indicator = indicator or self.PRINCIPAL 666 667 def __hash__(self): 668 return hash((self.point, self.indicator)) 669 670 def __cmp__(self, other): 671 if isinstance(other, Point): 672 return cmp((self.point, self.indicator), (other.point, other.indicator)) 673 elif isinstance(other, datetime): 674 return cmp(self.point, other) 675 else: 676 return 1 677 678 def __eq__(self, other): 679 return self.__cmp__(other) == 0 680 681 def __ne__(self, other): 682 return not self == other 683 684 def __lt__(self, other): 685 return self.__cmp__(other) < 0 686 687 def __le__(self, other): 688 return self.__cmp__(other) <= 0 689 690 def __gt__(self, other): 691 return not self <= other 692 693 def __ge__(self, other): 694 return not self < other 695 696 def __repr__(self): 697 return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL") 698 699 def get_slots(scale): 700 701 """ 702 Return an ordered list of time slots from the given 'scale'. 703 704 Each slot is a tuple containing details of a point in time for the start of 705 the slot, together with a list of parallel event periods. 706 707 Each point in time is described as a Point representing the actual point in 708 time together with an indicator of the nature of the point in time (as a 709 principal point in a time scale or as a repeated point used to terminate 710 events occurring for an instant in time). 711 """ 712 713 slots = [] 714 active = [] 715 716 points = scale.items() 717 points.sort() 718 719 for point, (starting, ending) in points: 720 ending = set(ending) 721 instants = ending.intersection(starting) 722 723 # Discard all active events ending at or before this start time. 724 # Free up the position in the active list. 725 726 for t in ending.difference(instants): 727 i = active.index(t) 728 active[i] = None 729 730 # For each event starting at the current point, fill any newly-vacated 731 # position or add to the end of the active list. 732 733 for t in starting: 734 try: 735 i = active.index(None) 736 active[i] = t 737 except ValueError: 738 active.append(t) 739 740 # Discard vacant positions from the end of the active list. 741 742 while active and active[-1] is None: 743 active.pop() 744 745 # Add an entry for the time point before "instants". 746 747 slots.append((Point(point), active[:])) 748 749 # Discard events ending at the same time as they began. 750 751 if instants: 752 for t in instants: 753 i = active.index(t) 754 active[i] = None 755 756 # Discard vacant positions from the end of the active list. 757 758 while active and active[-1] is None: 759 active.pop() 760 761 # Add another entry for the time point after "instants". 762 763 slots.append((Point(point, Point.REPEATED), active[:])) 764 765 return slots 766 767 def add_day_start_points(slots, tzid): 768 769 """ 770 Introduce into the 'slots' any day start points required by multi-day 771 periods. The 'tzid' is required to make sure that appropriate time zones 772 are chosen and not necessarily those provided by the existing time points. 773 """ 774 775 new_slots = [] 776 current_date = None 777 previously_active = [] 778 779 for point, active in slots: 780 start_of_day = get_start_of_day(point.point, tzid) 781 this_date = point.point.date() 782 783 # For each new day, add a slot for the start of the day where periods 784 # are active and where no such slot already exists. 785 786 if this_date != current_date: 787 788 # Fill in days where events remain active. 789 790 if current_date: 791 current_date += timedelta(1) 792 while current_date < this_date: 793 new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active)) 794 current_date += timedelta(1) 795 else: 796 current_date = this_date 797 798 # Add any continuing periods. 799 800 if point.point != start_of_day: 801 new_slots.append((Point(start_of_day), previously_active)) 802 803 # Add the currently active periods at this point in time. 804 805 previously_active = active 806 807 for t in new_slots: 808 insort_left(slots, t) 809 810 def remove_end_slot(slots, view_period): 811 812 """ 813 Remove from 'slots' any slot situated at the end of the given 'view_period'. 814 """ 815 816 end = view_period.get_end_point() 817 if not end or not slots: 818 return 819 i = bisect_left(slots, (Point(end), None)) 820 if i < len(slots): 821 del slots[i:] 822 823 def add_slots(slots, points): 824 825 """ 826 Introduce into the 'slots' entries for those in 'points' that are not 827 already present, propagating active periods from time points preceding 828 those added. 829 """ 830 831 new_slots = [] 832 833 for point in points: 834 i = bisect_left(slots, (point,)) # slots is [(point, active)...] 835 if i < len(slots) and slots[i][0] == point: 836 continue 837 838 new_slots.append((point, i > 0 and slots[i-1][1] or [])) 839 840 for t in new_slots: 841 insort_left(slots, t) 842 843 def partition_by_day(slots): 844 845 """ 846 Return a mapping from dates to time points provided by 'slots'. 847 """ 848 849 d = {} 850 851 for point, value in slots: 852 day = point.point.date() 853 if not d.has_key(day): 854 d[day] = [] 855 d[day].append((point, value)) 856 857 return d 858 859 def add_empty_days(days, tzid, start=None, end=None): 860 861 """ 862 Add empty days to 'days' between busy days, and optionally from the given 863 'start' day and until the given 'end' day. 864 """ 865 866 last_day = start - timedelta(1) 867 all_days = days.keys() 868 all_days.sort() 869 870 for day in all_days: 871 if last_day: 872 empty_day = last_day + timedelta(1) 873 while empty_day < day: 874 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 875 empty_day += timedelta(1) 876 last_day = day 877 878 if end: 879 empty_day = last_day + timedelta(1) 880 while empty_day < end: 881 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 882 empty_day += timedelta(1) 883 884 def get_spans(slots): 885 886 "Inspect the given 'slots', returning a mapping of period keys to spans." 887 888 points = [point for point, active in slots] 889 spans = {} 890 891 for _point, active in slots: 892 for p in active: 893 if p: 894 key = p.get_key() 895 start_slot = bisect_left(points, p.get_start()) 896 end_slot = bisect_left(points, p.get_end()) 897 spans[key] = end_slot - start_slot 898 899 return spans 900 901 def update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 902 903 """ 904 Update the free/busy details with the given 'periods', 'transp' setting, 905 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 906 907 An optional 'expires' datetime string indicates the expiry time of any 908 free/busy offer. 909 """ 910 911 remove_period(freebusy, uid, recurrenceid) 912 913 for p in periods: 914 insert_period(freebusy, FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires)) 915 916 # vim: tabstop=4 expandtab shiftwidth=4