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