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