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