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