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 from imiptools.sql import DatabaseOperations 32 33 def ifnone(x, y): 34 if x is None: return y 35 else: return x 36 37 def from_strings(t, encoding): 38 return tuple([from_string(s, encoding) for s in t]) 39 40 def from_string(s, encoding): 41 if s: 42 return unicode(s, encoding) 43 else: 44 return s 45 46 def to_string(s, encoding): 47 if s: 48 return s.encode(encoding) 49 else: 50 return s 51 52 class Comparable: 53 54 "A date/datetime wrapper that allows comparisons with other types." 55 56 def __init__(self, dt): 57 self.dt = dt 58 59 def __cmp__(self, other): 60 dt = None 61 odt = None 62 63 # Find any dates/datetimes. 64 65 if isinstance(self.dt, date): 66 dt = self.dt 67 if isinstance(other, date): 68 odt = other 69 elif isinstance(other, Comparable): 70 if isinstance(other.dt, date): 71 odt = other.dt 72 else: 73 other = other.dt 74 75 if dt and odt: 76 return cmp(dt, odt) 77 elif dt: 78 return other.__rcmp__(dt) 79 elif odt: 80 return self.dt.__cmp__(odt) 81 else: 82 return self.dt.__cmp__(other) 83 84 class PointInTime: 85 86 "A base class for special values." 87 88 pass 89 90 class StartOfTime(PointInTime): 91 92 "A special value that compares earlier than other values." 93 94 def __cmp__(self, other): 95 if isinstance(other, StartOfTime): 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 def __hash__(self): 107 return 0 108 109 class EndOfTime(PointInTime): 110 111 "A special value that compares later than other values." 112 113 def __cmp__(self, other): 114 if isinstance(other, EndOfTime): 115 return 0 116 else: 117 return 1 118 119 def __rcmp__(self, other): 120 return -self.__cmp__(other) 121 122 def __nonzero__(self): 123 return False 124 125 def __hash__(self): 126 return 0 127 128 class Endless: 129 130 "A special value indicating an endless period." 131 132 def __cmp__(self, other): 133 if isinstance(other, Endless): 134 return 0 135 else: 136 return 1 137 138 def __rcmp__(self, other): 139 return -self.__cmp__(other) 140 141 def __nonzero__(self): 142 return True 143 144 class PeriodBase: 145 146 "A basic period abstraction." 147 148 def __init__(self, start, end): 149 if isinstance(start, (date, PointInTime)): 150 self.start = start 151 else: 152 self.start = get_datetime(start) or StartOfTime() 153 if isinstance(end, (date, PointInTime)): 154 self.end = end 155 else: 156 self.end = get_datetime(end) or EndOfTime() 157 158 def as_tuple(self): 159 return self.start, self.end 160 161 def __hash__(self): 162 return hash((self.get_start(), self.get_end())) 163 164 def __cmp__(self, other): 165 166 "Return a comparison result against 'other' using points in time." 167 168 if isinstance(other, PeriodBase): 169 return cmp( 170 (Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(self.get_end_point(), EndOfTime()))), 171 (Comparable(ifnone(other.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime()))) 172 ) 173 else: 174 return 1 175 176 def overlaps(self, other): 177 return Comparable(ifnone(self.get_end_point(), EndOfTime())) > Comparable(ifnone(other.get_start_point(), StartOfTime())) and \ 178 Comparable(ifnone(self.get_start_point(), StartOfTime())) < Comparable(ifnone(other.get_end_point(), EndOfTime())) 179 180 def within(self, other): 181 return Comparable(ifnone(self.get_start_point(), StartOfTime())) >= Comparable(ifnone(other.get_start_point(), StartOfTime())) and \ 182 Comparable(ifnone(self.get_end_point(), EndOfTime())) <= Comparable(ifnone(other.get_end_point(), EndOfTime())) 183 184 def common(self, other): 185 start = max(Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_start_point(), StartOfTime()))) 186 end = min(Comparable(ifnone(self.get_end_point(), EndOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime()))) 187 if start <= end: 188 return self.make_corrected(start.dt, end.dt) 189 else: 190 return None 191 192 def get_key(self): 193 return self.get_start(), self.get_end() 194 195 # Datetime and metadata methods. 196 197 def get_start(self): 198 return self.start 199 200 def get_end(self): 201 return self.end 202 203 def get_start_attr(self): 204 return get_datetime_attributes(self.start, self.tzid) 205 206 def get_end_attr(self): 207 return get_datetime_attributes(self.end, self.tzid) 208 209 def get_start_item(self): 210 return self.get_start(), self.get_start_attr() 211 212 def get_end_item(self): 213 return self.get_end(), self.get_end_attr() 214 215 def get_start_point(self): 216 return self.start 217 218 def get_end_point(self): 219 return self.end 220 221 def get_duration(self): 222 start = self.get_start_point() 223 end = self.get_end_point() 224 if start and end: 225 return end - start 226 else: 227 return Endless() 228 229 class Period(PeriodBase): 230 231 "A simple period abstraction." 232 233 def __init__(self, start, end, tzid=None, origin=None): 234 235 """ 236 Initialise a period with the given 'start' and 'end', having a 237 contextual 'tzid', if specified, and an indicated 'origin'. 238 239 All metadata from the start and end points are derived from the supplied 240 dates/datetimes. 241 """ 242 243 PeriodBase.__init__(self, start, end) 244 self.tzid = tzid 245 self.origin = origin 246 247 def as_tuple(self): 248 return self.start, self.end, self.tzid, self.origin 249 250 def __repr__(self): 251 return "Period%r" % (self.as_tuple(),) 252 253 # Datetime and metadata methods. 254 255 def get_tzid(self): 256 return get_tzid(self.get_start_attr(), self.get_end_attr()) or self.tzid 257 258 def get_start_point(self): 259 start = self.get_start() 260 if isinstance(start, PointInTime): return start 261 else: return to_utc_datetime(start, self.get_tzid()) 262 263 def get_end_point(self): 264 end = self.get_end() 265 if isinstance(end, PointInTime): return end 266 else: return to_utc_datetime(end, self.get_tzid()) 267 268 # Period and event recurrence logic. 269 270 def is_replaced(self, recurrenceids): 271 272 """ 273 Return whether this period refers to one of the 'recurrenceids'. 274 The 'recurrenceids' should be normalised to UTC datetimes according to 275 time zone information provided by their objects or be floating dates or 276 datetimes requiring conversion using contextual time zone information. 277 """ 278 279 for recurrenceid in recurrenceids: 280 if self.is_affected(recurrenceid): 281 return recurrenceid 282 return None 283 284 def is_affected(self, recurrenceid): 285 286 """ 287 Return whether this period refers to 'recurrenceid'. The 'recurrenceid' 288 should be normalised to UTC datetimes according to time zone information 289 provided by their objects. Otherwise, this period's contextual time zone 290 information is used to convert any date or floating datetime 291 representation to a point in time. 292 """ 293 294 if not recurrenceid: 295 return None 296 d = get_recurrence_start(recurrenceid) 297 dt = get_recurrence_start_point(recurrenceid, self.tzid) 298 299 # Compare the start to dates only, using the normalised start datetime 300 # for comparisons with the start point. 301 302 if not isinstance(d, datetime) and self.get_start() == d or self.get_start_point() == dt: 303 return recurrenceid 304 305 return None 306 307 # Value correction methods. 308 309 def with_duration(self, duration): 310 311 """ 312 Return a version of this period with the same start point but with the 313 given 'duration'. 314 """ 315 316 return self.make_corrected(self.get_start(), self.get_start() + duration) 317 318 def check_permitted(self, permitted_values): 319 320 "Check the period against the given 'permitted_values'." 321 322 start = self.get_start() 323 end = self.get_end() 324 start_errors = check_permitted_values(start, permitted_values) 325 end_errors = check_permitted_values(end, permitted_values) 326 327 if not (start_errors or end_errors): 328 return None 329 330 return start_errors, end_errors 331 332 def get_corrected(self, permitted_values): 333 334 "Return a corrected version of this period." 335 336 errors = self.check_permitted(permitted_values) 337 338 if not errors: 339 return self 340 341 start_errors, end_errors = errors 342 343 start = self.get_start() 344 end = self.get_end() 345 346 if start_errors: 347 start = correct_datetime(start, permitted_values) 348 if end_errors: 349 end = correct_datetime(end, permitted_values) 350 351 return self.make_corrected(start, end) 352 353 def make_corrected(self, start, end): 354 return self.__class__(start, end, self.tzid, self.origin) 355 356 class FreeBusyPeriod(PeriodBase): 357 358 "A free/busy record abstraction." 359 360 def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, 361 summary=None, organiser=None, expires=None): 362 363 """ 364 Initialise a free/busy period with the given 'start' and 'end' points, 365 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 366 details. 367 368 An additional 'expires' parameter can be used to indicate an expiry 369 datetime in conjunction with free/busy offers made when countering 370 event proposals. 371 """ 372 373 PeriodBase.__init__(self, start, end) 374 self.uid = uid 375 self.transp = transp or None 376 self.recurrenceid = recurrenceid or None 377 self.summary = summary or None 378 self.organiser = organiser or None 379 self.expires = expires or None 380 381 def as_tuple(self, strings_only=False, string_datetimes=False): 382 383 """ 384 Return the initialisation parameter tuple, converting datetimes and 385 false value parameters to strings if 'strings_only' is set to a true 386 value. Otherwise, if 'string_datetimes' is set to a true value, only the 387 datetime values are converted to strings. 388 """ 389 390 null = lambda x: (strings_only and [""] or [x])[0] 391 return ( 392 (strings_only or string_datetimes) and format_datetime(self.get_start_point()) or self.start, 393 (strings_only or string_datetimes) and format_datetime(self.get_end_point()) or self.end, 394 self.uid or null(self.uid), 395 self.transp or strings_only and "OPAQUE" or None, 396 self.recurrenceid or null(self.recurrenceid), 397 self.summary or null(self.summary), 398 self.organiser or null(self.organiser), 399 self.expires or null(self.expires) 400 ) 401 402 def __cmp__(self, other): 403 404 """ 405 Compare this object to 'other', employing the uid if the periods 406 involved are the same. 407 """ 408 409 result = PeriodBase.__cmp__(self, other) 410 if result == 0 and isinstance(other, FreeBusyPeriod): 411 return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid)) 412 else: 413 return result 414 415 def get_key(self): 416 return self.uid, self.recurrenceid, self.get_start() 417 418 def __repr__(self): 419 return "FreeBusyPeriod%r" % (self.as_tuple(),) 420 421 def get_tzid(self): 422 return "UTC" 423 424 # Period and event recurrence logic. 425 426 def is_replaced(self, recurrences): 427 428 """ 429 Return whether this period refers to one of the 'recurrences'. 430 The 'recurrences' must be UTC datetimes corresponding to the start of 431 the period described by a recurrence. 432 """ 433 434 for recurrence in recurrences: 435 if self.is_affected(recurrence): 436 return True 437 return False 438 439 def is_affected(self, recurrence): 440 441 """ 442 Return whether this period refers to 'recurrence'. The 'recurrence' must 443 be a UTC datetime corresponding to the start of the period described by 444 a recurrence. 445 """ 446 447 return recurrence and self.get_start_point() == recurrence 448 449 # Value correction methods. 450 451 def make_corrected(self, start, end): 452 return self.__class__(start, end) 453 454 class RecurringPeriod(Period): 455 456 """ 457 A period with iCalendar metadata attributes and origin information from an 458 object. 459 """ 460 461 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None): 462 Period.__init__(self, start, end, tzid, origin) 463 self.start_attr = start_attr 464 self.end_attr = end_attr 465 466 def get_start_attr(self): 467 return self.start_attr 468 469 def get_end_attr(self): 470 return self.end_attr 471 472 def as_tuple(self): 473 return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr 474 475 def __repr__(self): 476 return "RecurringPeriod%r" % (self.as_tuple(),) 477 478 def make_corrected(self, start, end): 479 return self.__class__(start, end, self.tzid, self.origin, self.get_start_attr(), self.get_end_attr()) 480 481 class FreeBusyCollectionBase: 482 483 "Common operations on free/busy period collections." 484 485 def __init__(self, mutable=True): 486 self.mutable = mutable 487 488 def _check_mutable(self): 489 if not self.mutable: 490 raise TypeError, "Cannot mutate this collection." 491 492 def copy(self): 493 494 "Make an independent mutable copy of the collection." 495 496 return FreeBusyCollection(list(self), True) 497 498 # List emulation methods. 499 500 def __iadd__(self, periods): 501 for period in periods: 502 self.insert_period(period) 503 return self 504 505 def append(self, period): 506 self.insert_period(period) 507 508 # Operations. 509 510 def can_schedule(self, periods, uid, recurrenceid): 511 512 """ 513 Return whether the collection can accommodate the given 'periods' 514 employing the specified 'uid' and 'recurrenceid'. 515 """ 516 517 for conflict in self.have_conflict(periods, True): 518 if conflict.uid != uid or conflict.recurrenceid != recurrenceid: 519 return False 520 521 return True 522 523 def have_conflict(self, periods, get_conflicts=False): 524 525 """ 526 Return whether any period in the collection overlaps with the given 527 'periods', returning a collection of such overlapping periods if 528 'get_conflicts' is set to a true value. 529 """ 530 531 conflicts = set() 532 for p in periods: 533 overlapping = self.period_overlaps(p, get_conflicts) 534 if overlapping: 535 if get_conflicts: 536 conflicts.update(overlapping) 537 else: 538 return True 539 540 if get_conflicts: 541 return conflicts 542 else: 543 return False 544 545 def period_overlaps(self, period, get_periods=False): 546 547 """ 548 Return whether any period in the collection overlaps with the given 549 'period', returning a collection of overlapping periods if 'get_periods' 550 is set to a true value. 551 """ 552 553 overlapping = self.get_overlapping(period) 554 555 if get_periods: 556 return overlapping 557 else: 558 return len(overlapping) != 0 559 560 def replace_overlapping(self, period, replacements): 561 562 """ 563 Replace existing periods in the collection within the given 'period', 564 using the given 'replacements'. 565 """ 566 567 self._check_mutable() 568 569 self.remove_overlapping(period) 570 for replacement in replacements: 571 self.insert_period(replacement) 572 573 def coalesce_freebusy(self): 574 575 "Coalesce the periods in the collection, returning a new collection." 576 577 if not self: 578 return FreeBusyCollection() 579 580 fb = [] 581 582 it = iter(self) 583 period = it.next() 584 585 start = period.get_start_point() 586 end = period.get_end_point() 587 588 try: 589 while True: 590 period = it.next() 591 if period.get_start_point() > end: 592 fb.append(FreeBusyPeriod(start, end)) 593 start = period.get_start_point() 594 end = period.get_end_point() 595 else: 596 end = max(end, period.get_end_point()) 597 except StopIteration: 598 pass 599 600 fb.append(FreeBusyPeriod(start, end)) 601 return FreeBusyCollection(fb) 602 603 def invert_freebusy(self): 604 605 "Return the free periods from the collection as a new collection." 606 607 if not self: 608 return FreeBusyCollection([FreeBusyPeriod(None, None)]) 609 610 # Coalesce periods that overlap or are adjacent. 611 612 fb = self.coalesce_freebusy() 613 free = [] 614 615 # Add a start-of-time period if appropriate. 616 617 first = fb[0].get_start_point() 618 if first: 619 free.append(FreeBusyPeriod(None, first)) 620 621 start = fb[0].get_end_point() 622 623 for period in fb[1:]: 624 free.append(FreeBusyPeriod(start, period.get_start_point())) 625 start = period.get_end_point() 626 627 # Add an end-of-time period if appropriate. 628 629 if start: 630 free.append(FreeBusyPeriod(start, None)) 631 632 return FreeBusyCollection(free) 633 634 def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 635 636 """ 637 Update the free/busy details with the given 'periods', 'transp' setting, 638 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 639 640 An optional 'expires' datetime string indicates the expiry time of any 641 free/busy offer. 642 """ 643 644 self._check_mutable() 645 646 self.remove_event_periods(uid, recurrenceid) 647 648 for p in periods: 649 self.insert_period(FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires)) 650 651 class FreeBusyCollection(FreeBusyCollectionBase): 652 653 "An abstraction for a collection of free/busy periods." 654 655 def __init__(self, periods=None, mutable=True): 656 657 """ 658 Initialise the collection with the given list of 'periods', or start an 659 empty collection if no list is given. If 'mutable' is indicated, the 660 collection may be changed; otherwise, an exception will be raised. 661 """ 662 663 FreeBusyCollectionBase.__init__(self, mutable) 664 self.periods = periods or [] 665 666 # List emulation methods. 667 668 def __nonzero__(self): 669 return bool(self.periods) 670 671 def __iter__(self): 672 return iter(self.periods) 673 674 def __len__(self): 675 return len(self.periods) 676 677 def __getitem__(self, i): 678 return self.periods[i] 679 680 # Operations. 681 682 def insert_period(self, period): 683 684 "Insert the given 'period' into the collection." 685 686 self._check_mutable() 687 688 i = bisect_left(self.periods, period) 689 if i == len(self.periods): 690 self.periods.append(period) 691 elif self.periods[i] != period: 692 self.periods.insert(i, period) 693 694 def remove_periods(self, periods): 695 696 "Remove the given 'periods' from the collection." 697 698 self._check_mutable() 699 700 for period in periods: 701 i = bisect_left(self.periods, period) 702 if i < len(self.periods) and self.periods[i] == period: 703 del self.periods[i] 704 705 def remove_event_periods(self, uid, recurrenceid=None): 706 707 """ 708 Remove from the collection all periods associated with 'uid' and 709 'recurrenceid' (which if omitted causes the "parent" object's periods to 710 be referenced). 711 712 Return the removed periods. 713 """ 714 715 self._check_mutable() 716 717 removed = [] 718 i = 0 719 while i < len(self.periods): 720 fb = self.periods[i] 721 if fb.uid == uid and fb.recurrenceid == recurrenceid: 722 removed.append(self.periods[i]) 723 del self.periods[i] 724 else: 725 i += 1 726 727 return removed 728 729 def remove_additional_periods(self, uid, recurrenceids=None): 730 731 """ 732 Remove from the collection all periods associated with 'uid' having a 733 recurrence identifier indicating an additional or modified period. 734 735 If 'recurrenceids' is specified, remove all periods associated with 736 'uid' that do not have a recurrence identifier in the given list. 737 738 Return the removed periods. 739 """ 740 741 self._check_mutable() 742 743 removed = [] 744 i = 0 745 while i < len(self.periods): 746 fb = self.periods[i] 747 if fb.uid == uid and fb.recurrenceid and ( 748 recurrenceids is None or 749 recurrenceids is not None and fb.recurrenceid not in recurrenceids 750 ): 751 removed.append(self.periods[i]) 752 del self.periods[i] 753 else: 754 i += 1 755 756 return removed 757 758 def remove_affected_period(self, uid, start): 759 760 """ 761 Remove from the collection the period associated with 'uid' that 762 provides an occurrence starting at the given 'start' (provided by a 763 recurrence identifier, converted to a datetime). A recurrence identifier 764 is used to provide an alternative time period whilst also acting as a 765 reference to the originally-defined occurrence. 766 767 Return any removed period in a list. 768 """ 769 770 self._check_mutable() 771 772 removed = [] 773 774 search = Period(start, start) 775 found = bisect_left(self.periods, search) 776 777 while found < len(self.periods): 778 fb = self.periods[found] 779 780 # Stop looking if the start no longer matches the recurrence identifier. 781 782 if fb.get_start_point() != search.get_start_point(): 783 break 784 785 # If the period belongs to the parent object, remove it and return. 786 787 if not fb.recurrenceid and uid == fb.uid: 788 removed.append(self.periods[found]) 789 del self.periods[found] 790 break 791 792 # Otherwise, keep looking for a matching period. 793 794 found += 1 795 796 return removed 797 798 def periods_from(self, period): 799 800 "Return the entries in the collection at or after 'period'." 801 802 first = bisect_left(self.periods, period) 803 return self.periods[first:] 804 805 def periods_until(self, period): 806 807 "Return the entries in the collection before 'period'." 808 809 last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid())) 810 return self.periods[:last] 811 812 def get_overlapping(self, period): 813 814 """ 815 Return the entries in the collection providing periods overlapping with 816 'period'. 817 """ 818 819 # Find the range of periods potentially overlapping the period in the 820 # free/busy collection. 821 822 startpoints = self.periods_until(period) 823 824 # Find the range of periods potentially overlapping the period in a version 825 # of the free/busy collection sorted according to end datetimes. 826 827 endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints] 828 endpoints.sort() 829 first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),)) 830 endpoints = endpoints[first:] 831 832 overlapping = set() 833 834 for p, fb in endpoints: 835 if fb.overlaps(period): 836 overlapping.add(fb) 837 838 overlapping = list(overlapping) 839 overlapping.sort() 840 return overlapping 841 842 def remove_overlapping(self, period): 843 844 "Remove all periods overlapping with 'period' from the collection." 845 846 self._check_mutable() 847 848 overlapping = self.get_overlapping(period) 849 850 if overlapping: 851 for fb in overlapping: 852 self.periods.remove(fb) 853 854 class FreeBusyDatabaseCollection(FreeBusyCollectionBase, DatabaseOperations): 855 856 """ 857 An abstraction for a collection of free/busy periods stored in a database 858 system. 859 """ 860 861 period_columns = ["start", "end", "object_uid", "transp", "object_recurrenceid", "summary", "organiser", "expires"] 862 863 def __init__(self, cursor, table_name, column_names=None, filter_values=None, 864 mutable=True, paramstyle=None): 865 866 """ 867 Initialise the collection with the given 'cursor' and with the 868 'table_name', 'column_names' and 'filter_values' configuring the 869 selection of data. If 'mutable' is indicated, the collection may be 870 changed; otherwise, an exception will be raised. 871 """ 872 873 FreeBusyCollectionBase.__init__(self, mutable) 874 DatabaseOperations.__init__(self, column_names, filter_values, paramstyle) 875 self.cursor = cursor 876 self.table_name = table_name 877 878 def make_period(self, t): 879 return FreeBusyPeriod(*from_strings(t, "utf-8")) 880 881 # List emulation methods. 882 883 def __nonzero__(self): 884 return len(self) and True or False 885 886 def __iter__(self): 887 query, values = self.get_query( 888 "select %(columns)s from %(table)s :condition" % { 889 "columns" : self.columnlist(self.period_columns), 890 "table" : self.table_name 891 }) 892 self.cursor.execute(query, values) 893 return iter(map(lambda t: self.make_period(t), self.cursor.fetchall())) 894 895 def __len__(self): 896 query, values = self.get_query( 897 "select count(*) from %(table)s :condition" % { 898 "table" : self.table_name 899 }) 900 self.cursor.execute(query, values) 901 result = self.cursor.fetchone() 902 return result and int(result[0]) or 0 903 904 def __getitem__(self, i): 905 return list(iter(self))[i] 906 907 # Operations. 908 909 def insert_period(self, period): 910 911 "Insert the given 'period' into the collection." 912 913 self._check_mutable() 914 915 columns, values = self.period_columns, period.as_tuple(string_datetimes=True) 916 917 query, values = self.get_query( 918 "insert into %(table)s (:columns) values (:values)" % { 919 "table" : self.table_name 920 }, 921 columns, [to_string(v, "utf-8") for v in values]) 922 923 self.cursor.execute(query, values) 924 925 def remove_periods(self, periods): 926 927 "Remove the given 'periods' from the collection." 928 929 self._check_mutable() 930 931 for period in periods: 932 values = period.as_tuple(string_datetimes=True) 933 934 query, values = self.get_query( 935 "delete from %(table)s :condition" % { 936 "table" : self.table_name 937 }, 938 self.period_columns, [to_string(v, "utf-8") for v in values]) 939 940 self.cursor.execute(query, values) 941 942 def remove_event_periods(self, uid, recurrenceid=None): 943 944 """ 945 Remove from the collection all periods associated with 'uid' and 946 'recurrenceid' (which if omitted causes the "parent" object's periods to 947 be referenced). 948 949 Return the removed periods. 950 """ 951 952 self._check_mutable() 953 954 if recurrenceid: 955 columns, values = ["object_uid", "object_recurrenceid"], [uid, recurrenceid] 956 else: 957 columns, values = ["object_uid", "object_recurrenceid is null"], [uid] 958 959 query, _values = self.get_query( 960 "select %(columns)s from %(table)s :condition" % { 961 "columns" : self.columnlist(self.period_columns), 962 "table" : self.table_name 963 }, 964 columns, values) 965 966 self.cursor.execute(query, _values) 967 removed = self.cursor.fetchall() 968 969 query, values = self.get_query( 970 "delete from %(table)s :condition" % { 971 "table" : self.table_name 972 }, 973 columns, values) 974 975 self.cursor.execute(query, values) 976 977 return map(lambda t: self.make_period(t), removed) 978 979 def remove_additional_periods(self, uid, recurrenceids=None): 980 981 """ 982 Remove from the collection all periods associated with 'uid' having a 983 recurrence identifier indicating an additional or modified period. 984 985 If 'recurrenceids' is specified, remove all periods associated with 986 'uid' that do not have a recurrence identifier in the given list. 987 988 Return the removed periods. 989 """ 990 991 self._check_mutable() 992 993 if not recurrenceids: 994 columns, values = ["object_uid", "object_recurrenceid is not null"], [uid] 995 else: 996 columns, values = ["object_uid", "object_recurrenceid not in ?", "object_recurrenceid is not null"], [uid, tuple(recurrenceids)] 997 998 query, _values = self.get_query( 999 "select %(columns)s from %(table)s :condition" % { 1000 "columns" : self.columnlist(self.period_columns), 1001 "table" : self.table_name 1002 }, 1003 columns, values) 1004 1005 self.cursor.execute(query, _values) 1006 removed = self.cursor.fetchall() 1007 1008 query, values = self.get_query( 1009 "delete from %(table)s :condition" % { 1010 "table" : self.table_name 1011 }, 1012 columns, values) 1013 1014 self.cursor.execute(query, values) 1015 1016 return map(lambda t: self.make_period(t), removed) 1017 1018 def remove_affected_period(self, uid, start): 1019 1020 """ 1021 Remove from the collection the period associated with 'uid' that 1022 provides an occurrence starting at the given 'start' (provided by a 1023 recurrence identifier, converted to a datetime). A recurrence identifier 1024 is used to provide an alternative time period whilst also acting as a 1025 reference to the originally-defined occurrence. 1026 1027 Return any removed period in a list. 1028 """ 1029 1030 self._check_mutable() 1031 1032 start = format_datetime(start) 1033 1034 columns, values = ["object_uid", "start", "object_recurrenceid is null"], [uid, start] 1035 1036 query, _values = self.get_query( 1037 "select %(columns)s from %(table)s :condition" % { 1038 "columns" : self.columnlist(self.period_columns), 1039 "table" : self.table_name 1040 }, 1041 columns, values) 1042 1043 self.cursor.execute(query, _values) 1044 removed = self.cursor.fetchall() 1045 1046 query, values = self.get_query( 1047 "delete from %(table)s :condition" % { 1048 "table" : self.table_name 1049 }, 1050 columns, values) 1051 1052 self.cursor.execute(query, values) 1053 1054 return map(lambda t: self.make_period(t), removed) 1055 1056 def periods_from(self, period): 1057 1058 "Return the entries in the collection at or after 'period'." 1059 1060 start = format_datetime(period.get_start_point()) 1061 1062 columns, values = [], [] 1063 1064 if start: 1065 columns.append("start >= ?") 1066 values.append(start) 1067 1068 query, values = self.get_query( 1069 "select %(columns)s from %(table)s :condition" % { 1070 "columns" : self.columnlist(self.period_columns), 1071 "table" : self.table_name 1072 }, 1073 columns, values) 1074 1075 self.cursor.execute(query, values) 1076 1077 return map(lambda t: self.make_period(t), self.cursor.fetchall()) 1078 1079 def periods_until(self, period): 1080 1081 "Return the entries in the collection before 'period'." 1082 1083 end = format_datetime(period.get_end_point()) 1084 1085 columns, values = [], [] 1086 1087 if end: 1088 columns.append("start < ?") 1089 values.append(end) 1090 1091 query, values = self.get_query( 1092 "select %(columns)s from %(table)s :condition" % { 1093 "columns" : self.columnlist(self.period_columns), 1094 "table" : self.table_name 1095 }, 1096 columns, values) 1097 1098 self.cursor.execute(query, values) 1099 1100 return map(lambda t: self.make_period(t), self.cursor.fetchall()) 1101 1102 def get_overlapping(self, period): 1103 1104 """ 1105 Return the entries in the collection providing periods overlapping with 1106 'period'. 1107 """ 1108 1109 columns, values = self._get_period_values(period) 1110 1111 query, values = self.get_query( 1112 "select %(columns)s from %(table)s :condition" % { 1113 "columns" : self.columnlist(self.period_columns), 1114 "table" : self.table_name 1115 }, 1116 columns, values) 1117 1118 self.cursor.execute(query, values) 1119 1120 return map(lambda t: self.make_period(t), self.cursor.fetchall()) 1121 1122 def remove_overlapping(self, period): 1123 1124 "Remove all periods overlapping with 'period' from the collection." 1125 1126 self._check_mutable() 1127 1128 columns, values = self._get_period_values(period) 1129 1130 query, values = self.get_query( 1131 "delete from %(table)s :condition" % { 1132 "table" : self.table_name 1133 }, 1134 columns, values) 1135 1136 self.cursor.execute(query, values) 1137 1138 def _get_period_values(self, period): 1139 1140 start = format_datetime(period.get_start_point()) 1141 end = format_datetime(period.get_end_point()) 1142 1143 columns, values = [], [] 1144 1145 if end: 1146 columns.append("start < ?") 1147 values.append(end) 1148 if start: 1149 columns.append("end > ?") 1150 values.append(start) 1151 1152 return columns, values 1153 1154 # Period layout. 1155 1156 def get_scale(periods, tzid, view_period=None): 1157 1158 """ 1159 Return a time scale from the given list of 'periods'. 1160 1161 The given 'tzid' is used to make sure that the times are defined according 1162 to the chosen time zone. 1163 1164 An optional 'view_period' is used to constrain the scale to the given 1165 period. 1166 1167 The returned scale is a mapping from time to (starting, ending) tuples, 1168 where starting and ending are collections of periods. 1169 """ 1170 1171 scale = {} 1172 view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None 1173 view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None 1174 1175 for p in periods: 1176 1177 # Add a point and this event to the starting list. 1178 1179 start = to_timezone(p.get_start(), tzid) 1180 start = view_start and max(start, view_start) or start 1181 if not scale.has_key(start): 1182 scale[start] = [], [] 1183 scale[start][0].append(p) 1184 1185 # Add a point and this event to the ending list. 1186 1187 end = to_timezone(p.get_end(), tzid) 1188 end = view_end and min(end, view_end) or end 1189 if not scale.has_key(end): 1190 scale[end] = [], [] 1191 scale[end][1].append(p) 1192 1193 return scale 1194 1195 class Point: 1196 1197 "A qualified point in time." 1198 1199 PRINCIPAL, REPEATED = 0, 1 1200 1201 def __init__(self, point, indicator=None): 1202 self.point = point 1203 self.indicator = indicator or self.PRINCIPAL 1204 1205 def __hash__(self): 1206 return hash((self.point, self.indicator)) 1207 1208 def __cmp__(self, other): 1209 if isinstance(other, Point): 1210 return cmp((self.point, self.indicator), (other.point, other.indicator)) 1211 elif isinstance(other, datetime): 1212 return cmp(self.point, other) 1213 else: 1214 return 1 1215 1216 def __eq__(self, other): 1217 return self.__cmp__(other) == 0 1218 1219 def __ne__(self, other): 1220 return not self == other 1221 1222 def __lt__(self, other): 1223 return self.__cmp__(other) < 0 1224 1225 def __le__(self, other): 1226 return self.__cmp__(other) <= 0 1227 1228 def __gt__(self, other): 1229 return not self <= other 1230 1231 def __ge__(self, other): 1232 return not self < other 1233 1234 def __repr__(self): 1235 return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL") 1236 1237 def get_slots(scale): 1238 1239 """ 1240 Return an ordered list of time slots from the given 'scale'. 1241 1242 Each slot is a tuple containing details of a point in time for the start of 1243 the slot, together with a list of parallel event periods. 1244 1245 Each point in time is described as a Point representing the actual point in 1246 time together with an indicator of the nature of the point in time (as a 1247 principal point in a time scale or as a repeated point used to terminate 1248 events occurring for an instant in time). 1249 """ 1250 1251 slots = [] 1252 active = [] 1253 1254 points = scale.items() 1255 points.sort() 1256 1257 for point, (starting, ending) in points: 1258 ending = set(ending) 1259 instants = ending.intersection(starting) 1260 1261 # Discard all active events ending at or before this start time. 1262 # Free up the position in the active list. 1263 1264 for t in ending.difference(instants): 1265 i = active.index(t) 1266 active[i] = None 1267 1268 # For each event starting at the current point, fill any newly-vacated 1269 # position or add to the end of the active list. 1270 1271 for t in starting: 1272 try: 1273 i = active.index(None) 1274 active[i] = t 1275 except ValueError: 1276 active.append(t) 1277 1278 # Discard vacant positions from the end of the active list. 1279 1280 while active and active[-1] is None: 1281 active.pop() 1282 1283 # Add an entry for the time point before "instants". 1284 1285 slots.append((Point(point), active[:])) 1286 1287 # Discard events ending at the same time as they began. 1288 1289 if instants: 1290 for t in instants: 1291 i = active.index(t) 1292 active[i] = None 1293 1294 # Discard vacant positions from the end of the active list. 1295 1296 while active and active[-1] is None: 1297 active.pop() 1298 1299 # Add another entry for the time point after "instants". 1300 1301 slots.append((Point(point, Point.REPEATED), active[:])) 1302 1303 return slots 1304 1305 def add_day_start_points(slots, tzid): 1306 1307 """ 1308 Introduce into the 'slots' any day start points required by multi-day 1309 periods. The 'tzid' is required to make sure that appropriate time zones 1310 are chosen and not necessarily those provided by the existing time points. 1311 """ 1312 1313 new_slots = [] 1314 current_date = None 1315 previously_active = [] 1316 1317 for point, active in slots: 1318 start_of_day = get_start_of_day(point.point, tzid) 1319 this_date = point.point.date() 1320 1321 # For each new day, add a slot for the start of the day where periods 1322 # are active and where no such slot already exists. 1323 1324 if this_date != current_date: 1325 1326 # Fill in days where events remain active. 1327 1328 if current_date: 1329 current_date += timedelta(1) 1330 while current_date < this_date: 1331 new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active)) 1332 current_date += timedelta(1) 1333 else: 1334 current_date = this_date 1335 1336 # Add any continuing periods. 1337 1338 if point.point != start_of_day: 1339 new_slots.append((Point(start_of_day), previously_active)) 1340 1341 # Add the currently active periods at this point in time. 1342 1343 previously_active = active 1344 1345 for t in new_slots: 1346 insort_left(slots, t) 1347 1348 def remove_end_slot(slots, view_period): 1349 1350 """ 1351 Remove from 'slots' any slot situated at the end of the given 'view_period'. 1352 """ 1353 1354 end = view_period.get_end_point() 1355 if not end or not slots: 1356 return 1357 i = bisect_left(slots, (Point(end), None)) 1358 if i < len(slots): 1359 del slots[i:] 1360 1361 def add_slots(slots, points): 1362 1363 """ 1364 Introduce into the 'slots' entries for those in 'points' that are not 1365 already present, propagating active periods from time points preceding 1366 those added. 1367 """ 1368 1369 new_slots = [] 1370 1371 for point in points: 1372 i = bisect_left(slots, (point,)) # slots is [(point, active)...] 1373 if i < len(slots) and slots[i][0] == point: 1374 continue 1375 1376 new_slots.append((point, i > 0 and slots[i-1][1] or [])) 1377 1378 for t in new_slots: 1379 insort_left(slots, t) 1380 1381 def partition_by_day(slots): 1382 1383 """ 1384 Return a mapping from dates to time points provided by 'slots'. 1385 """ 1386 1387 d = {} 1388 1389 for point, value in slots: 1390 day = point.point.date() 1391 if not d.has_key(day): 1392 d[day] = [] 1393 d[day].append((point, value)) 1394 1395 return d 1396 1397 def add_empty_days(days, tzid, start=None, end=None): 1398 1399 """ 1400 Add empty days to 'days' between busy days, and optionally from the given 1401 'start' day and until the given 'end' day. 1402 """ 1403 1404 last_day = start - timedelta(1) 1405 all_days = days.keys() 1406 all_days.sort() 1407 1408 for day in all_days: 1409 if last_day: 1410 empty_day = last_day + timedelta(1) 1411 while empty_day < day: 1412 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 1413 empty_day += timedelta(1) 1414 last_day = day 1415 1416 if end: 1417 empty_day = last_day + timedelta(1) 1418 while empty_day < end: 1419 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 1420 empty_day += timedelta(1) 1421 1422 def get_spans(slots): 1423 1424 "Inspect the given 'slots', returning a mapping of period keys to spans." 1425 1426 points = [point for point, active in slots] 1427 spans = {} 1428 1429 for _point, active in slots: 1430 for p in active: 1431 if p: 1432 key = p.get_key() 1433 start_slot = bisect_left(points, p.get_start()) 1434 end_slot = bisect_left(points, p.get_end()) 1435 spans[key] = end_slot - start_slot 1436 1437 return spans 1438 1439 # vim: tabstop=4 expandtab shiftwidth=4