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