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