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 26 # Conversion functions. 27 28 def from_string(s, encoding): 29 30 "Interpret 's' using 'encoding', preserving None." 31 32 if s: 33 if isinstance(s, unicode): 34 return s 35 else: 36 return unicode(s, encoding) 37 else: 38 return s 39 40 def to_string(s, encoding): 41 42 "Encode 's' using 'encoding', preserving None." 43 44 if s: 45 return s.encode(encoding) 46 else: 47 return s 48 49 class period_from_tuple: 50 51 "Convert a tuple to an instance of the given 'period_class'." 52 53 def __init__(self, period_class): 54 self.period_class = period_class 55 def __call__(self, t): 56 return make_period(t, self.period_class) 57 58 def period_to_tuple(p): 59 60 "Convert period 'p' to a tuple for serialisation." 61 62 return p.as_tuple(strings_only=True) 63 64 def make_period(t, period_class): 65 66 "Convert tuple 't' to an instance of the given 'period_class'." 67 68 args = [] 69 for arg, column in zip(t, period_class.period_columns): 70 args.append(from_string(arg, "utf-8")) 71 return period_class(*args) 72 73 def make_tuple(t, period_class): 74 75 "Restrict tuple 't' to the columns appropriate for 'period_class'." 76 77 args = [] 78 for arg, column in zip(t, period_class.period_columns): 79 args.append(arg) 80 return tuple(args) 81 82 83 84 # Period abstractions. 85 86 class FreeBusyPeriod(PeriodBase): 87 88 "A free/busy record abstraction." 89 90 period_columns = [ 91 "start", "end", "object_uid", "transp", "object_recurrenceid", 92 "summary", "organiser" 93 ] 94 95 def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, 96 summary=None, organiser=None): 97 98 """ 99 Initialise a free/busy period with the given 'start' and 'end' points, 100 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 101 details. 102 """ 103 104 PeriodBase.__init__(self, start, end) 105 self.uid = uid or None 106 self.transp = transp or None 107 self.recurrenceid = recurrenceid or None 108 self.summary = summary or None 109 self.organiser = organiser or None 110 111 def as_tuple(self, strings_only=False, string_datetimes=False): 112 113 """ 114 Return the initialisation parameter tuple, converting datetimes and 115 false value parameters to strings if 'strings_only' is set to a true 116 value. Otherwise, if 'string_datetimes' is set to a true value, only the 117 datetime values are converted to strings. 118 """ 119 120 null = lambda x: (strings_only and [""] or [x])[0] 121 return ( 122 (strings_only or string_datetimes) and format_datetime(self.get_start_point()) or self.start, 123 (strings_only or string_datetimes) and format_datetime(self.get_end_point()) or self.end, 124 self.uid or null(self.uid), 125 self.transp or strings_only and "OPAQUE" or None, 126 self.recurrenceid or null(self.recurrenceid), 127 self.summary or null(self.summary), 128 self.organiser or null(self.organiser) 129 ) 130 131 def __cmp__(self, other): 132 133 """ 134 Compare this object to 'other', employing the uid if the periods 135 involved are the same. 136 """ 137 138 result = PeriodBase.__cmp__(self, other) 139 if result == 0 and isinstance(other, FreeBusyPeriod): 140 return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid)) 141 else: 142 return result 143 144 def get_key(self): 145 return self.uid, self.recurrenceid, self.get_start() 146 147 def __repr__(self): 148 return "FreeBusyPeriod%r" % (self.as_tuple(),) 149 150 def get_tzid(self): 151 return "UTC" 152 153 # Period and event recurrence logic. 154 155 def is_replaced(self, recurrences): 156 157 """ 158 Return whether this period refers to one of the 'recurrences'. 159 The 'recurrences' must be UTC datetimes corresponding to the start of 160 the period described by a recurrence. 161 """ 162 163 for recurrence in recurrences: 164 if self.is_affected(recurrence): 165 return True 166 return False 167 168 def is_affected(self, recurrence): 169 170 """ 171 Return whether this period refers to 'recurrence'. The 'recurrence' must 172 be a UTC datetime corresponding to the start of the period described by 173 a recurrence. 174 """ 175 176 return recurrence and self.get_start_point() == recurrence 177 178 # Value correction methods. 179 180 def make_corrected(self, start, end): 181 return self.__class__(start, end) 182 183 class FreeBusyOfferPeriod(FreeBusyPeriod): 184 185 "A free/busy record abstraction for an offer period." 186 187 period_columns = FreeBusyPeriod.period_columns + ["expires"] 188 189 def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, 190 summary=None, organiser=None, expires=None): 191 192 """ 193 Initialise a free/busy period with the given 'start' and 'end' points, 194 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 195 details. 196 197 An additional 'expires' parameter can be used to indicate an expiry 198 datetime in conjunction with free/busy offers made when countering 199 event proposals. 200 """ 201 202 FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, 203 summary, organiser) 204 self.expires = expires or None 205 206 def as_tuple(self, strings_only=False, string_datetimes=False): 207 208 """ 209 Return the initialisation parameter tuple, converting datetimes and 210 false value parameters to strings if 'strings_only' is set to a true 211 value. Otherwise, if 'string_datetimes' is set to a true value, only the 212 datetime values are converted to strings. 213 """ 214 215 null = lambda x: (strings_only and [""] or [x])[0] 216 return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( 217 self.expires or null(self.expires),) 218 219 def __repr__(self): 220 return "FreeBusyOfferPeriod%r" % (self.as_tuple(),) 221 222 class FreeBusyGroupPeriod(FreeBusyPeriod): 223 224 "A free/busy record abstraction for a quota group period." 225 226 period_columns = FreeBusyPeriod.period_columns + ["attendee"] 227 228 def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, 229 summary=None, organiser=None, attendee=None): 230 231 """ 232 Initialise a free/busy period with the given 'start' and 'end' points, 233 plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' 234 details. 235 236 An additional 'attendee' parameter can be used to indicate the identity 237 of the attendee recording the period. 238 """ 239 240 FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, 241 summary, organiser) 242 self.attendee = attendee or None 243 244 def as_tuple(self, strings_only=False, string_datetimes=False): 245 246 """ 247 Return the initialisation parameter tuple, converting datetimes and 248 false value parameters to strings if 'strings_only' is set to a true 249 value. Otherwise, if 'string_datetimes' is set to a true value, only the 250 datetime values are converted to strings. 251 """ 252 253 null = lambda x: (strings_only and [""] or [x])[0] 254 return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( 255 self.attendee or null(self.attendee),) 256 257 def __cmp__(self, other): 258 259 """ 260 Compare this object to 'other', employing the uid if the periods 261 involved are the same. 262 """ 263 264 result = FreeBusyPeriod.__cmp__(self, other) 265 if isinstance(other, FreeBusyGroupPeriod) and result == 0: 266 return cmp(self.attendee, other.attendee) 267 else: 268 return result 269 270 def __repr__(self): 271 return "FreeBusyGroupPeriod%r" % (self.as_tuple(),) 272 273 274 275 # Collection abstractions. 276 277 class FreeBusyCollectionBase: 278 279 "Common operations on free/busy period collections." 280 281 period_class = FreeBusyPeriod 282 283 def __init__(self, mutable=True): 284 self.mutable = mutable 285 286 def _check_mutable(self): 287 if not self.mutable: 288 raise TypeError, "Cannot mutate this collection." 289 290 def close(self): 291 292 "Close the collection." 293 294 pass 295 296 def copy(self): 297 298 "Make an independent mutable copy of the collection." 299 300 return FreeBusyCollection(list(self), True) 301 302 def make_period(self, t): 303 304 """ 305 Make a period using the given tuple of arguments and the collection's 306 column details. 307 """ 308 309 return make_period(t, self.period_class) 310 311 def make_tuple(self, t): 312 313 """ 314 Return a tuple from the given tuple 't' conforming to the collection's 315 column details. 316 """ 317 318 return make_tuple(t, self.period_class) 319 320 # List emulation methods. 321 322 def __iadd__(self, periods): 323 self.insert_periods(periods) 324 return self 325 326 def append(self, period): 327 self.insert_period(period) 328 329 # Operations. 330 331 def insert_period(self, period): 332 333 """ 334 Insert the given 'period' into the collection. 335 336 This should be implemented in subclasses. 337 """ 338 339 pass 340 341 def insert_periods(self, periods): 342 343 "Insert the given 'periods' into the collection." 344 345 for p in periods: 346 self.insert_period(p) 347 348 def can_schedule(self, periods, uid, recurrenceid): 349 350 """ 351 Return whether the collection can accommodate the given 'periods' 352 employing the specified 'uid' and 'recurrenceid'. 353 """ 354 355 for conflict in self.have_conflict(periods, True): 356 if conflict.uid != uid or conflict.recurrenceid != recurrenceid: 357 return False 358 359 return True 360 361 def have_conflict(self, periods, get_conflicts=False): 362 363 """ 364 Return whether any period in the collection overlaps with the given 365 'periods', returning a collection of such overlapping periods if 366 'get_conflicts' is set to a true value. 367 """ 368 369 conflicts = set() 370 for p in periods: 371 overlapping = self.period_overlaps(p, get_conflicts) 372 if overlapping: 373 if get_conflicts: 374 conflicts.update(overlapping) 375 else: 376 return True 377 378 if get_conflicts: 379 return conflicts 380 else: 381 return False 382 383 def period_overlaps(self, period, get_periods=False): 384 385 """ 386 Return whether any period in the collection overlaps with the given 387 'period', returning a collection of overlapping periods if 'get_periods' 388 is set to a true value. 389 """ 390 391 overlapping = self.get_overlapping([period]) 392 393 if get_periods: 394 return overlapping 395 else: 396 return len(overlapping) != 0 397 398 def replace_overlapping(self, period, replacements): 399 400 """ 401 Replace existing periods in the collection within the given 'period', 402 using the given 'replacements'. 403 """ 404 405 self._check_mutable() 406 407 self.remove_overlapping(period) 408 for replacement in replacements: 409 self.insert_period(replacement) 410 411 def coalesce_freebusy(self): 412 413 "Coalesce the periods in the collection, returning a new collection." 414 415 if not self: 416 return FreeBusyCollection() 417 418 fb = [] 419 420 it = iter(self) 421 period = it.next() 422 423 start = period.get_start_point() 424 end = period.get_end_point() 425 426 try: 427 while True: 428 period = it.next() 429 if period.get_start_point() > end: 430 fb.append(self.period_class(start, end)) 431 start = period.get_start_point() 432 end = period.get_end_point() 433 else: 434 end = max(end, period.get_end_point()) 435 except StopIteration: 436 pass 437 438 fb.append(self.period_class(start, end)) 439 return FreeBusyCollection(fb) 440 441 def invert_freebusy(self): 442 443 "Return the free periods from the collection as a new collection." 444 445 if not self: 446 return FreeBusyCollection([self.period_class(None, None)]) 447 448 # Coalesce periods that overlap or are adjacent. 449 450 fb = self.coalesce_freebusy() 451 free = [] 452 453 # Add a start-of-time period if appropriate. 454 455 first = fb[0].get_start_point() 456 if first: 457 free.append(self.period_class(None, first)) 458 459 start = fb[0].get_end_point() 460 461 for period in fb[1:]: 462 free.append(self.period_class(start, period.get_start_point())) 463 start = period.get_end_point() 464 465 # Add an end-of-time period if appropriate. 466 467 if start: 468 free.append(self.period_class(start, None)) 469 470 return FreeBusyCollection(free) 471 472 def _update_freebusy(self, periods, uid, recurrenceid): 473 474 """ 475 Update the free/busy details with the given 'periods', using the given 476 'uid' plus 'recurrenceid' to remove existing periods. 477 """ 478 479 self._check_mutable() 480 481 self.remove_specific_event_periods(uid, recurrenceid) 482 483 self.insert_periods(periods) 484 485 def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser): 486 487 """ 488 Update the free/busy details with the given 'periods', 'transp' setting, 489 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 490 """ 491 492 new_periods = [] 493 494 for p in periods: 495 new_periods.append( 496 self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser) 497 ) 498 499 self._update_freebusy(new_periods, uid, recurrenceid) 500 501 class SupportAttendee: 502 503 "A mix-in that supports the affected attendee in free/busy periods." 504 505 period_class = FreeBusyGroupPeriod 506 507 def _update_freebusy(self, periods, uid, recurrenceid, attendee=None): 508 509 """ 510 Update the free/busy details with the given 'periods', using the given 511 'uid' plus 'recurrenceid' and 'attendee' to remove existing periods. 512 """ 513 514 self._check_mutable() 515 516 self.remove_specific_event_periods(uid, recurrenceid, attendee) 517 518 self.insert_periods(periods) 519 520 def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, attendee=None): 521 522 """ 523 Update the free/busy details with the given 'periods', 'transp' setting, 524 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 525 526 An optional 'attendee' indicates the attendee affected by the period. 527 """ 528 529 new_periods = [] 530 531 for p in periods: 532 new_periods.append( 533 self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, attendee) 534 ) 535 536 self._update_freebusy(new_periods, uid, recurrenceid, attendee) 537 538 class SupportExpires: 539 540 "A mix-in that supports the expiry datetime in free/busy periods." 541 542 period_class = FreeBusyOfferPeriod 543 544 def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 545 546 """ 547 Update the free/busy details with the given 'periods', 'transp' setting, 548 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. 549 550 An optional 'expires' datetime string indicates the expiry time of any 551 free/busy offer. 552 """ 553 554 new_periods = [] 555 556 for p in periods: 557 new_periods.append( 558 self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires) 559 ) 560 561 self._update_freebusy(new_periods, uid, recurrenceid) 562 563 564 565 # Simple abstractions suitable for use with file-based representations and as 566 # general copies of collections. 567 568 class FreeBusyCollection(FreeBusyCollectionBase): 569 570 "An abstraction for a collection of free/busy periods." 571 572 def __init__(self, periods=None, mutable=True): 573 574 """ 575 Initialise the collection with the given list of 'periods', or start an 576 empty collection if no list is given. If 'mutable' is indicated, the 577 collection may be changed; otherwise, an exception will be raised. 578 """ 579 580 FreeBusyCollectionBase.__init__(self, mutable) 581 582 if periods is not None: 583 self.periods = periods 584 else: 585 self.periods = [] 586 587 def get_filename(self): 588 589 "Return any filename for the periods collection." 590 591 if hasattr(self.periods, "filename"): 592 return self.periods.filename 593 else: 594 return None 595 596 def close(self): 597 598 "Close the collection." 599 600 if hasattr(self.periods, "close"): 601 self.periods.close() 602 603 # List emulation methods. 604 605 def __nonzero__(self): 606 return bool(self.periods) 607 608 def __iter__(self): 609 return iter(self.periods) 610 611 def __len__(self): 612 return len(self.periods) 613 614 def __getitem__(self, i): 615 return self.periods[i] 616 617 # Dictionary emulation methods (even though this is not a mapping). 618 619 def clear(self): 620 del self.periods[:] 621 622 # Operations. 623 624 def insert_period(self, period): 625 626 "Insert the given 'period' into the collection." 627 628 self._check_mutable() 629 630 i = bisect_left(self.periods, period) 631 if i == len(self.periods): 632 self.periods.append(period) 633 elif self.periods[i] != period: 634 self.periods.insert(i, period) 635 636 def remove_periods(self, periods): 637 638 "Remove the given 'periods' from the collection." 639 640 self._check_mutable() 641 642 for period in periods: 643 i = bisect_left(self.periods, period) 644 if i < len(self.periods) and self.periods[i] == period: 645 del self.periods[i] 646 647 def remove_periods_before(self, period): 648 649 "Remove the entries in the collection before 'period'." 650 651 last = bisect_right(self.periods, period) 652 self.remove_periods(self.periods[:last]) 653 654 def remove_event_periods(self, uid, recurrenceid=None, participant=None): 655 656 """ 657 Remove from the collection all periods associated with 'uid' and 658 'recurrenceid' (which if omitted causes the "parent" object's periods to 659 be referenced). 660 661 If 'participant' is specified, only remove periods for which the 662 participant is given as attending. 663 664 Return the removed periods. 665 """ 666 667 self._check_mutable() 668 669 removed = [] 670 i = 0 671 while i < len(self.periods): 672 fb = self.periods[i] 673 674 if fb.uid == uid and fb.recurrenceid == recurrenceid and \ 675 (not participant or participant == fb.attendee): 676 677 removed.append(self.periods[i]) 678 del self.periods[i] 679 else: 680 i += 1 681 682 return removed 683 684 # Specific period removal when updating event details. 685 686 remove_specific_event_periods = remove_event_periods 687 688 def remove_additional_periods(self, uid, recurrenceids=None): 689 690 """ 691 Remove from the collection all periods associated with 'uid' having a 692 recurrence identifier indicating an additional or modified period. 693 694 If 'recurrenceids' is specified, remove all periods associated with 695 'uid' that do not have a recurrence identifier in the given list. 696 697 Return the removed periods. 698 """ 699 700 self._check_mutable() 701 702 removed = [] 703 i = 0 704 while i < len(self.periods): 705 fb = self.periods[i] 706 if fb.uid == uid and fb.recurrenceid and ( 707 recurrenceids is None or 708 recurrenceids is not None and fb.recurrenceid not in recurrenceids 709 ): 710 removed.append(self.periods[i]) 711 del self.periods[i] 712 else: 713 i += 1 714 715 return removed 716 717 def remove_affected_period(self, uid, start, participant=None): 718 719 """ 720 Remove from the collection the period associated with 'uid' that 721 provides an occurrence starting at the given 'start' (provided by a 722 recurrence identifier, converted to a datetime). A recurrence identifier 723 is used to provide an alternative time period whilst also acting as a 724 reference to the originally-defined occurrence. 725 726 If 'participant' is specified, only remove periods for which the 727 participant is given as attending. 728 729 Return any removed period in a list. 730 """ 731 732 self._check_mutable() 733 734 removed = [] 735 736 search = Period(start, start) 737 found = bisect_left(self.periods, search) 738 739 while found < len(self.periods): 740 fb = self.periods[found] 741 742 # Stop looking if the start no longer matches the recurrence identifier. 743 744 if fb.get_start_point() != search.get_start_point(): 745 break 746 747 # If the period belongs to the parent object, remove it and return. 748 749 if not fb.recurrenceid and uid == fb.uid and \ 750 (not participant or participant == fb.attendee): 751 752 removed.append(self.periods[found]) 753 del self.periods[found] 754 break 755 756 # Otherwise, keep looking for a matching period. 757 758 found += 1 759 760 return removed 761 762 def periods_from(self, period): 763 764 "Return the entries in the collection at or after 'period'." 765 766 first = bisect_left(self.periods, period) 767 return self.periods[first:] 768 769 def periods_until(self, period): 770 771 "Return the entries in the collection before 'period'." 772 773 last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid())) 774 return self.periods[:last] 775 776 def get_overlapping(self, periods): 777 778 """ 779 Return the entries in the collection providing periods overlapping with 780 the given sorted collection of 'periods'. 781 """ 782 783 return get_overlapping(self.periods, periods) 784 785 def remove_overlapping(self, period): 786 787 "Remove all periods overlapping with 'period' from the collection." 788 789 self._check_mutable() 790 791 overlapping = self.get_overlapping([period]) 792 793 if overlapping: 794 for fb in overlapping: 795 self.periods.remove(fb) 796 797 class FreeBusyGroupCollection(SupportAttendee, FreeBusyCollection): 798 799 "A collection of quota group free/busy objects." 800 801 def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None): 802 803 """ 804 Remove from the collection all periods associated with 'uid' and 805 'recurrenceid' (which if omitted causes the "parent" object's periods to 806 be referenced) and any 'attendee'. 807 808 Return the removed periods. 809 """ 810 811 self._check_mutable() 812 813 removed = [] 814 i = 0 815 while i < len(self.periods): 816 fb = self.periods[i] 817 if fb.uid == uid and fb.recurrenceid == recurrenceid and fb.attendee == attendee: 818 removed.append(self.periods[i]) 819 del self.periods[i] 820 else: 821 i += 1 822 823 return removed 824 825 class FreeBusyOffersCollection(SupportExpires, FreeBusyCollection): 826 827 "A collection of offered free/busy objects." 828 829 pass 830 831 # vim: tabstop=4 expandtab shiftwidth=4