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, paramstyle=None): 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, paramstyle) 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" : self.columnlist(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 int(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" : self.columnlist(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, values = self.get_query( 937 "delete from %(table)s :condition" % { 938 "table" : self.table_name 939 }, 940 columns, values) 941 942 self.cursor.execute(query, values) 943 944 return map(lambda t: FreeBusyPeriod(*t), removed) 945 946 def remove_additional_periods(self, uid, recurrenceids=None): 947 948 """ 949 Remove from the collection all periods associated with 'uid' having a 950 recurrence identifier indicating an additional or modified period. 951 952 If 'recurrenceids' is specified, remove all periods associated with 953 'uid' that do not have a recurrence identifier in the given list. 954 955 Return the removed periods. 956 """ 957 958 self._check_mutable() 959 960 if recurrenceids is None: 961 columns, values = ["object_uid", "object_recurrenceid is not null"], [uid] 962 else: 963 columns, values = ["object_uid", "object_recurrenceid not in ?", "object_recurrenceid is not null"], [uid, tuple(recurrenceids)] 964 965 query, _values = self.get_query( 966 "select %(columns)s from %(table)s :condition" % { 967 "columns" : self.columnlist(self.period_columns), 968 "table" : self.table_name 969 }, 970 columns, values) 971 972 self.cursor.execute(query, _values) 973 removed = self.cursor.fetchall() 974 975 query, values = self.get_query( 976 "delete from %(table)s :condition" % { 977 "table" : self.table_name 978 }, 979 columns, values) 980 981 self.cursor.execute(query, values) 982 983 return map(lambda t: FreeBusyPeriod(*t), removed) 984 985 def remove_affected_period(self, uid, start): 986 987 """ 988 Remove from the collection the period associated with 'uid' that 989 provides an occurrence starting at the given 'start' (provided by a 990 recurrence identifier, converted to a datetime). A recurrence identifier 991 is used to provide an alternative time period whilst also acting as a 992 reference to the originally-defined occurrence. 993 994 Return any removed period in a list. 995 """ 996 997 self._check_mutable() 998 999 start = format_datetime(start) 1000 1001 columns, values = ["object_uid", "start", "object_recurrenceid is null"], [uid, start] 1002 1003 query, _values = self.get_query( 1004 "select %(columns)s from %(table)s :condition" % { 1005 "columns" : self.columnlist(self.period_columns), 1006 "table" : self.table_name 1007 }, 1008 columns, values) 1009 1010 self.cursor.execute(query, _values) 1011 removed = self.cursor.fetchall() 1012 1013 query, values = self.get_query( 1014 "delete from %(table)s :condition" % { 1015 "table" : self.table_name 1016 }, 1017 columns, values) 1018 1019 self.cursor.execute(query, values) 1020 1021 return map(lambda t: FreeBusyPeriod(*t), removed) 1022 1023 def periods_from(self, period): 1024 1025 "Return the entries in the collection at or after 'period'." 1026 1027 columns, values = ["start >= ?"], [format_datetime(period.get_start_point())] 1028 1029 query, values = self.get_query( 1030 "select %(columns)s from %(table)s :condition" % { 1031 "columns" : self.columnlist(self.period_columns), 1032 "table" : self.table_name 1033 }, 1034 columns, values) 1035 1036 self.cursor.execute(query, values) 1037 1038 return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall()) 1039 1040 def periods_until(self, period): 1041 1042 "Return the entries in the collection before 'period'." 1043 1044 columns, values = ["start < ?"], [format_datetime(period.get_end_point())] 1045 1046 query, values = self.get_query( 1047 "select %(columns)s from %(table)s :condition" % { 1048 "columns" : self.columnlist(self.period_columns), 1049 "table" : self.table_name 1050 }, 1051 columns, values) 1052 1053 self.cursor.execute(query, values) 1054 1055 return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall()) 1056 1057 def get_overlapping(self, period): 1058 1059 """ 1060 Return the entries in the collection providing periods overlapping with 1061 'period'. 1062 """ 1063 1064 columns, values = ["start < ?", "end > ?"], [format_datetime(period.get_end_point()), format_datetime(period.get_start_point())] 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 remove_overlapping(self, period): 1078 1079 "Remove all periods overlapping with 'period' from the collection." 1080 1081 self._check_mutable() 1082 1083 columns, values = ["start < ?", "end > ?"], [format_datetime(period.get_end_point()), format_datetime(period.get_start_point())] 1084 1085 query, values = self.get_query( 1086 "delete from %(table)s :condition" % { 1087 "table" : self.table_name 1088 }, 1089 columns, values) 1090 1091 self.cursor.execute(query, values) 1092 1093 # Period layout. 1094 1095 def get_scale(periods, tzid, view_period=None): 1096 1097 """ 1098 Return a time scale from the given list of 'periods'. 1099 1100 The given 'tzid' is used to make sure that the times are defined according 1101 to the chosen time zone. 1102 1103 An optional 'view_period' is used to constrain the scale to the given 1104 period. 1105 1106 The returned scale is a mapping from time to (starting, ending) tuples, 1107 where starting and ending are collections of periods. 1108 """ 1109 1110 scale = {} 1111 view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None 1112 view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None 1113 1114 for p in periods: 1115 1116 # Add a point and this event to the starting list. 1117 1118 start = to_timezone(p.get_start(), tzid) 1119 start = view_start and max(start, view_start) or start 1120 if not scale.has_key(start): 1121 scale[start] = [], [] 1122 scale[start][0].append(p) 1123 1124 # Add a point and this event to the ending list. 1125 1126 end = to_timezone(p.get_end(), tzid) 1127 end = view_end and min(end, view_end) or end 1128 if not scale.has_key(end): 1129 scale[end] = [], [] 1130 scale[end][1].append(p) 1131 1132 return scale 1133 1134 class Point: 1135 1136 "A qualified point in time." 1137 1138 PRINCIPAL, REPEATED = 0, 1 1139 1140 def __init__(self, point, indicator=None): 1141 self.point = point 1142 self.indicator = indicator or self.PRINCIPAL 1143 1144 def __hash__(self): 1145 return hash((self.point, self.indicator)) 1146 1147 def __cmp__(self, other): 1148 if isinstance(other, Point): 1149 return cmp((self.point, self.indicator), (other.point, other.indicator)) 1150 elif isinstance(other, datetime): 1151 return cmp(self.point, other) 1152 else: 1153 return 1 1154 1155 def __eq__(self, other): 1156 return self.__cmp__(other) == 0 1157 1158 def __ne__(self, other): 1159 return not self == other 1160 1161 def __lt__(self, other): 1162 return self.__cmp__(other) < 0 1163 1164 def __le__(self, other): 1165 return self.__cmp__(other) <= 0 1166 1167 def __gt__(self, other): 1168 return not self <= other 1169 1170 def __ge__(self, other): 1171 return not self < other 1172 1173 def __repr__(self): 1174 return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL") 1175 1176 def get_slots(scale): 1177 1178 """ 1179 Return an ordered list of time slots from the given 'scale'. 1180 1181 Each slot is a tuple containing details of a point in time for the start of 1182 the slot, together with a list of parallel event periods. 1183 1184 Each point in time is described as a Point representing the actual point in 1185 time together with an indicator of the nature of the point in time (as a 1186 principal point in a time scale or as a repeated point used to terminate 1187 events occurring for an instant in time). 1188 """ 1189 1190 slots = [] 1191 active = [] 1192 1193 points = scale.items() 1194 points.sort() 1195 1196 for point, (starting, ending) in points: 1197 ending = set(ending) 1198 instants = ending.intersection(starting) 1199 1200 # Discard all active events ending at or before this start time. 1201 # Free up the position in the active list. 1202 1203 for t in ending.difference(instants): 1204 i = active.index(t) 1205 active[i] = None 1206 1207 # For each event starting at the current point, fill any newly-vacated 1208 # position or add to the end of the active list. 1209 1210 for t in starting: 1211 try: 1212 i = active.index(None) 1213 active[i] = t 1214 except ValueError: 1215 active.append(t) 1216 1217 # Discard vacant positions from the end of the active list. 1218 1219 while active and active[-1] is None: 1220 active.pop() 1221 1222 # Add an entry for the time point before "instants". 1223 1224 slots.append((Point(point), active[:])) 1225 1226 # Discard events ending at the same time as they began. 1227 1228 if instants: 1229 for t in instants: 1230 i = active.index(t) 1231 active[i] = None 1232 1233 # Discard vacant positions from the end of the active list. 1234 1235 while active and active[-1] is None: 1236 active.pop() 1237 1238 # Add another entry for the time point after "instants". 1239 1240 slots.append((Point(point, Point.REPEATED), active[:])) 1241 1242 return slots 1243 1244 def add_day_start_points(slots, tzid): 1245 1246 """ 1247 Introduce into the 'slots' any day start points required by multi-day 1248 periods. The 'tzid' is required to make sure that appropriate time zones 1249 are chosen and not necessarily those provided by the existing time points. 1250 """ 1251 1252 new_slots = [] 1253 current_date = None 1254 previously_active = [] 1255 1256 for point, active in slots: 1257 start_of_day = get_start_of_day(point.point, tzid) 1258 this_date = point.point.date() 1259 1260 # For each new day, add a slot for the start of the day where periods 1261 # are active and where no such slot already exists. 1262 1263 if this_date != current_date: 1264 1265 # Fill in days where events remain active. 1266 1267 if current_date: 1268 current_date += timedelta(1) 1269 while current_date < this_date: 1270 new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active)) 1271 current_date += timedelta(1) 1272 else: 1273 current_date = this_date 1274 1275 # Add any continuing periods. 1276 1277 if point.point != start_of_day: 1278 new_slots.append((Point(start_of_day), previously_active)) 1279 1280 # Add the currently active periods at this point in time. 1281 1282 previously_active = active 1283 1284 for t in new_slots: 1285 insort_left(slots, t) 1286 1287 def remove_end_slot(slots, view_period): 1288 1289 """ 1290 Remove from 'slots' any slot situated at the end of the given 'view_period'. 1291 """ 1292 1293 end = view_period.get_end_point() 1294 if not end or not slots: 1295 return 1296 i = bisect_left(slots, (Point(end), None)) 1297 if i < len(slots): 1298 del slots[i:] 1299 1300 def add_slots(slots, points): 1301 1302 """ 1303 Introduce into the 'slots' entries for those in 'points' that are not 1304 already present, propagating active periods from time points preceding 1305 those added. 1306 """ 1307 1308 new_slots = [] 1309 1310 for point in points: 1311 i = bisect_left(slots, (point,)) # slots is [(point, active)...] 1312 if i < len(slots) and slots[i][0] == point: 1313 continue 1314 1315 new_slots.append((point, i > 0 and slots[i-1][1] or [])) 1316 1317 for t in new_slots: 1318 insort_left(slots, t) 1319 1320 def partition_by_day(slots): 1321 1322 """ 1323 Return a mapping from dates to time points provided by 'slots'. 1324 """ 1325 1326 d = {} 1327 1328 for point, value in slots: 1329 day = point.point.date() 1330 if not d.has_key(day): 1331 d[day] = [] 1332 d[day].append((point, value)) 1333 1334 return d 1335 1336 def add_empty_days(days, tzid, start=None, end=None): 1337 1338 """ 1339 Add empty days to 'days' between busy days, and optionally from the given 1340 'start' day and until the given 'end' day. 1341 """ 1342 1343 last_day = start - timedelta(1) 1344 all_days = days.keys() 1345 all_days.sort() 1346 1347 for day in all_days: 1348 if last_day: 1349 empty_day = last_day + timedelta(1) 1350 while empty_day < day: 1351 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 1352 empty_day += timedelta(1) 1353 last_day = day 1354 1355 if end: 1356 empty_day = last_day + timedelta(1) 1357 while empty_day < end: 1358 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)] 1359 empty_day += timedelta(1) 1360 1361 def get_spans(slots): 1362 1363 "Inspect the given 'slots', returning a mapping of period keys to spans." 1364 1365 points = [point for point, active in slots] 1366 spans = {} 1367 1368 for _point, active in slots: 1369 for p in active: 1370 if p: 1371 key = p.get_key() 1372 start_slot = bisect_left(points, p.get_start()) 1373 end_slot = bisect_left(points, p.get_end()) 1374 spans[key] = end_slot - start_slot 1375 1376 return spans 1377 1378 # vim: tabstop=4 expandtab shiftwidth=4