1 #!/usr/bin/env python 2 3 """ 4 Managing free/busy periods. 5 6 Copyright (C) 2014, 2015, 2016, 2017 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 23 from imiptools.dates import format_datetime 24 from imiptools.period import get_overlapping, Period, PeriodBase 25 from imiptools.sql import DatabaseOperations 26 27 try: 28 from cStringIO import StringIO 29 except ImportError: 30 from StringIO import StringIO 31 32 def from_string(s, encoding): 33 34 "Interpret 's' using 'encoding', preserving None." 35 36 if s: 37 return unicode(s, encoding) 38 else: 39 return s 40 41 def to_string(s, encoding): 42 43 "Encode 's' using 'encoding', preserving None." 44 45 if s: 46 return s.encode(encoding) 47 else: 48 return s 49 50 def to_copy_string(s, encoding): 51 52 """ 53 Encode 's' using 'encoding' as a string suitable for use in tabular data 54 acceptable to the PostgreSQL COPY command with \N as null. 55 """ 56 57 s = to_string(s, encoding) 58 return s is None and "\\N" or s 59 60 def to_copy_file(records): 61 62 """ 63 Encode the given 'records' and store them in a file-like object for use with 64 a tabular import mechanism. Return the file-like object. 65 """ 66 67 io = StringIO() 68 for values in records: 69 l = [] 70 for v in values: 71 l.append(to_copy_string(v, "utf-8")) 72 io.write("\t".join(l)) 73 io.write("\n") 74 io.seek(0) 75 return io 76 77 def quote_column(column): 78 79 "Quote 'column' using the SQL keyword quoting notation." 80 81 return '"%s"' % column 82 83 class FreeBusyPeriod(PeriodBase): 84 85 "A free/busy record abstraction." 86 87 def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, 88 summary=None, organiser=None): 89 90 """ 91 Initialise a free/busy period with the given 'start' and 'end' points, 92 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 93 details. 94 """ 95 96 PeriodBase.__init__(self, start, end) 97 self.uid = uid 98 self.transp = transp or None 99 self.recurrenceid = recurrenceid or None 100 self.summary = summary or None 101 self.organiser = organiser or None 102 103 def as_tuple(self, strings_only=False, string_datetimes=False): 104 105 """ 106 Return the initialisation parameter tuple, converting datetimes and 107 false value parameters to strings if 'strings_only' is set to a true 108 value. Otherwise, if 'string_datetimes' is set to a true value, only the 109 datetime values are converted to strings. 110 """ 111 112 null = lambda x: (strings_only and [""] or [x])[0] 113 return ( 114 (strings_only or string_datetimes) and format_datetime(self.get_start_point()) or self.start, 115 (strings_only or string_datetimes) and format_datetime(self.get_end_point()) or self.end, 116 self.uid or null(self.uid), 117 self.transp or strings_only and "OPAQUE" or None, 118 self.recurrenceid or null(self.recurrenceid), 119 self.summary or null(self.summary), 120 self.organiser or null(self.organiser) 121 ) 122 123 def __cmp__(self, other): 124 125 """ 126 Compare this object to 'other', employing the uid if the periods 127 involved are the same. 128 """ 129 130 result = PeriodBase.__cmp__(self, other) 131 if result == 0 and isinstance(other, FreeBusyPeriod): 132 return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid)) 133 else: 134 return result 135 136 def get_key(self): 137 return self.uid, self.recurrenceid, self.get_start() 138 139 def __repr__(self): 140 return "FreeBusyPeriod%r" % (self.as_tuple(),) 141 142 def get_tzid(self): 143 return "UTC" 144 145 # Period and event recurrence logic. 146 147 def is_replaced(self, recurrences): 148 149 """ 150 Return whether this period refers to one of the 'recurrences'. 151 The 'recurrences' must be UTC datetimes corresponding to the start of 152 the period described by a recurrence. 153 """ 154 155 for recurrence in recurrences: 156 if self.is_affected(recurrence): 157 return True 158 return False 159 160 def is_affected(self, recurrence): 161 162 """ 163 Return whether this period refers to 'recurrence'. The 'recurrence' must 164 be a UTC datetime corresponding to the start of the period described by 165 a recurrence. 166 """ 167 168 return recurrence and self.get_start_point() == recurrence 169 170 # Value correction methods. 171 172 def make_corrected(self, start, end): 173 return self.__class__(start, end) 174 175 class FreeBusyOfferPeriod(FreeBusyPeriod): 176 177 "A free/busy record abstraction for an offer period." 178 179 def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, 180 summary=None, organiser=None, expires=None): 181 182 """ 183 Initialise a free/busy period with the given 'start' and 'end' points, 184 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 185 details. 186 187 An additional 'expires' parameter can be used to indicate an expiry 188 datetime in conjunction with free/busy offers made when countering 189 event proposals. 190 """ 191 192 FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, 193 summary, organiser) 194 self.expires = expires or None 195 196 def as_tuple(self, strings_only=False, string_datetimes=False): 197 198 """ 199 Return the initialisation parameter tuple, converting datetimes and 200 false value parameters to strings if 'strings_only' is set to a true 201 value. Otherwise, if 'string_datetimes' is set to a true value, only the 202 datetime values are converted to strings. 203 """ 204 205 null = lambda x: (strings_only and [""] or [x])[0] 206 return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( 207 self.expires or null(self.expires),) 208 209 def __repr__(self): 210 return "FreeBusyOfferPeriod%r" % (self.as_tuple(),) 211 212 class FreeBusyGroupPeriod(FreeBusyPeriod): 213 214 "A free/busy record abstraction for a quota group period." 215 216 def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, 217 summary=None, organiser=None, attendee=None): 218 219 """ 220 Initialise a free/busy period with the given 'start' and 'end' points, 221 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 222 details. 223 224 An additional 'attendee' parameter can be used to indicate the identity 225 of the attendee recording the period. 226 """ 227 228 FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, 229 summary, organiser) 230 self.attendee = attendee or None 231 232 def as_tuple(self, strings_only=False, string_datetimes=False): 233 234 """ 235 Return the initialisation parameter tuple, converting datetimes and 236 false value parameters to strings if 'strings_only' is set to a true 237 value. Otherwise, if 'string_datetimes' is set to a true value, only the 238 datetime values are converted to strings. 239 """ 240 241 null = lambda x: (strings_only and [""] or [x])[0] 242 return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( 243 self.attendee or null(self.attendee),) 244 245 def __cmp__(self, other): 246 247 """ 248 Compare this object to 'other', employing the uid if the periods 249 involved are the same. 250 """ 251 252 result = FreeBusyPeriod.__cmp__(self, other) 253 if isinstance(other, FreeBusyGroupPeriod) and result == 0: 254 return cmp(self.attendee, other.attendee) 255 else: 256 return result 257 258 def __repr__(self): 259 return "FreeBusyGroupPeriod%r" % (self.as_tuple(),) 260 261 class FreeBusyCollectionBase: 262 263 "Common operations on free/busy period collections." 264 265 period_columns = [ 266 "start", "end", "object_uid", "transp", "object_recurrenceid", 267 "summary", "organiser" 268 ] 269 270 period_class = FreeBusyPeriod 271 272 def __init__(self, mutable=True): 273 self.mutable = mutable 274 275 def _check_mutable(self): 276 if not self.mutable: 277 raise TypeError, "Cannot mutate this collection." 278 279 def copy(self): 280 281 "Make an independent mutable copy of the collection." 282 283 return FreeBusyCollection(list(self), True) 284 285 def make_period(self, t): 286 287 """ 288 Make a period using the given tuple of arguments and the collection's 289 column details. 290 """ 291 292 args = [] 293 for arg, column in zip(t, self.period_columns): 294 args.append(from_string(arg, "utf-8")) 295 return self.period_class(*args) 296 297 def make_tuple(self, t): 298 299 """ 300 Return a tuple from the given tuple 't' conforming to the collection's 301 column details. 302 """ 303 304 args = [] 305 for arg, column in zip(t, self.period_columns): 306 args.append(arg) 307 return tuple(args) 308 309 # List emulation methods. 310 311 def __iadd__(self, periods): 312 self.insert_periods(periods) 313 return self 314 315 def append(self, period): 316 self.insert_period(period) 317 318 # Operations. 319 320 def insert_periods(self, periods): 321 322 "Insert the given 'periods' into the collection." 323 324 for p in periods: 325 self.insert_period(p) 326 327 def can_schedule(self, periods, uid, recurrenceid): 328 329 """ 330 Return whether the collection can accommodate the given 'periods' 331 employing the specified 'uid' and 'recurrenceid'. 332 """ 333 334 for conflict in self.have_conflict(periods, True): 335 if conflict.uid != uid or conflict.recurrenceid != recurrenceid: 336 return False 337 338 return True 339 340 def have_conflict(self, periods, get_conflicts=False): 341 342 """ 343 Return whether any period in the collection overlaps with the given 344 'periods', returning a collection of such overlapping periods if 345 'get_conflicts' is set to a true value. 346 """ 347 348 conflicts = set() 349 for p in periods: 350 overlapping = self.period_overlaps(p, get_conflicts) 351 if overlapping: 352 if get_conflicts: 353 conflicts.update(overlapping) 354 else: 355 return True 356 357 if get_conflicts: 358 return conflicts 359 else: 360 return False 361 362 def period_overlaps(self, period, get_periods=False): 363 364 """ 365 Return whether any period in the collection overlaps with the given 366 'period', returning a collection of overlapping periods if 'get_periods' 367 is set to a true value. 368 """ 369 370 overlapping = self.get_overlapping([period]) 371 372 if get_periods: 373 return overlapping 374 else: 375 return len(overlapping) != 0 376 377 def replace_overlapping(self, period, replacements): 378 379 """ 380 Replace existing periods in the collection within the given 'period', 381 using the given 'replacements'. 382 """ 383 384 self._check_mutable() 385 386 self.remove_overlapping(period) 387 for replacement in replacements: 388 self.insert_period(replacement) 389 390 def coalesce_freebusy(self): 391 392 "Coalesce the periods in the collection, returning a new collection." 393 394 if not self: 395 return FreeBusyCollection() 396 397 fb = [] 398 399 it = iter(self) 400 period = it.next() 401 402 start = period.get_start_point() 403 end = period.get_end_point() 404 405 try: 406 while True: 407 period = it.next() 408 if period.get_start_point() > end: 409 fb.append(self.period_class(start, end)) 410 start = period.get_start_point() 411 end = period.get_end_point() 412 else: 413 end = max(end, period.get_end_point()) 414 except StopIteration: 415 pass 416 417 fb.append(self.period_class(start, end)) 418 return FreeBusyCollection(fb) 419 420 def invert_freebusy(self): 421 422 "Return the free periods from the collection as a new collection." 423 424 if not self: 425 return FreeBusyCollection([self.period_class(None, None)]) 426 427 # Coalesce periods that overlap or are adjacent. 428 429 fb = self.coalesce_freebusy() 430 free = [] 431 432 # Add a start-of-time period if appropriate. 433 434 first = fb[0].get_start_point() 435 if first: 436 free.append(self.period_class(None, first)) 437 438 start = fb[0].get_end_point() 439 440 for period in fb[1:]: 441 free.append(self.period_class(start, period.get_start_point())) 442 start = period.get_end_point() 443 444 # Add an end-of-time period if appropriate. 445 446 if start: 447 free.append(self.period_class(start, None)) 448 449 return FreeBusyCollection(free) 450 451 def _update_freebusy(self, periods, uid, recurrenceid): 452 453 """ 454 Update the free/busy details with the given 'periods', using the given 455 'uid' plus 'recurrenceid' to remove existing periods. 456 """ 457 458 self._check_mutable() 459 460 self.remove_specific_event_periods(uid, recurrenceid) 461 462 self.insert_periods(periods) 463 464 def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser): 465 466 """ 467 Update the free/busy details with the given 'periods', 'transp' setting, 468 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 469 """ 470 471 new_periods = [] 472 473 for p in periods: 474 new_periods.append( 475 self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser) 476 ) 477 478 self._update_freebusy(new_periods, uid, recurrenceid) 479 480 class SupportAttendee: 481 482 "A mix-in that supports the affected attendee in free/busy periods." 483 484 period_columns = FreeBusyCollectionBase.period_columns + ["attendee"] 485 period_class = FreeBusyGroupPeriod 486 487 def _update_freebusy(self, periods, uid, recurrenceid, attendee=None): 488 489 """ 490 Update the free/busy details with the given 'periods', using the given 491 'uid' plus 'recurrenceid' and 'attendee' to remove existing periods. 492 """ 493 494 self._check_mutable() 495 496 self.remove_specific_event_periods(uid, recurrenceid, attendee) 497 498 self.insert_periods(periods) 499 500 def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, attendee=None): 501 502 """ 503 Update the free/busy details with the given 'periods', 'transp' setting, 504 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 505 506 An optional 'attendee' indicates the attendee affected by the period. 507 """ 508 509 new_periods = [] 510 511 for p in periods: 512 new_periods.append( 513 self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, attendee) 514 ) 515 516 self._update_freebusy(new_periods, uid, recurrenceid, attendee) 517 518 class SupportExpires: 519 520 "A mix-in that supports the expiry datetime in free/busy periods." 521 522 period_columns = FreeBusyCollectionBase.period_columns + ["expires"] 523 period_class = FreeBusyOfferPeriod 524 525 def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 526 527 """ 528 Update the free/busy details with the given 'periods', 'transp' setting, 529 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 530 531 An optional 'expires' datetime string indicates the expiry time of any 532 free/busy offer. 533 """ 534 535 new_periods = [] 536 537 for p in periods: 538 new_periods.append( 539 self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires) 540 ) 541 542 self._update_freebusy(new_periods, uid, recurrenceid) 543 544 class FreeBusyCollection(FreeBusyCollectionBase): 545 546 "An abstraction for a collection of free/busy periods." 547 548 def __init__(self, periods=None, mutable=True): 549 550 """ 551 Initialise the collection with the given list of 'periods', or start an 552 empty collection if no list is given. If 'mutable' is indicated, the 553 collection may be changed; otherwise, an exception will be raised. 554 """ 555 556 FreeBusyCollectionBase.__init__(self, mutable) 557 self.periods = periods or [] 558 559 # List emulation methods. 560 561 def __nonzero__(self): 562 return bool(self.periods) 563 564 def __iter__(self): 565 return iter(self.periods) 566 567 def __len__(self): 568 return len(self.periods) 569 570 def __getitem__(self, i): 571 return self.periods[i] 572 573 # Operations. 574 575 def insert_period(self, period): 576 577 "Insert the given 'period' into the collection." 578 579 self._check_mutable() 580 581 i = bisect_left(self.periods, period) 582 if i == len(self.periods): 583 self.periods.append(period) 584 elif self.periods[i] != period: 585 self.periods.insert(i, period) 586 587 def remove_periods(self, periods): 588 589 "Remove the given 'periods' from the collection." 590 591 self._check_mutable() 592 593 for period in periods: 594 i = bisect_left(self.periods, period) 595 if i < len(self.periods) and self.periods[i] == period: 596 del self.periods[i] 597 598 def remove_event_periods(self, uid, recurrenceid=None, participant=None): 599 600 """ 601 Remove from the collection all periods associated with 'uid' and 602 'recurrenceid' (which if omitted causes the "parent" object's periods to 603 be referenced). 604 605 If 'participant' is specified, only remove periods for which the 606 participant is given as attending. 607 608 Return the removed periods. 609 """ 610 611 self._check_mutable() 612 613 removed = [] 614 i = 0 615 while i < len(self.periods): 616 fb = self.periods[i] 617 618 if fb.uid == uid and fb.recurrenceid == recurrenceid and \ 619 (not participant or participant == fb.attendee): 620 621 removed.append(self.periods[i]) 622 del self.periods[i] 623 else: 624 i += 1 625 626 return removed 627 628 # Specific period removal when updating event details. 629 630 remove_specific_event_periods = remove_event_periods 631 632 def remove_additional_periods(self, uid, recurrenceids=None): 633 634 """ 635 Remove from the collection all periods associated with 'uid' having a 636 recurrence identifier indicating an additional or modified period. 637 638 If 'recurrenceids' is specified, remove all periods associated with 639 'uid' that do not have a recurrence identifier in the given list. 640 641 Return the removed periods. 642 """ 643 644 self._check_mutable() 645 646 removed = [] 647 i = 0 648 while i < len(self.periods): 649 fb = self.periods[i] 650 if fb.uid == uid and fb.recurrenceid and ( 651 recurrenceids is None or 652 recurrenceids is not None and fb.recurrenceid not in recurrenceids 653 ): 654 removed.append(self.periods[i]) 655 del self.periods[i] 656 else: 657 i += 1 658 659 return removed 660 661 def remove_affected_period(self, uid, start, participant=None): 662 663 """ 664 Remove from the collection the period associated with 'uid' that 665 provides an occurrence starting at the given 'start' (provided by a 666 recurrence identifier, converted to a datetime). A recurrence identifier 667 is used to provide an alternative time period whilst also acting as a 668 reference to the originally-defined occurrence. 669 670 If 'participant' is specified, only remove periods for which the 671 participant is given as attending. 672 673 Return any removed period in a list. 674 """ 675 676 self._check_mutable() 677 678 removed = [] 679 680 search = Period(start, start) 681 found = bisect_left(self.periods, search) 682 683 while found < len(self.periods): 684 fb = self.periods[found] 685 686 # Stop looking if the start no longer matches the recurrence identifier. 687 688 if fb.get_start_point() != search.get_start_point(): 689 break 690 691 # If the period belongs to the parent object, remove it and return. 692 693 if not fb.recurrenceid and uid == fb.uid and \ 694 (not participant or participant == fb.attendee): 695 696 removed.append(self.periods[found]) 697 del self.periods[found] 698 break 699 700 # Otherwise, keep looking for a matching period. 701 702 found += 1 703 704 return removed 705 706 def periods_from(self, period): 707 708 "Return the entries in the collection at or after 'period'." 709 710 first = bisect_left(self.periods, period) 711 return self.periods[first:] 712 713 def periods_until(self, period): 714 715 "Return the entries in the collection before 'period'." 716 717 last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid())) 718 return self.periods[:last] 719 720 def get_overlapping(self, periods): 721 722 """ 723 Return the entries in the collection providing periods overlapping with 724 the given sorted collection of 'periods'. 725 """ 726 727 return get_overlapping(self.periods, periods) 728 729 def remove_overlapping(self, period): 730 731 "Remove all periods overlapping with 'period' from the collection." 732 733 self._check_mutable() 734 735 overlapping = self.get_overlapping([period]) 736 737 if overlapping: 738 for fb in overlapping: 739 self.periods.remove(fb) 740 741 class FreeBusyGroupCollection(SupportAttendee, FreeBusyCollection): 742 743 "A collection of quota group free/busy objects." 744 745 def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None): 746 747 """ 748 Remove from the collection all periods associated with 'uid' and 749 'recurrenceid' (which if omitted causes the "parent" object's periods to 750 be referenced) and any 'attendee'. 751 752 Return the removed periods. 753 """ 754 755 self._check_mutable() 756 757 removed = [] 758 i = 0 759 while i < len(self.periods): 760 fb = self.periods[i] 761 if fb.uid == uid and fb.recurrenceid == recurrenceid and fb.attendee == attendee: 762 removed.append(self.periods[i]) 763 del self.periods[i] 764 else: 765 i += 1 766 767 return removed 768 769 class FreeBusyOffersCollection(SupportExpires, FreeBusyCollection): 770 771 "A collection of offered free/busy objects." 772 773 pass 774 775 class FreeBusyDatabaseCollection(FreeBusyCollectionBase, DatabaseOperations): 776 777 """ 778 An abstraction for a collection of free/busy periods stored in a database 779 system. 780 """ 781 782 def __init__(self, cursor, table_name, column_names=None, filter_values=None, 783 mutable=True, paramstyle=None): 784 785 """ 786 Initialise the collection with the given 'cursor' and with the 787 'table_name', 'column_names' and 'filter_values' configuring the 788 selection of data. If 'mutable' is indicated, the collection may be 789 changed; otherwise, an exception will be raised. 790 """ 791 792 FreeBusyCollectionBase.__init__(self, mutable) 793 DatabaseOperations.__init__(self, column_names, filter_values, paramstyle) 794 self.cursor = cursor 795 self.table_name = table_name 796 797 # List emulation methods. 798 799 def __nonzero__(self): 800 return len(self) and True or False 801 802 def __iter__(self): 803 query, values = self.get_query( 804 "select %(columns)s from %(table)s :condition" % { 805 "columns" : self.columnlist(self.period_columns), 806 "table" : self.table_name 807 }) 808 self.cursor.execute(query, values) 809 return iter(map(lambda t: self.make_period(t), self.cursor.fetchall())) 810 811 def __len__(self): 812 query, values = self.get_query( 813 "select count(*) from %(table)s :condition" % { 814 "table" : self.table_name 815 }) 816 self.cursor.execute(query, values) 817 result = self.cursor.fetchone() 818 return result and int(result[0]) or 0 819 820 def __getitem__(self, i): 821 return list(iter(self))[i] 822 823 # Operations. 824 825 def insert_period(self, period): 826 827 "Insert the given 'period' into the collection." 828 829 self._check_mutable() 830 831 columns, values = self.period_columns, period.as_tuple(string_datetimes=True) 832 833 query, values = self.get_query( 834 "insert into %(table)s (:columns) values (:values)" % { 835 "table" : self.table_name 836 }, 837 columns, [to_string(v, "utf-8") for v in values]) 838 839 self.cursor.execute(query, values) 840 841 def insert_periods(self, periods): 842 843 "Insert the given 'periods' into the collection." 844 845 if not hasattr(self.cursor, "copy_from"): 846 return FreeBusyCollectionBase.insert_periods(self, periods) 847 848 self._check_mutable() 849 850 columns = self.merge_default_columns(self.period_columns) 851 852 all_values = [] 853 for period in periods: 854 all_values.append(self.merge_default_values(period.as_tuple(string_datetimes=True))) 855 856 f = to_copy_file(all_values) 857 858 # Copy from the file-like object to the table. 859 860 self.cursor.copy_from(f, self.table_name, columns=map(quote_column, columns)) 861 862 def remove_periods(self, periods): 863 864 "Remove the given 'periods' from the collection." 865 866 self._check_mutable() 867 868 for period in periods: 869 values = period.as_tuple(string_datetimes=True) 870 871 query, values = self.get_query( 872 "delete from %(table)s :condition" % { 873 "table" : self.table_name 874 }, 875 self.period_columns, [to_string(v, "utf-8") for v in values]) 876 877 self.cursor.execute(query, values) 878 879 def remove_event_periods(self, uid, recurrenceid=None, participant=None): 880 881 """ 882 Remove from the collection all periods associated with 'uid' and 883 'recurrenceid' (which if omitted causes the "parent" object's periods to 884 be referenced). 885 886 If 'participant' is specified, only remove periods for which the 887 participant is given as attending. 888 889 Return the removed periods. 890 """ 891 892 self._check_mutable() 893 894 columns, values = ["object_uid"], [uid] 895 896 if recurrenceid: 897 columns.append("object_recurrenceid") 898 values.append(recurrenceid) 899 else: 900 columns.append("object_recurrenceid is null") 901 902 if participant: 903 columns.append("attendee") 904 values.append(participant) 905 906 query, _values = self.get_query( 907 "select %(columns)s from %(table)s :condition" % { 908 "columns" : self.columnlist(self.period_columns), 909 "table" : self.table_name 910 }, 911 columns, values) 912 913 self.cursor.execute(query, _values) 914 removed = self.cursor.fetchall() 915 916 query, values = self.get_query( 917 "delete from %(table)s :condition" % { 918 "table" : self.table_name 919 }, 920 columns, values) 921 922 self.cursor.execute(query, values) 923 924 return map(lambda t: self.make_period(t), removed) 925 926 # Specific period removal when updating event details. 927 928 remove_specific_event_periods = remove_event_periods 929 930 def remove_additional_periods(self, uid, recurrenceids=None): 931 932 """ 933 Remove from the collection all periods associated with 'uid' having a 934 recurrence identifier indicating an additional or modified period. 935 936 If 'recurrenceids' is specified, remove all periods associated with 937 'uid' that do not have a recurrence identifier in the given list. 938 939 Return the removed periods. 940 """ 941 942 self._check_mutable() 943 944 if not recurrenceids: 945 columns, values = ["object_uid", "object_recurrenceid is not null"], [uid] 946 else: 947 columns, values = ["object_uid", "object_recurrenceid not in ?", "object_recurrenceid is not null"], [uid, tuple(recurrenceids)] 948 949 query, _values = self.get_query( 950 "select %(columns)s from %(table)s :condition" % { 951 "columns" : self.columnlist(self.period_columns), 952 "table" : self.table_name 953 }, 954 columns, values) 955 956 self.cursor.execute(query, _values) 957 removed = self.cursor.fetchall() 958 959 query, values = self.get_query( 960 "delete from %(table)s :condition" % { 961 "table" : self.table_name 962 }, 963 columns, values) 964 965 self.cursor.execute(query, values) 966 967 return map(lambda t: self.make_period(t), removed) 968 969 def remove_affected_period(self, uid, start, participant=None): 970 971 """ 972 Remove from the collection the period associated with 'uid' that 973 provides an occurrence starting at the given 'start' (provided by a 974 recurrence identifier, converted to a datetime). A recurrence identifier 975 is used to provide an alternative time period whilst also acting as a 976 reference to the originally-defined occurrence. 977 978 If 'participant' is specified, only remove periods for which the 979 participant is given as attending. 980 981 Return any removed period in a list. 982 """ 983 984 self._check_mutable() 985 986 start = format_datetime(start) 987 988 columns, values = ["object_uid", "start", "object_recurrenceid is null"], [uid, start] 989 990 if participant: 991 columns.append("attendee") 992 values.append(participant) 993 994 query, _values = self.get_query( 995 "select %(columns)s from %(table)s :condition" % { 996 "columns" : self.columnlist(self.period_columns), 997 "table" : self.table_name 998 }, 999 columns, values) 1000 1001 self.cursor.execute(query, _values) 1002 removed = self.cursor.fetchall() 1003 1004 query, values = self.get_query( 1005 "delete from %(table)s :condition" % { 1006 "table" : self.table_name 1007 }, 1008 columns, values) 1009 1010 self.cursor.execute(query, values) 1011 1012 return map(lambda t: self.make_period(t), removed) 1013 1014 def periods_from(self, period): 1015 1016 "Return the entries in the collection at or after 'period'." 1017 1018 start = format_datetime(period.get_start_point()) 1019 1020 columns, values = [], [] 1021 1022 if start: 1023 columns.append("start >= ?") 1024 values.append(start) 1025 1026 query, values = self.get_query( 1027 "select %(columns)s from %(table)s :condition" % { 1028 "columns" : self.columnlist(self.period_columns), 1029 "table" : self.table_name 1030 }, 1031 columns, values) 1032 1033 self.cursor.execute(query, values) 1034 1035 return map(lambda t: self.make_period(t), self.cursor.fetchall()) 1036 1037 def periods_until(self, period): 1038 1039 "Return the entries in the collection before 'period'." 1040 1041 end = format_datetime(period.get_end_point()) 1042 1043 columns, values = [], [] 1044 1045 if end: 1046 columns.append("start < ?") 1047 values.append(end) 1048 1049 query, values = self.get_query( 1050 "select %(columns)s from %(table)s :condition" % { 1051 "columns" : self.columnlist(self.period_columns), 1052 "table" : self.table_name 1053 }, 1054 columns, values) 1055 1056 self.cursor.execute(query, values) 1057 1058 return map(lambda t: self.make_period(t), self.cursor.fetchall()) 1059 1060 def get_overlapping(self, periods): 1061 1062 """ 1063 Return the entries in the collection providing periods overlapping with 1064 the given sorted collection of 'periods'. 1065 """ 1066 1067 overlapping = set() 1068 1069 for period in periods: 1070 columns, values = self._get_period_values(period) 1071 1072 query, values = self.get_query( 1073 "select %(columns)s from %(table)s :condition" % { 1074 "columns" : self.columnlist(self.period_columns), 1075 "table" : self.table_name 1076 }, 1077 columns, values) 1078 1079 self.cursor.execute(query, values) 1080 1081 overlapping.update(map(lambda t: self.make_period(t), self.cursor.fetchall())) 1082 1083 overlapping = list(overlapping) 1084 overlapping.sort() 1085 return overlapping 1086 1087 def remove_overlapping(self, period): 1088 1089 "Remove all periods overlapping with 'period' from the collection." 1090 1091 self._check_mutable() 1092 1093 columns, values = self._get_period_values(period) 1094 1095 query, values = self.get_query( 1096 "delete from %(table)s :condition" % { 1097 "table" : self.table_name 1098 }, 1099 columns, values) 1100 1101 self.cursor.execute(query, values) 1102 1103 def _get_period_values(self, period): 1104 1105 start = format_datetime(period.get_start_point()) 1106 end = format_datetime(period.get_end_point()) 1107 1108 columns, values = [], [] 1109 1110 if end: 1111 columns.append("start < ?") 1112 values.append(end) 1113 if start: 1114 columns.append("end > ?") 1115 values.append(start) 1116 1117 return columns, values 1118 1119 class FreeBusyGroupDatabaseCollection(SupportAttendee, FreeBusyDatabaseCollection): 1120 1121 "A collection of quota group free/busy objects." 1122 1123 def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None): 1124 1125 """ 1126 Remove from the collection all periods associated with 'uid' and 1127 'recurrenceid' (which if omitted causes the "parent" object's periods to 1128 be referenced) and any 'attendee'. 1129 1130 Return the removed periods. 1131 """ 1132 1133 self._check_mutable() 1134 1135 columns, values = ["object_uid"], [uid] 1136 1137 if recurrenceid: 1138 columns.append("object_recurrenceid") 1139 values.append(recurrenceid) 1140 else: 1141 columns.append("object_recurrenceid is null") 1142 1143 if attendee: 1144 columns.append("attendee") 1145 values.append(attendee) 1146 else: 1147 columns.append("attendee is null") 1148 1149 query, _values = self.get_query( 1150 "select %(columns)s from %(table)s :condition" % { 1151 "columns" : self.columnlist(self.period_columns), 1152 "table" : self.table_name 1153 }, 1154 columns, values) 1155 1156 self.cursor.execute(query, _values) 1157 removed = self.cursor.fetchall() 1158 1159 query, values = self.get_query( 1160 "delete from %(table)s :condition" % { 1161 "table" : self.table_name 1162 }, 1163 columns, values) 1164 1165 self.cursor.execute(query, values) 1166 1167 return map(lambda t: self.make_period(t), removed) 1168 1169 class FreeBusyOffersDatabaseCollection(SupportExpires, FreeBusyDatabaseCollection): 1170 1171 "A collection of offered free/busy objects." 1172 1173 pass 1174 1175 # vim: tabstop=4 expandtab shiftwidth=4