1 #!/usr/bin/env python 2 3 """ 4 Managing and presenting periods of time. 5 6 Copyright (C) 2014, 2015, 2016 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 Return the removed periods. 544 """ 545 546 removed = [] 547 i = 0 548 while i < len(freebusy): 549 fb = freebusy[i] 550 if fb.uid == uid and fb.recurrenceid and ( 551 recurrenceids is None or 552 recurrenceids is not None and fb.recurrenceid not in recurrenceids 553 ): 554 removed.append(freebusy[i]) 555 del freebusy[i] 556 else: 557 i += 1 558 559 return removed 560 561 def remove_affected_period(freebusy, uid, start): 562 563 """ 564 Remove from 'freebusy' a period associated with 'uid' that provides an 565 occurrence starting at the given 'start' (provided by a recurrence 566 identifier, converted to a datetime). A recurrence identifier is used to 567 provide an alternative time period whilst also acting as a reference to the 568 originally-defined occurrence. 569 570 Return any removed period in a list. 571 """ 572 573 removed = [] 574 575 search = Period(start, start) 576 found = bisect_left(freebusy, search) 577 578 while found < len(freebusy): 579 fb = freebusy[found] 580 581 # Stop looking if the start no longer matches the recurrence identifier. 582 583 if fb.get_start_point() != search.get_start_point(): 584 break 585 586 # If the period belongs to the parent object, remove it and return. 587 588 if not fb.recurrenceid and uid == fb.uid: 589 removed.append(freebusy[found]) 590 del freebusy[found] 591 break 592 593 # Otherwise, keep looking for a matching period. 594 595 found += 1 596 597 return removed 598 599 def periods_from(freebusy, period): 600 601 "Return the entries in 'freebusy' at or after 'period'." 602 603 first = bisect_left(freebusy, period) 604 return freebusy[first:] 605 606 def periods_until(freebusy, period): 607 608 "Return the entries in 'freebusy' before 'period'." 609 610 last = bisect_right(freebusy, Period(period.get_end(), period.get_end(), period.get_tzid())) 611 return freebusy[:last] 612 613 def get_overlapping(freebusy, period): 614 615 """ 616 Return the entries in 'freebusy' providing periods overlapping with 617 'period'. 618 """ 619 620 # Find the range of periods potentially overlapping the period in the 621 # free/busy collection. 622 623 startpoints = periods_until(freebusy, period) 624 625 # Find the range of periods potentially overlapping the period in a version 626 # of the free/busy collection sorted according to end datetimes. 627 628 endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints] 629 endpoints.sort() 630 first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),)) 631 endpoints = endpoints[first:] 632 633 overlapping = set() 634 635 for p, fb in endpoints: 636 if fb.overlaps(period): 637 overlapping.add(fb) 638 639 overlapping = list(overlapping) 640 overlapping.sort() 641 return overlapping 642 643 def period_overlaps(freebusy, period, get_periods=False): 644 645 """ 646 Return whether any period in 'freebusy' overlaps with the given 'period', 647 returning a collection of overlapping periods if 'get_periods' is set to a 648 true value. 649 """ 650 651 overlapping = get_overlapping(freebusy, period) 652 653 if get_periods: 654 return overlapping 655 else: 656 return len(overlapping) != 0 657 658 def remove_overlapping(freebusy, period): 659 660 "Remove from 'freebusy' all periods overlapping with 'period'." 661 662 overlapping = get_overlapping(freebusy, period) 663 664 if overlapping: 665 for fb in overlapping: 666 freebusy.remove(fb) 667 668 def replace_overlapping(freebusy, period, replacements): 669 670 """ 671 Replace existing periods in 'freebusy' within the given 'period', using the 672 given 'replacements'. 673 """ 674 675 remove_overlapping(freebusy, period) 676 for replacement in replacements: 677 insert_period(freebusy, replacement) 678 679 def coalesce_freebusy(freebusy): 680 681 "Coalesce the periods in 'freebusy'." 682 683 if not freebusy: 684 return freebusy 685 686 fb = [] 687 start = freebusy[0].get_start_point() 688 end = freebusy[0].get_end_point() 689 690 for period in freebusy[1:]: 691 if period.get_start_point() > end: 692 fb.append(FreeBusyPeriod(start, end)) 693 start = period.get_start_point() 694 end = period.get_end_point() 695 else: 696 end = max(end, period.get_end_point()) 697 698 fb.append(FreeBusyPeriod(start, end)) 699 return fb 700 701 def invert_freebusy(freebusy): 702 703 "Return the free periods from 'freebusy'." 704 705 if not freebusy: 706 return [FreeBusyPeriod(None, None)] 707 708 # Coalesce periods that overlap or are adjacent. 709 710 fb = coalesce_freebusy(freebusy) 711 free = [] 712 713 # Add a start-of-time period if appropriate. 714 715 first = fb[0].get_start_point() 716 if first: 717 free.append(FreeBusyPeriod(None, first)) 718 719 start = fb[0].get_end_point() 720 721 for period in fb[1:]: 722 free.append(FreeBusyPeriod(start, period.get_start_point())) 723 start = period.get_end_point() 724 725 # Add an end-of-time period if appropriate. 726 727 if start: 728 free.append(FreeBusyPeriod(start, None)) 729 730 return free 731 732 # Period layout. 733 734 def get_scale(periods, tzid, view_period=None): 735 736 """ 737 Return a time scale from the given list of 'periods'. 738 739 The given 'tzid' is used to make sure that the times are defined according 740 to the chosen time zone. 741 742 An optional 'view_period' is used to constrain the scale to the given 743 period. 744 745 The returned scale is a mapping from time to (starting, ending) tuples, 746 where starting and ending are collections of periods. 747 """ 748 749 scale = {} 750 view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None 751 view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None 752 753 for p in periods: 754 755 # Add a point and this event to the starting list. 756 757 start = to_timezone(p.get_start(), tzid) 758 start = view_start and max(start, view_start) or start 759 if not scale.has_key(start): 760 scale[start] = [], [] 761 scale[start][0].append(p) 762 763 # Add a point and this event to the ending list. 764 765 end = to_timezone(p.get_end(), tzid) 766 end = view_end and min(end, view_end) or end 767 if not scale.has_key(end): 768 scale[end] = [], [] 769 scale[end][1].append(p) 770 771 return scale 772 773 class Point: 774 775 "A qualified point in time." 776 777 PRINCIPAL, REPEATED = 0, 1 778 779 def __init__(self, point, indicator=None): 780 self.point = point 781 self.indicator = indicator or self.PRINCIPAL 782 783 def __hash__(self): 784 return hash((self.point, self.indicator)) 785 786 def __cmp__(self, other): 787 if isinstance(other, Point): 788 return cmp((self.point, self.indicator), (other.point, other.indicator)) 789 elif isinstance(other, datetime): 790 return cmp(self.point, other) 791 else: 792 return 1 793 794 def __eq__(self, other): 795 return self.__cmp__(other) == 0 796 797 def __ne__(self, other): 798 return not self == other 799 800 def __lt__(self, other): 801 return self.__cmp__(other) < 0 802 803 def __le__(self, other): 804 return self.__cmp__(other) <= 0 805 806 def __gt__(self, other): 807 return not self <= other 808 809 def __ge__(self, other): 810 return not self < other 811 812 def __repr__(self): 813 return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL") 814 815 def get_slots(scale): 816 817 """ 818 Return an ordered list of time slots from the given 'scale'. 819 820 Each slot is a tuple containing details of a point in time for the start of 821 the slot, together with a list of parallel event periods. 822 823 Each point in time is described as a Point representing the actual point in 824 time together with an indicator of the nature of the point in time (as a 825 principal point in a time scale or as a repeated point used to terminate 826 events occurring for an instant in time). 827 """ 828 829 slots = [] 830 active = [] 831 832 points = scale.items() 833 points.sort() 834 835 for point, (starting, ending) in points: 836 ending = set(ending) 837 instants = ending.intersection(starting) 838 839 # Discard all active events ending at or before this start time. 840 # Free up the position in the active list. 841 842 for t in ending.difference(instants): 843 i = active.index(t) 844 active[i] = None 845 846 # For each event starting at the current point, fill any newly-vacated 847 # position or add to the end of the active list. 848 849 for t in starting: 850 try: 851 i = active.index(None) 852 active[i] = t 853 except ValueError: 854 active.append(t) 855 856 # Discard vacant positions from the end of the active list. 857 858 while active and active[-1] is None: 859 active.pop() 860 861 # Add an entry for the time point before "instants". 862 863 slots.append((Point(point), active[:])) 864 865 # Discard events ending at the same time as they began. 866 867 if instants: 868 for t in instants: 869 i = active.index(t) 870 active[i] = None 871 872 # Discard vacant positions from the end of the active list. 873 874 while active and active[-1] is None: 875 active.pop() 876 877 # Add another entry for the time point after "instants". 878 879 slots.append((Point(point, Point.REPEATED), active[:])) 880 881 return slots 882 883 def add_day_start_points(slots, tzid): 884 885 """ 886 Introduce into the 'slots' any day start points required by multi-day 887 periods. The 'tzid' is required to make sure that appropriate time zones 888 are chosen and not necessarily those provided by the existing time points. 889 """ 890 891 new_slots = [] 892 current_date = None 893 previously_active = [] 894 895 for point, active in slots: 896 start_of_day = get_start_of_day(point.point, tzid) 897 this_date = point.point.date() 898 899 # For each new day, add a slot for the start of the day where periods 900 # are active and where no such slot already exists. 901 902 if this_date != current_date: 903 904 # Fill in days where events remain active. 905 906 if current_date: 907 current_date += timedelta(1) 908 while current_date < this_date: 909 new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active)) 910 current_date += timedelta(1) 911 else: 912 current_date = this_date 913 914 # Add any continuing periods. 915 916 if point.point != start_of_day: 917 new_slots.append((Point(start_of_day), previously_active)) 918 919 # Add the currently active periods at this point in time. 920 921 previously_active = active 922 923 for t in new_slots: 924 insort_left(slots, t) 925 926 def remove_end_slot(slots, view_period): 927 928 """ 929 Remove from 'slots' any slot situated at the end of the given 'view_period'. 930 """ 931 932 end = view_period.get_end_point() 933 if not end or not slots: 934 return 935 i = bisect_left(slots, (Point(end), None)) 936 if i < len(slots): 937 del slots[i:] 938 939 def add_slots(slots, points): 940 941 """ 942 Introduce into the 'slots' entries for those in 'points' that are not 943 already present, propagating active periods from time points preceding 944 those added. 945 """ 946 947 new_slots = [] 948 949 for point in points: 950 i = bisect_left(slots, (point,)) # slots is [(point, active)...] 951 if i < len(slots) and slots[i][0] == point: 952 continue 953 954 new_slots.append((point, i > 0 and slots[i-1][1] or [])) 955 956 for t in new_slots: 957 insort_left(slots, t) 958 959 def partition_by_day(slots): 960 961 """ 962 Return a mapping from dates to time points provided by 'slots'. 963 """ 964 965 d = {} 966 967 for point, value in slots: 968 day = point.point.date() 969 if not d.has_key(day): 970 d[day] = [] 971 d[day].append((point, value)) 972 973 return d 974 975 def add_empty_days(days, tzid, start=None, end=None): 976 977 """ 978 Add empty days to 'days' between busy days, and optionally from the given 979 'start' day and until the given 'end' day. 980 """ 981 982 last_day = start - timedelta(1) 983 all_days = days.keys() 984 all_days.sort() 985 986 for day in all_days: 987 if last_day: 988 empty_day = last_day + timedelta(1) 989 while empty_day < day: 990 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 991 empty_day += timedelta(1) 992 last_day = day 993 994 if end: 995 empty_day = last_day + timedelta(1) 996 while empty_day < end: 997 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 998 empty_day += timedelta(1) 999 1000 def get_spans(slots): 1001 1002 "Inspect the given 'slots', returning a mapping of period keys to spans." 1003 1004 points = [point for point, active in slots] 1005 spans = {} 1006 1007 for _point, active in slots: 1008 for p in active: 1009 if p: 1010 key = p.get_key() 1011 start_slot = bisect_left(points, p.get_start()) 1012 end_slot = bisect_left(points, p.get_end()) 1013 spans[key] = end_slot - start_slot 1014 1015 return spans 1016 1017 def update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 1018 1019 """ 1020 Update the free/busy details with the given 'periods', 'transp' setting, 1021 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 1022 1023 An optional 'expires' datetime string indicates the expiry time of any 1024 free/busy offer. 1025 """ 1026 1027 remove_event_periods(freebusy, uid, recurrenceid) 1028 1029 for p in periods: 1030 insert_period(freebusy, FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires)) 1031 1032 # vim: tabstop=4 expandtab shiftwidth=4