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