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