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_event_periods(self, uid, recurrenceid=None, participant=None): 648 649 """ 650 Remove from the collection all periods associated with 'uid' and 651 'recurrenceid' (which if omitted causes the "parent" object's periods to 652 be referenced). 653 654 If 'participant' is specified, only remove periods for which the 655 participant is given as attending. 656 657 Return the removed periods. 658 """ 659 660 self._check_mutable() 661 662 removed = [] 663 i = 0 664 while i < len(self.periods): 665 fb = self.periods[i] 666 667 if fb.uid == uid and fb.recurrenceid == recurrenceid and \ 668 (not participant or participant == fb.attendee): 669 670 removed.append(self.periods[i]) 671 del self.periods[i] 672 else: 673 i += 1 674 675 return removed 676 677 # Specific period removal when updating event details. 678 679 remove_specific_event_periods = remove_event_periods 680 681 def remove_additional_periods(self, uid, recurrenceids=None): 682 683 """ 684 Remove from the collection all periods associated with 'uid' having a 685 recurrence identifier indicating an additional or modified period. 686 687 If 'recurrenceids' is specified, remove all periods associated with 688 'uid' that do not have a recurrence identifier in the given list. 689 690 Return the removed periods. 691 """ 692 693 self._check_mutable() 694 695 removed = [] 696 i = 0 697 while i < len(self.periods): 698 fb = self.periods[i] 699 if fb.uid == uid and fb.recurrenceid and ( 700 recurrenceids is None or 701 recurrenceids is not None and fb.recurrenceid not in recurrenceids 702 ): 703 removed.append(self.periods[i]) 704 del self.periods[i] 705 else: 706 i += 1 707 708 return removed 709 710 def remove_affected_period(self, uid, start, participant=None): 711 712 """ 713 Remove from the collection the period associated with 'uid' that 714 provides an occurrence starting at the given 'start' (provided by a 715 recurrence identifier, converted to a datetime). A recurrence identifier 716 is used to provide an alternative time period whilst also acting as a 717 reference to the originally-defined occurrence. 718 719 If 'participant' is specified, only remove periods for which the 720 participant is given as attending. 721 722 Return any removed period in a list. 723 """ 724 725 self._check_mutable() 726 727 removed = [] 728 729 search = Period(start, start) 730 found = bisect_left(self.periods, search) 731 732 while found < len(self.periods): 733 fb = self.periods[found] 734 735 # Stop looking if the start no longer matches the recurrence identifier. 736 737 if fb.get_start_point() != search.get_start_point(): 738 break 739 740 # If the period belongs to the parent object, remove it and return. 741 742 if not fb.recurrenceid and uid == fb.uid and \ 743 (not participant or participant == fb.attendee): 744 745 removed.append(self.periods[found]) 746 del self.periods[found] 747 break 748 749 # Otherwise, keep looking for a matching period. 750 751 found += 1 752 753 return removed 754 755 def periods_from(self, period): 756 757 "Return the entries in the collection at or after 'period'." 758 759 first = bisect_left(self.periods, period) 760 return self.periods[first:] 761 762 def periods_until(self, period): 763 764 "Return the entries in the collection before 'period'." 765 766 last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid())) 767 return self.periods[:last] 768 769 def get_overlapping(self, periods): 770 771 """ 772 Return the entries in the collection providing periods overlapping with 773 the given sorted collection of 'periods'. 774 """ 775 776 return get_overlapping(self.periods, periods) 777 778 def remove_overlapping(self, period): 779 780 "Remove all periods overlapping with 'period' from the collection." 781 782 self._check_mutable() 783 784 overlapping = self.get_overlapping([period]) 785 786 if overlapping: 787 for fb in overlapping: 788 self.periods.remove(fb) 789 790 class FreeBusyGroupCollection(SupportAttendee, FreeBusyCollection): 791 792 "A collection of quota group free/busy objects." 793 794 def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None): 795 796 """ 797 Remove from the collection all periods associated with 'uid' and 798 'recurrenceid' (which if omitted causes the "parent" object's periods to 799 be referenced) and any 'attendee'. 800 801 Return the removed periods. 802 """ 803 804 self._check_mutable() 805 806 removed = [] 807 i = 0 808 while i < len(self.periods): 809 fb = self.periods[i] 810 if fb.uid == uid and fb.recurrenceid == recurrenceid and fb.attendee == attendee: 811 removed.append(self.periods[i]) 812 del self.periods[i] 813 else: 814 i += 1 815 816 return removed 817 818 class FreeBusyOffersCollection(SupportExpires, FreeBusyCollection): 819 820 "A collection of offered free/busy objects." 821 822 pass 823 824 # vim: tabstop=4 expandtab shiftwidth=4