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