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, summary=None, organiser=None, expires=None): 361 362 """ 363 Initialise a free/busy period with the given 'start' and 'end' points, 364 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 365 details. 366 367 An additional 'expires' parameter can be used to indicate an expiry 368 datetime in conjunction with free/busy offers made when countering 369 event proposals. 370 """ 371 372 PeriodBase.__init__(self, start, end) 373 self.uid = uid 374 self.transp = transp or None 375 self.recurrenceid = recurrenceid or None 376 self.summary = summary or None 377 self.organiser = organiser or None 378 self.expires = expires or None 379 380 def as_tuple(self, strings_only=False, string_datetimes=False): 381 382 """ 383 Return the initialisation parameter tuple, converting datetimes and 384 false value parameters to strings if 'strings_only' is set to a true 385 value. Otherwise, if 'string_datetimes' is set to a true value, only the 386 datetime values are converted to strings. 387 """ 388 389 null = lambda x: (strings_only and [""] or [x])[0] 390 return ( 391 (strings_only or string_datetimes) and format_datetime(self.get_start_point()) or self.start, 392 (strings_only or string_datetimes) and format_datetime(self.get_end_point()) or self.end, 393 self.uid or null(self.uid), 394 self.transp or strings_only and "OPAQUE" or None, 395 self.recurrenceid or null(self.recurrenceid), 396 self.summary or null(self.summary), 397 self.organiser or null(self.organiser), 398 self.expires or null(self.expires) 399 ) 400 401 def __cmp__(self, other): 402 403 """ 404 Compare this object to 'other', employing the uid if the periods 405 involved are the same. 406 """ 407 408 result = PeriodBase.__cmp__(self, other) 409 if result == 0 and isinstance(other, FreeBusyPeriod): 410 return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid)) 411 else: 412 return result 413 414 def get_key(self): 415 return self.uid, self.recurrenceid, self.get_start() 416 417 def __repr__(self): 418 return "FreeBusyPeriod%r" % (self.as_tuple(),) 419 420 def get_tzid(self): 421 return "UTC" 422 423 # Period and event recurrence logic. 424 425 def is_replaced(self, recurrences): 426 427 """ 428 Return whether this period refers to one of the 'recurrences'. 429 The 'recurrences' must be UTC datetimes corresponding to the start of 430 the period described by a recurrence. 431 """ 432 433 for recurrence in recurrences: 434 if self.is_affected(recurrence): 435 return True 436 return False 437 438 def is_affected(self, recurrence): 439 440 """ 441 Return whether this period refers to 'recurrence'. The 'recurrence' must 442 be a UTC datetime corresponding to the start of the period described by 443 a recurrence. 444 """ 445 446 return recurrence and self.get_start_point() == recurrence 447 448 # Value correction methods. 449 450 def make_corrected(self, start, end): 451 return self.__class__(start, end) 452 453 class RecurringPeriod(Period): 454 455 """ 456 A period with iCalendar metadata attributes and origin information from an 457 object. 458 """ 459 460 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None): 461 Period.__init__(self, start, end, tzid, origin) 462 self.start_attr = start_attr 463 self.end_attr = end_attr 464 465 def get_start_attr(self): 466 return self.start_attr 467 468 def get_end_attr(self): 469 return self.end_attr 470 471 def as_tuple(self): 472 return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr 473 474 def __repr__(self): 475 return "RecurringPeriod%r" % (self.as_tuple(),) 476 477 def make_corrected(self, start, end): 478 return self.__class__(start, end, self.tzid, self.origin, self.get_start_attr(), self.get_end_attr()) 479 480 class FreeBusyCollectionBase: 481 482 "Common operations on free/busy period collections." 483 484 def __init__(self, mutable=True): 485 self.mutable = mutable 486 487 def _check_mutable(self): 488 if not self.mutable: 489 raise TypeError, "Cannot mutate this collection." 490 491 def copy(self): 492 493 "Make an independent mutable copy of the collection." 494 495 return FreeBusyCollection(list(self), True) 496 497 # List emulation methods. 498 499 def __iadd__(self, periods): 500 for period in periods: 501 self.insert_period(period) 502 return self 503 504 def append(self, period): 505 self.insert_period(period) 506 507 # Operations. 508 509 def can_schedule(self, periods, uid, recurrenceid): 510 511 """ 512 Return whether the collection can accommodate the given 'periods' 513 employing the specified 'uid' and 'recurrenceid'. 514 """ 515 516 for conflict in self.have_conflict(periods, True): 517 if conflict.uid != uid or conflict.recurrenceid != recurrenceid: 518 return False 519 520 return True 521 522 def have_conflict(self, periods, get_conflicts=False): 523 524 """ 525 Return whether any period in the collection overlaps with the given 526 'periods', returning a collection of such overlapping periods if 527 'get_conflicts' is set to a true value. 528 """ 529 530 conflicts = set() 531 for p in periods: 532 overlapping = self.period_overlaps(p, get_conflicts) 533 if overlapping: 534 if get_conflicts: 535 conflicts.update(overlapping) 536 else: 537 return True 538 539 if get_conflicts: 540 return conflicts 541 else: 542 return False 543 544 def period_overlaps(self, period, get_periods=False): 545 546 """ 547 Return whether any period in the collection overlaps with the given 548 'period', returning a collection of overlapping periods if 'get_periods' 549 is set to a true value. 550 """ 551 552 overlapping = self.get_overlapping(period) 553 554 if get_periods: 555 return overlapping 556 else: 557 return len(overlapping) != 0 558 559 def replace_overlapping(self, period, replacements): 560 561 """ 562 Replace existing periods in the collection within the given 'period', 563 using the given 'replacements'. 564 """ 565 566 self._check_mutable() 567 568 self.remove_overlapping(period) 569 for replacement in replacements: 570 self.insert_period(replacement) 571 572 def coalesce_freebusy(self): 573 574 "Coalesce the periods in the collection, returning a new collection." 575 576 if not self: 577 return FreeBusyCollection() 578 579 fb = [] 580 581 it = iter(self) 582 period = it.next() 583 584 start = period.get_start_point() 585 end = period.get_end_point() 586 587 try: 588 while True: 589 period = it.next() 590 if period.get_start_point() > end: 591 fb.append(FreeBusyPeriod(start, end)) 592 start = period.get_start_point() 593 end = period.get_end_point() 594 else: 595 end = max(end, period.get_end_point()) 596 except StopIteration: 597 pass 598 599 fb.append(FreeBusyPeriod(start, end)) 600 return FreeBusyCollection(fb) 601 602 def invert_freebusy(self): 603 604 "Return the free periods from the collection as a new collection." 605 606 if not self: 607 return FreeBusyCollection([FreeBusyPeriod(None, None)]) 608 609 # Coalesce periods that overlap or are adjacent. 610 611 fb = self.coalesce_freebusy() 612 free = [] 613 614 # Add a start-of-time period if appropriate. 615 616 first = fb[0].get_start_point() 617 if first: 618 free.append(FreeBusyPeriod(None, first)) 619 620 start = fb[0].get_end_point() 621 622 for period in fb[1:]: 623 free.append(FreeBusyPeriod(start, period.get_start_point())) 624 start = period.get_end_point() 625 626 # Add an end-of-time period if appropriate. 627 628 if start: 629 free.append(FreeBusyPeriod(start, None)) 630 631 return FreeBusyCollection(free) 632 633 def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 634 635 """ 636 Update the free/busy details with the given 'periods', 'transp' setting, 637 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 638 639 An optional 'expires' datetime string indicates the expiry time of any 640 free/busy offer. 641 """ 642 643 self._check_mutable() 644 645 self.remove_event_periods(uid, recurrenceid) 646 647 for p in periods: 648 self.insert_period(FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires)) 649 650 class FreeBusyCollection(FreeBusyCollectionBase): 651 652 "An abstraction for a collection of free/busy periods." 653 654 def __init__(self, periods=None, mutable=True): 655 656 """ 657 Initialise the collection with the given list of 'periods', or start an 658 empty collection if no list is given. 659 """ 660 661 FreeBusyCollectionBase.__init__(self, mutable) 662 self.periods = periods or [] 663 664 # List emulation methods. 665 666 def __nonzero__(self): 667 return bool(self.periods) 668 669 def __iter__(self): 670 return iter(self.periods) 671 672 def __len__(self): 673 return len(self.periods) 674 675 def __getitem__(self, i): 676 return self.periods[i] 677 678 # Operations. 679 680 def insert_period(self, period): 681 682 "Insert the given 'period' into the collection." 683 684 self._check_mutable() 685 686 i = bisect_left(self.periods, period) 687 if i == len(self.periods): 688 self.periods.append(period) 689 elif self.periods[i] != period: 690 self.periods.insert(i, period) 691 692 def remove_periods(self, periods): 693 694 "Remove the given 'periods' from the collection." 695 696 self._check_mutable() 697 698 for period in periods: 699 i = bisect_left(self.periods, period) 700 if i < len(self.periods) and self.periods[i] == period: 701 del self.periods[i] 702 703 def remove_event_periods(self, uid, recurrenceid=None): 704 705 """ 706 Remove from the collection all periods associated with 'uid' and 707 'recurrenceid' (which if omitted causes the "parent" object's periods to 708 be referenced). 709 710 Return the removed periods. 711 """ 712 713 self._check_mutable() 714 715 removed = [] 716 i = 0 717 while i < len(self.periods): 718 fb = self.periods[i] 719 if fb.uid == uid and fb.recurrenceid == recurrenceid: 720 removed.append(self.periods[i]) 721 del self.periods[i] 722 else: 723 i += 1 724 725 return removed 726 727 def remove_additional_periods(self, uid, recurrenceids=None): 728 729 """ 730 Remove from the collection all periods associated with 'uid' having a 731 recurrence identifier indicating an additional or modified period. 732 733 If 'recurrenceids' is specified, remove all periods associated with 734 'uid' that do not have a recurrence identifier in the given list. 735 736 Return the removed periods. 737 """ 738 739 self._check_mutable() 740 741 removed = [] 742 i = 0 743 while i < len(self.periods): 744 fb = self.periods[i] 745 if fb.uid == uid and fb.recurrenceid and ( 746 recurrenceids is None or 747 recurrenceids is not None and fb.recurrenceid not in recurrenceids 748 ): 749 removed.append(self.periods[i]) 750 del self.periods[i] 751 else: 752 i += 1 753 754 return removed 755 756 def remove_affected_period(self, uid, start): 757 758 """ 759 Remove from the collection the period associated with 'uid' that 760 provides an occurrence starting at the given 'start' (provided by a 761 recurrence identifier, converted to a datetime). A recurrence identifier 762 is used to provide an alternative time period whilst also acting as a 763 reference to the originally-defined occurrence. 764 765 Return any removed period in a list. 766 """ 767 768 self._check_mutable() 769 770 removed = [] 771 772 search = Period(start, start) 773 found = bisect_left(self.periods, search) 774 775 while found < len(self.periods): 776 fb = self.periods[found] 777 778 # Stop looking if the start no longer matches the recurrence identifier. 779 780 if fb.get_start_point() != search.get_start_point(): 781 break 782 783 # If the period belongs to the parent object, remove it and return. 784 785 if not fb.recurrenceid and uid == fb.uid: 786 removed.append(self.periods[found]) 787 del self.periods[found] 788 break 789 790 # Otherwise, keep looking for a matching period. 791 792 found += 1 793 794 return removed 795 796 def periods_from(self, period): 797 798 "Return the entries in the collection at or after 'period'." 799 800 first = bisect_left(self.periods, period) 801 return self.periods[first:] 802 803 def periods_until(self, period): 804 805 "Return the entries in the collection before 'period'." 806 807 last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid())) 808 return self.periods[:last] 809 810 def get_overlapping(self, period): 811 812 """ 813 Return the entries in the collection providing periods overlapping with 814 'period'. 815 """ 816 817 # Find the range of periods potentially overlapping the period in the 818 # free/busy collection. 819 820 startpoints = self.periods_until(period) 821 822 # Find the range of periods potentially overlapping the period in a version 823 # of the free/busy collection sorted according to end datetimes. 824 825 endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints] 826 endpoints.sort() 827 first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),)) 828 endpoints = endpoints[first:] 829 830 overlapping = set() 831 832 for p, fb in endpoints: 833 if fb.overlaps(period): 834 overlapping.add(fb) 835 836 overlapping = list(overlapping) 837 overlapping.sort() 838 return overlapping 839 840 def remove_overlapping(self, period): 841 842 "Remove all periods overlapping with 'period' from the collection." 843 844 self._check_mutable() 845 846 overlapping = self.get_overlapping(period) 847 848 if overlapping: 849 for fb in overlapping: 850 self.periods.remove(fb) 851 852 class FreeBusyDatabaseCollection(FreeBusyCollectionBase, DatabaseOperations): 853 854 """ 855 An abstraction for a collection of free/busy periods stored in a database 856 system. 857 """ 858 859 period_columns = ["start", "end", "object_uid", "transp", "object_recurrenceid", "summary", "organiser", "expires"] 860 861 def __init__(self, cursor, table_name, column_names=None, filter_values=None, mutable=True, paramstyle=None): 862 863 """ 864 Initialise the collection with the given 'cursor' and with the 865 'table_name', 'column_names' and 'filter_values' configuring the 866 selection of data. 867 """ 868 869 FreeBusyCollectionBase.__init__(self, mutable) 870 DatabaseOperations.__init__(self, column_names, filter_values, paramstyle) 871 self.cursor = cursor 872 self.table_name = table_name 873 874 def make_period(self, t): 875 return FreeBusyPeriod(*from_strings(t, "utf-8")) 876 877 # List emulation methods. 878 879 def __nonzero__(self): 880 return len(self) and True or False 881 882 def __iter__(self): 883 query, values = self.get_query( 884 "select %(columns)s from %(table)s :condition" % { 885 "columns" : self.columnlist(self.period_columns), 886 "table" : self.table_name 887 }) 888 self.cursor.execute(query, values) 889 return iter(map(lambda t: self.make_period(t), self.cursor.fetchall())) 890 891 def __len__(self): 892 query, values = self.get_query( 893 "select count(*) from %(table)s :condition" % { 894 "table" : self.table_name 895 }) 896 self.cursor.execute(query, values) 897 result = self.cursor.fetchone() 898 return result and int(result[0]) or 0 899 900 def __getitem__(self, i): 901 return list(iter(self))[i] 902 903 # Operations. 904 905 def insert_period(self, period): 906 907 "Insert the given 'period' into the collection." 908 909 self._check_mutable() 910 911 columns, values = self.period_columns, period.as_tuple(string_datetimes=True) 912 913 query, values = self.get_query( 914 "insert into %(table)s (:columns) values (:values)" % { 915 "table" : self.table_name 916 }, 917 columns, [to_string(v, "utf-8") for v in values]) 918 919 self.cursor.execute(query, values) 920 921 def remove_periods(self, periods): 922 923 "Remove the given 'periods' from the collection." 924 925 self._check_mutable() 926 927 for period in periods: 928 values = period.as_tuple(string_datetimes=True) 929 930 query, values = self.get_query( 931 "delete from %(table)s :condition" % { 932 "table" : self.table_name 933 }, 934 self.period_columns, [to_string(v, "utf-8") for v in values]) 935 936 self.cursor.execute(query, values) 937 938 def remove_event_periods(self, uid, recurrenceid=None): 939 940 """ 941 Remove from the collection all periods associated with 'uid' and 942 'recurrenceid' (which if omitted causes the "parent" object's periods to 943 be referenced). 944 945 Return the removed periods. 946 """ 947 948 self._check_mutable() 949 950 if recurrenceid: 951 columns, values = ["object_uid", "object_recurrenceid"], [uid, recurrenceid] 952 else: 953 columns, values = ["object_uid", "object_recurrenceid is null"], [uid] 954 955 query, _values = self.get_query( 956 "select %(columns)s from %(table)s :condition" % { 957 "columns" : self.columnlist(self.period_columns), 958 "table" : self.table_name 959 }, 960 columns, values) 961 962 self.cursor.execute(query, _values) 963 removed = self.cursor.fetchall() 964 965 query, values = self.get_query( 966 "delete from %(table)s :condition" % { 967 "table" : self.table_name 968 }, 969 columns, values) 970 971 self.cursor.execute(query, values) 972 973 return map(lambda t: self.make_period(t), removed) 974 975 def remove_additional_periods(self, uid, recurrenceids=None): 976 977 """ 978 Remove from the collection all periods associated with 'uid' having a 979 recurrence identifier indicating an additional or modified period. 980 981 If 'recurrenceids' is specified, remove all periods associated with 982 'uid' that do not have a recurrence identifier in the given list. 983 984 Return the removed periods. 985 """ 986 987 self._check_mutable() 988 989 if not recurrenceids: 990 columns, values = ["object_uid", "object_recurrenceid is not null"], [uid] 991 else: 992 columns, values = ["object_uid", "object_recurrenceid not in ?", "object_recurrenceid is not null"], [uid, tuple(recurrenceids)] 993 994 query, _values = self.get_query( 995 "select %(columns)s from %(table)s :condition" % { 996 "columns" : self.columnlist(self.period_columns), 997 "table" : self.table_name 998 }, 999 columns, values) 1000 1001 self.cursor.execute(query, _values) 1002 removed = self.cursor.fetchall() 1003 1004 query, values = self.get_query( 1005 "delete from %(table)s :condition" % { 1006 "table" : self.table_name 1007 }, 1008 columns, values) 1009 1010 self.cursor.execute(query, values) 1011 1012 return map(lambda t: self.make_period(t), removed) 1013 1014 def remove_affected_period(self, uid, start): 1015 1016 """ 1017 Remove from the collection the period associated with 'uid' that 1018 provides an occurrence starting at the given 'start' (provided by a 1019 recurrence identifier, converted to a datetime). A recurrence identifier 1020 is used to provide an alternative time period whilst also acting as a 1021 reference to the originally-defined occurrence. 1022 1023 Return any removed period in a list. 1024 """ 1025 1026 self._check_mutable() 1027 1028 start = format_datetime(start) 1029 1030 columns, values = ["object_uid", "start", "object_recurrenceid is null"], [uid, start] 1031 1032 query, _values = self.get_query( 1033 "select %(columns)s from %(table)s :condition" % { 1034 "columns" : self.columnlist(self.period_columns), 1035 "table" : self.table_name 1036 }, 1037 columns, values) 1038 1039 self.cursor.execute(query, _values) 1040 removed = self.cursor.fetchall() 1041 1042 query, values = self.get_query( 1043 "delete from %(table)s :condition" % { 1044 "table" : self.table_name 1045 }, 1046 columns, values) 1047 1048 self.cursor.execute(query, values) 1049 1050 return map(lambda t: self.make_period(t), removed) 1051 1052 def periods_from(self, period): 1053 1054 "Return the entries in the collection at or after 'period'." 1055 1056 start = format_datetime(period.get_start_point()) 1057 1058 columns, values = [], [] 1059 1060 if start: 1061 columns.append("start >= ?") 1062 values.append(start) 1063 1064 query, values = self.get_query( 1065 "select %(columns)s from %(table)s :condition" % { 1066 "columns" : self.columnlist(self.period_columns), 1067 "table" : self.table_name 1068 }, 1069 columns, values) 1070 1071 self.cursor.execute(query, values) 1072 1073 return map(lambda t: self.make_period(t), self.cursor.fetchall()) 1074 1075 def periods_until(self, period): 1076 1077 "Return the entries in the collection before 'period'." 1078 1079 end = format_datetime(period.get_end_point()) 1080 1081 columns, values = [], [] 1082 1083 if end: 1084 columns.append("start < ?") 1085 values.append(end) 1086 1087 query, values = self.get_query( 1088 "select %(columns)s from %(table)s :condition" % { 1089 "columns" : self.columnlist(self.period_columns), 1090 "table" : self.table_name 1091 }, 1092 columns, values) 1093 1094 self.cursor.execute(query, values) 1095 1096 return map(lambda t: self.make_period(t), self.cursor.fetchall()) 1097 1098 def get_overlapping(self, period): 1099 1100 """ 1101 Return the entries in the collection providing periods overlapping with 1102 'period'. 1103 """ 1104 1105 columns, values = self._get_period_values(period) 1106 1107 query, values = self.get_query( 1108 "select %(columns)s from %(table)s :condition" % { 1109 "columns" : self.columnlist(self.period_columns), 1110 "table" : self.table_name 1111 }, 1112 columns, values) 1113 1114 self.cursor.execute(query, values) 1115 1116 return map(lambda t: self.make_period(t), self.cursor.fetchall()) 1117 1118 def remove_overlapping(self, period): 1119 1120 "Remove all periods overlapping with 'period' from the collection." 1121 1122 self._check_mutable() 1123 1124 columns, values = self._get_period_values(period) 1125 1126 query, values = self.get_query( 1127 "delete from %(table)s :condition" % { 1128 "table" : self.table_name 1129 }, 1130 columns, values) 1131 1132 self.cursor.execute(query, values) 1133 1134 def _get_period_values(self, period): 1135 1136 start = format_datetime(period.get_start_point()) 1137 end = format_datetime(period.get_end_point()) 1138 1139 columns, values = [], [] 1140 1141 if end: 1142 columns.append("start < ?") 1143 values.append(end) 1144 if start: 1145 columns.append("end > ?") 1146 values.append(start) 1147 1148 return columns, values 1149 1150 # Period layout. 1151 1152 def get_scale(periods, tzid, view_period=None): 1153 1154 """ 1155 Return a time scale from the given list of 'periods'. 1156 1157 The given 'tzid' is used to make sure that the times are defined according 1158 to the chosen time zone. 1159 1160 An optional 'view_period' is used to constrain the scale to the given 1161 period. 1162 1163 The returned scale is a mapping from time to (starting, ending) tuples, 1164 where starting and ending are collections of periods. 1165 """ 1166 1167 scale = {} 1168 view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None 1169 view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None 1170 1171 for p in periods: 1172 1173 # Add a point and this event to the starting list. 1174 1175 start = to_timezone(p.get_start(), tzid) 1176 start = view_start and max(start, view_start) or start 1177 if not scale.has_key(start): 1178 scale[start] = [], [] 1179 scale[start][0].append(p) 1180 1181 # Add a point and this event to the ending list. 1182 1183 end = to_timezone(p.get_end(), tzid) 1184 end = view_end and min(end, view_end) or end 1185 if not scale.has_key(end): 1186 scale[end] = [], [] 1187 scale[end][1].append(p) 1188 1189 return scale 1190 1191 class Point: 1192 1193 "A qualified point in time." 1194 1195 PRINCIPAL, REPEATED = 0, 1 1196 1197 def __init__(self, point, indicator=None): 1198 self.point = point 1199 self.indicator = indicator or self.PRINCIPAL 1200 1201 def __hash__(self): 1202 return hash((self.point, self.indicator)) 1203 1204 def __cmp__(self, other): 1205 if isinstance(other, Point): 1206 return cmp((self.point, self.indicator), (other.point, other.indicator)) 1207 elif isinstance(other, datetime): 1208 return cmp(self.point, other) 1209 else: 1210 return 1 1211 1212 def __eq__(self, other): 1213 return self.__cmp__(other) == 0 1214 1215 def __ne__(self, other): 1216 return not self == other 1217 1218 def __lt__(self, other): 1219 return self.__cmp__(other) < 0 1220 1221 def __le__(self, other): 1222 return self.__cmp__(other) <= 0 1223 1224 def __gt__(self, other): 1225 return not self <= other 1226 1227 def __ge__(self, other): 1228 return not self < other 1229 1230 def __repr__(self): 1231 return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL") 1232 1233 def get_slots(scale): 1234 1235 """ 1236 Return an ordered list of time slots from the given 'scale'. 1237 1238 Each slot is a tuple containing details of a point in time for the start of 1239 the slot, together with a list of parallel event periods. 1240 1241 Each point in time is described as a Point representing the actual point in 1242 time together with an indicator of the nature of the point in time (as a 1243 principal point in a time scale or as a repeated point used to terminate 1244 events occurring for an instant in time). 1245 """ 1246 1247 slots = [] 1248 active = [] 1249 1250 points = scale.items() 1251 points.sort() 1252 1253 for point, (starting, ending) in points: 1254 ending = set(ending) 1255 instants = ending.intersection(starting) 1256 1257 # Discard all active events ending at or before this start time. 1258 # Free up the position in the active list. 1259 1260 for t in ending.difference(instants): 1261 i = active.index(t) 1262 active[i] = None 1263 1264 # For each event starting at the current point, fill any newly-vacated 1265 # position or add to the end of the active list. 1266 1267 for t in starting: 1268 try: 1269 i = active.index(None) 1270 active[i] = t 1271 except ValueError: 1272 active.append(t) 1273 1274 # Discard vacant positions from the end of the active list. 1275 1276 while active and active[-1] is None: 1277 active.pop() 1278 1279 # Add an entry for the time point before "instants". 1280 1281 slots.append((Point(point), active[:])) 1282 1283 # Discard events ending at the same time as they began. 1284 1285 if instants: 1286 for t in instants: 1287 i = active.index(t) 1288 active[i] = None 1289 1290 # Discard vacant positions from the end of the active list. 1291 1292 while active and active[-1] is None: 1293 active.pop() 1294 1295 # Add another entry for the time point after "instants". 1296 1297 slots.append((Point(point, Point.REPEATED), active[:])) 1298 1299 return slots 1300 1301 def add_day_start_points(slots, tzid): 1302 1303 """ 1304 Introduce into the 'slots' any day start points required by multi-day 1305 periods. The 'tzid' is required to make sure that appropriate time zones 1306 are chosen and not necessarily those provided by the existing time points. 1307 """ 1308 1309 new_slots = [] 1310 current_date = None 1311 previously_active = [] 1312 1313 for point, active in slots: 1314 start_of_day = get_start_of_day(point.point, tzid) 1315 this_date = point.point.date() 1316 1317 # For each new day, add a slot for the start of the day where periods 1318 # are active and where no such slot already exists. 1319 1320 if this_date != current_date: 1321 1322 # Fill in days where events remain active. 1323 1324 if current_date: 1325 current_date += timedelta(1) 1326 while current_date < this_date: 1327 new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active)) 1328 current_date += timedelta(1) 1329 else: 1330 current_date = this_date 1331 1332 # Add any continuing periods. 1333 1334 if point.point != start_of_day: 1335 new_slots.append((Point(start_of_day), previously_active)) 1336 1337 # Add the currently active periods at this point in time. 1338 1339 previously_active = active 1340 1341 for t in new_slots: 1342 insort_left(slots, t) 1343 1344 def remove_end_slot(slots, view_period): 1345 1346 """ 1347 Remove from 'slots' any slot situated at the end of the given 'view_period'. 1348 """ 1349 1350 end = view_period.get_end_point() 1351 if not end or not slots: 1352 return 1353 i = bisect_left(slots, (Point(end), None)) 1354 if i < len(slots): 1355 del slots[i:] 1356 1357 def add_slots(slots, points): 1358 1359 """ 1360 Introduce into the 'slots' entries for those in 'points' that are not 1361 already present, propagating active periods from time points preceding 1362 those added. 1363 """ 1364 1365 new_slots = [] 1366 1367 for point in points: 1368 i = bisect_left(slots, (point,)) # slots is [(point, active)...] 1369 if i < len(slots) and slots[i][0] == point: 1370 continue 1371 1372 new_slots.append((point, i > 0 and slots[i-1][1] or [])) 1373 1374 for t in new_slots: 1375 insort_left(slots, t) 1376 1377 def partition_by_day(slots): 1378 1379 """ 1380 Return a mapping from dates to time points provided by 'slots'. 1381 """ 1382 1383 d = {} 1384 1385 for point, value in slots: 1386 day = point.point.date() 1387 if not d.has_key(day): 1388 d[day] = [] 1389 d[day].append((point, value)) 1390 1391 return d 1392 1393 def add_empty_days(days, tzid, start=None, end=None): 1394 1395 """ 1396 Add empty days to 'days' between busy days, and optionally from the given 1397 'start' day and until the given 'end' day. 1398 """ 1399 1400 last_day = start - timedelta(1) 1401 all_days = days.keys() 1402 all_days.sort() 1403 1404 for day in all_days: 1405 if last_day: 1406 empty_day = last_day + timedelta(1) 1407 while empty_day < day: 1408 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 1409 empty_day += timedelta(1) 1410 last_day = day 1411 1412 if end: 1413 empty_day = last_day + timedelta(1) 1414 while empty_day < end: 1415 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 1416 empty_day += timedelta(1) 1417 1418 def get_spans(slots): 1419 1420 "Inspect the given 'slots', returning a mapping of period keys to spans." 1421 1422 points = [point for point, active in slots] 1423 spans = {} 1424 1425 for _point, active in slots: 1426 for p in active: 1427 if p: 1428 key = p.get_key() 1429 start_slot = bisect_left(points, p.get_start()) 1430 end_slot = bisect_left(points, p.get_end()) 1431 spans[key] = end_slot - start_slot 1432 1433 return spans 1434 1435 # vim: tabstop=4 expandtab shiftwidth=4