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