1 #!/usr/bin/env python 2 3 """ 4 Interpretation of vCalendar content. 5 6 Copyright (C) 2014, 2015 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 23 from datetime import date, datetime, timedelta 24 from email.mime.text import MIMEText 25 from imiptools.dates import check_permitted_values, correct_datetime, \ 26 format_datetime, get_datetime, \ 27 get_datetime_item as get_item_from_datetime, \ 28 get_datetime_tzid, \ 29 get_duration, get_period, get_period_item, \ 30 get_recurrence_start_point, \ 31 get_time, get_tzid, to_datetime, to_timezone, \ 32 to_utc_datetime 33 from imiptools.period import FreeBusyPeriod, Period, RecurringPeriod, period_overlaps 34 from vCalendar import iterwrite, parse, ParseError, to_dict, to_node 35 from vRecurrence import get_parameters, get_rule 36 import email.utils 37 38 try: 39 from cStringIO import StringIO 40 except ImportError: 41 from StringIO import StringIO 42 43 class Object: 44 45 "Access to calendar structures." 46 47 def __init__(self, fragment): 48 self.objtype, (self.details, self.attr) = fragment.items()[0] 49 50 def get_uid(self): 51 return self.get_value("UID") 52 53 def get_recurrenceid(self): 54 55 """ 56 Return the recurrence identifier, normalised to a UTC datetime if 57 specified as a datetime or date with accompanying time zone information, 58 maintained as a date or floating datetime otherwise. If no recurrence 59 identifier is present, None is returned. 60 61 Note that this normalised form of the identifier may well not be the 62 same as the originally-specified identifier because that could have been 63 specified using an accompanying TZID attribute, whereas the normalised 64 form is effectively a converted datetime value. 65 """ 66 67 if not self.has_key("RECURRENCE-ID"): 68 return None 69 dt, attr = self.get_datetime_item("RECURRENCE-ID") 70 71 # Coerce any date to a UTC datetime if TZID was specified. 72 73 tzid = attr.get("TZID") 74 if tzid: 75 dt = to_timezone(to_datetime(dt, tzid), "UTC") 76 return format_datetime(dt) 77 78 def get_recurrence_start_point(self, recurrenceid, tzid): 79 80 """ 81 Return the start point corresponding to the given 'recurrenceid', using 82 the fallback 'tzid' to define the specific point in time referenced by 83 the recurrence identifier if the identifier has a date representation. 84 85 If 'recurrenceid' is given as None, this object's recurrence identifier 86 is used to obtain a start point, but if this object does not provide a 87 recurrence, None is returned. 88 89 A start point is typically used to match free/busy periods which are 90 themselves defined in terms of UTC datetimes. 91 """ 92 93 recurrenceid = recurrenceid or self.get_recurrenceid() 94 if recurrenceid: 95 return get_recurrence_start_point(recurrenceid, tzid) 96 else: 97 return None 98 99 def get_recurrence_start_points(self, recurrenceids, tzid): 100 return [self.get_recurrence_start_point(recurrenceid, tzid) for recurrenceid in recurrenceids] 101 102 # Structure access. 103 104 def copy(self): 105 return Object(to_dict(self.to_node())) 106 107 def get_items(self, name, all=True): 108 return get_items(self.details, name, all) 109 110 def get_item(self, name): 111 return get_item(self.details, name) 112 113 def get_value_map(self, name): 114 return get_value_map(self.details, name) 115 116 def get_values(self, name, all=True): 117 return get_values(self.details, name, all) 118 119 def get_value(self, name): 120 return get_value(self.details, name) 121 122 def get_utc_datetime(self, name, date_tzid=None): 123 return get_utc_datetime(self.details, name, date_tzid) 124 125 def get_date_values(self, name, tzid=None): 126 items = get_date_value_items(self.details, name, tzid) 127 return items and [value for value, attr in items] 128 129 def get_date_value_items(self, name, tzid=None): 130 return get_date_value_items(self.details, name, tzid) 131 132 def get_period_values(self, name, tzid=None): 133 return get_period_values(self.details, name, tzid) 134 135 def get_datetime(self, name): 136 t = get_datetime_item(self.details, name) 137 if not t: return None 138 dt, attr = t 139 return dt 140 141 def get_datetime_item(self, name): 142 return get_datetime_item(self.details, name) 143 144 def get_duration(self, name): 145 return get_duration(self.get_value(name)) 146 147 def to_node(self): 148 return to_node({self.objtype : [(self.details, self.attr)]}) 149 150 def to_part(self, method): 151 return to_part(method, [self.to_node()]) 152 153 # Direct access to the structure. 154 155 def has_key(self, name): 156 return self.details.has_key(name) 157 158 def get(self, name): 159 return self.details.get(name) 160 161 def keys(self): 162 return self.details.keys() 163 164 def __getitem__(self, name): 165 return self.details[name] 166 167 def __setitem__(self, name, value): 168 self.details[name] = value 169 170 def __delitem__(self, name): 171 del self.details[name] 172 173 def remove(self, name): 174 try: 175 del self[name] 176 except KeyError: 177 pass 178 179 def remove_all(self, names): 180 for name in names: 181 self.remove(name) 182 183 def preserve(self, names): 184 for name in self.keys(): 185 if not name in names: 186 self.remove(name) 187 188 # Computed results. 189 190 def get_main_period(self, tzid): 191 192 """ 193 Return a period object corresponding to the main start-end period for 194 the object. 195 """ 196 197 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_main_period_items(tzid) 198 return RecurringPeriod(dtstart, dtend, tzid, "DTSTART", dtstart_attr, dtend_attr) 199 200 def get_main_period_items(self, tzid): 201 202 """ 203 Return two (value, attributes) items corresponding to the main start-end 204 period for the object. 205 """ 206 207 dtstart, dtstart_attr = self.get_datetime_item("DTSTART") 208 209 if self.has_key("DTEND"): 210 dtend, dtend_attr = self.get_datetime_item("DTEND") 211 elif self.has_key("DURATION"): 212 duration = self.get_duration("DURATION") 213 dtend = dtstart + duration 214 dtend_attr = dtstart_attr 215 else: 216 dtend, dtend_attr = dtstart, dtstart_attr 217 218 return (dtstart, dtstart_attr), (dtend, dtend_attr) 219 220 def get_periods(self, tzid, end=None): 221 222 """ 223 Return periods defined by this object, employing the given 'tzid' where 224 no time zone information is defined, and limiting the collection to a 225 window of time with the given 'end'. 226 227 If 'end' is omitted, only explicit recurrences and recurrences from 228 explicitly-terminated rules will be returned. 229 """ 230 231 return get_periods(self, tzid, end) 232 233 def get_active_periods(self, recurrenceids, tzid, end=None): 234 235 """ 236 Return all periods specified by this object that are not replaced by 237 those defined by 'recurrenceids', using 'tzid' as a fallback time zone 238 to convert floating dates and datetimes, and using 'end' to indicate the 239 end of the time window within which periods are considered. 240 """ 241 242 # Specific recurrences yield all specified periods. 243 244 periods = self.get_periods(tzid, end) 245 246 if self.get_recurrenceid(): 247 return periods 248 249 # Parent objects need to have their periods tested against redefined 250 # recurrences. 251 252 active = [] 253 254 for p in periods: 255 256 # Subtract any recurrences from the free/busy details of a 257 # parent object. 258 259 if not p.is_replaced(recurrenceids): 260 active.append(p) 261 262 return active 263 264 def get_freebusy_period(self, period, only_organiser=False): 265 266 """ 267 Return a free/busy period for the given 'period' provided by this 268 object, using the 'only_organiser' status to produce a suitable 269 transparency value. 270 """ 271 272 return FreeBusyPeriod( 273 period.get_start_point(), 274 period.get_end_point(), 275 self.get_value("UID"), 276 only_organiser and "ORG" or self.get_value("TRANSP") or "OPAQUE", 277 self.get_recurrenceid(), 278 self.get_value("SUMMARY"), 279 self.get_value("ORGANIZER") 280 ) 281 282 def get_participation_status(self, participant): 283 284 """ 285 Return the participation status of the given 'participant', with the 286 special value "ORG" indicating organiser-only participation. 287 """ 288 289 attendees = self.get_value_map("ATTENDEE") 290 organiser = self.get_value("ORGANIZER") 291 292 attendee_attr = attendees.get(participant) 293 if attendee_attr: 294 return attendee_attr.get("PARTSTAT", "NEEDS-ACTION") 295 elif organiser == participant: 296 return "ORG" 297 298 return None 299 300 def get_participation(self, partstat, include_needs_action=False): 301 302 """ 303 Return whether 'partstat' indicates some kind of participation in an 304 event. If 'include_needs_action' is specified as a true value, events 305 not yet responded to will be treated as events with tentative 306 participation. 307 """ 308 309 return not partstat in ("DECLINED", "DELEGATED", "NEEDS-ACTION") or \ 310 include_needs_action and partstat == "NEEDS-ACTION" or \ 311 partstat == "ORG" 312 313 def get_tzid(self): 314 315 """ 316 Return a time zone identifier used by the start or end datetimes, 317 potentially suitable for converting dates to datetimes. 318 """ 319 320 if not self.has_key("DTSTART"): 321 return None 322 dtstart, dtstart_attr = self.get_datetime_item("DTSTART") 323 if self.has_key("DTEND"): 324 dtend, dtend_attr = self.get_datetime_item("DTEND") 325 else: 326 dtend_attr = None 327 return get_tzid(dtstart_attr, dtend_attr) 328 329 def is_shared(self): 330 331 """ 332 Return whether this object is shared based on the presence of a SEQUENCE 333 property. 334 """ 335 336 return self.get_value("SEQUENCE") is not None 337 338 def possibly_active_from(self, dt, tzid): 339 340 """ 341 Return whether the object is possibly active from or after the given 342 datetime 'dt' using 'tzid' to convert any dates or floating datetimes. 343 """ 344 345 dt = to_datetime(dt, tzid) 346 periods = self.get_periods(tzid) 347 348 for p in periods: 349 if p.get_end_point() > dt: 350 return True 351 352 return self.possibly_recurring_indefinitely() 353 354 def possibly_recurring_indefinitely(self): 355 356 "Return whether this object may recur indefinitely." 357 358 rrule = self.get_value("RRULE") 359 parameters = rrule and get_parameters(rrule) 360 until = parameters and parameters.get("UNTIL") 361 count = parameters and parameters.get("COUNT") 362 363 # Non-recurring periods or constrained recurrences. 364 365 if not rrule or until or count: 366 return False 367 368 # Unconstrained recurring periods will always lie beyond any specified 369 # datetime. 370 371 else: 372 return True 373 374 # Modification methods. 375 376 def set_datetime(self, name, dt, tzid=None): 377 378 """ 379 Set a datetime for property 'name' using 'dt' and the optional fallback 380 'tzid', returning whether an update has occurred. 381 """ 382 383 if dt: 384 old_value = self.get_value(name) 385 self[name] = [get_item_from_datetime(dt, tzid)] 386 return format_datetime(dt) != old_value 387 388 return False 389 390 def set_period(self, period): 391 392 "Set the given 'period' as the main start and end." 393 394 result = self.set_datetime("DTSTART", period.get_start()) 395 result = self.set_datetime("DTEND", period.get_end()) or result 396 if self.has_key("DURATION"): 397 del self["DURATION"] 398 399 return result 400 401 def set_periods(self, periods): 402 403 """ 404 Set the given 'periods' as recurrence date properties, replacing the 405 previous RDATE properties and ignoring any RRULE properties. 406 """ 407 408 old_values = set(self.get_date_values("RDATE") or []) 409 new_rdates = [] 410 411 if self.has_key("RDATE"): 412 del self["RDATE"] 413 414 for p in periods: 415 if p.origin != "RRULE": 416 new_rdates.append(get_period_item(p.get_start(), p.get_end())) 417 418 if new_rdates: 419 self["RDATE"] = new_rdates 420 421 return old_values != set(self.get_date_values("RDATE") or []) 422 423 def update_dtstamp(self): 424 425 "Update the DTSTAMP in the object." 426 427 dtstamp = self.get_utc_datetime("DTSTAMP") 428 utcnow = get_time() 429 dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow) 430 self["DTSTAMP"] = [(dtstamp, {})] 431 return dtstamp 432 433 def update_sequence(self, increment=False): 434 435 "Set or update the SEQUENCE in the object." 436 437 sequence = self.get_value("SEQUENCE") or "0" 438 self["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 439 return sequence 440 441 def update_exceptions(self, excluded): 442 443 """ 444 Update the exceptions to any rule by applying the list of 'excluded' 445 periods. 446 """ 447 448 to_exclude = set(excluded).difference(self.get_date_values("EXDATE") or []) 449 if not to_exclude: 450 return False 451 452 if not self.has_key("EXDATE"): 453 self["EXDATE"] = [] 454 455 for p in to_exclude: 456 self["EXDATE"].append(get_period_item(p.get_start(), p.get_end())) 457 458 return True 459 460 def correct_object(self, tzid, permitted_values): 461 462 "Correct the object's period details." 463 464 corrected = set() 465 rdates = [] 466 467 for period in self.get_periods(tzid): 468 start = period.get_start() 469 end = period.get_end() 470 start_errors = check_permitted_values(start, permitted_values) 471 end_errors = check_permitted_values(end, permitted_values) 472 473 if not (start_errors or end_errors): 474 if period.origin == "RDATE": 475 rdates.append(period) 476 continue 477 478 if start_errors: 479 start = correct_datetime(start, permitted_values) 480 if end_errors: 481 end = correct_datetime(end, permitted_values) 482 period = RecurringPeriod(start, end, period.tzid, period.origin, period.get_start_attr(), period.get_end_attr()) 483 484 if period.origin == "DTSTART": 485 self.set_period(period) 486 corrected.add("DTSTART") 487 elif period.origin == "RDATE": 488 rdates.append(period) 489 corrected.add("RDATE") 490 491 if "RDATE" in corrected: 492 self.set_periods(rdates) 493 494 return corrected 495 496 # Construction and serialisation. 497 498 def make_calendar(nodes, method=None): 499 500 """ 501 Return a complete calendar node wrapping the given 'nodes' and employing the 502 given 'method', if indicated. 503 """ 504 505 return ("VCALENDAR", {}, 506 (method and [("METHOD", {}, method)] or []) + 507 [("VERSION", {}, "2.0")] + 508 nodes 509 ) 510 511 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, 512 attendee_attr=None, period=None): 513 514 """ 515 Return a calendar node defining the free/busy details described in the given 516 'freebusy' list, employing the given 'uid', for the given 'organiser' and 517 optional 'organiser_attr', with the optional 'attendee' providing recipient 518 details together with the optional 'attendee_attr'. 519 520 The result will be constrained to the 'period' if specified. 521 """ 522 523 record = [] 524 rwrite = record.append 525 526 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 527 528 if attendee: 529 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 530 531 rwrite(("UID", {}, uid)) 532 533 if freebusy: 534 535 # Get a constrained view if start and end limits are specified. 536 537 if period: 538 periods = period_overlaps(freebusy, period, True) 539 else: 540 periods = freebusy 541 542 # Write the limits of the resource. 543 544 if periods: 545 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point()))) 546 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point()))) 547 else: 548 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point()))) 549 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point()))) 550 551 for p in periods: 552 if p.transp == "OPAQUE": 553 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 554 map(format_datetime, [p.get_start_point(), p.get_end_point()]) 555 ))) 556 557 return ("VFREEBUSY", {}, record) 558 559 def parse_object(f, encoding, objtype=None): 560 561 """ 562 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 563 given, only objects of that type will be returned. Otherwise, the root of 564 the content will be returned as a dictionary with a single key indicating 565 the object type. 566 567 Return None if the content was not readable or suitable. 568 """ 569 570 try: 571 try: 572 doctype, attrs, elements = obj = parse(f, encoding=encoding) 573 if objtype and doctype == objtype: 574 return to_dict(obj)[objtype][0] 575 elif not objtype: 576 return to_dict(obj) 577 finally: 578 f.close() 579 580 # NOTE: Handle parse errors properly. 581 582 except (ParseError, ValueError): 583 pass 584 585 return None 586 587 def to_part(method, calendar): 588 589 """ 590 Write using the given 'method', the 'calendar' details to a MIME 591 text/calendar part. 592 """ 593 594 encoding = "utf-8" 595 out = StringIO() 596 try: 597 to_stream(out, make_calendar(calendar, method), encoding) 598 part = MIMEText(out.getvalue(), "calendar", encoding) 599 part.set_param("method", method) 600 return part 601 602 finally: 603 out.close() 604 605 def to_stream(out, fragment, encoding="utf-8"): 606 iterwrite(out, encoding=encoding).append(fragment) 607 608 # Structure access functions. 609 610 def get_items(d, name, all=True): 611 612 """ 613 Get all items from 'd' for the given 'name', returning single items if 614 'all' is specified and set to a false value and if only one value is 615 present for the name. Return None if no items are found for the name or if 616 many items are found but 'all' is set to a false value. 617 """ 618 619 if d.has_key(name): 620 items = [(value or None, attr) for value, attr in d[name]] 621 if all: 622 return items 623 elif len(items) == 1: 624 return items[0] 625 else: 626 return None 627 else: 628 return None 629 630 def get_item(d, name): 631 return get_items(d, name, False) 632 633 def get_value_map(d, name): 634 635 """ 636 Return a dictionary for all items in 'd' having the given 'name'. The 637 dictionary will map values for the name to any attributes or qualifiers 638 that may have been present. 639 """ 640 641 items = get_items(d, name) 642 if items: 643 return dict(items) 644 else: 645 return {} 646 647 def values_from_items(items): 648 return map(lambda x: x[0], items) 649 650 def get_values(d, name, all=True): 651 if d.has_key(name): 652 items = d[name] 653 if not all and len(items) == 1: 654 return items[0][0] 655 else: 656 return values_from_items(items) 657 else: 658 return None 659 660 def get_value(d, name): 661 return get_values(d, name, False) 662 663 def get_date_value_items(d, name, tzid=None): 664 665 """ 666 Obtain items from 'd' having the given 'name', where a single item yields 667 potentially many values. Return a list of tuples of the form (value, 668 attributes) where the attributes have been given for the property in 'd'. 669 """ 670 671 items = get_items(d, name) 672 if items: 673 all_items = [] 674 for item in items: 675 values, attr = item 676 if not attr.has_key("TZID") and tzid: 677 attr["TZID"] = tzid 678 if not isinstance(values, list): 679 values = [values] 680 for value in values: 681 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr)) 682 return all_items 683 else: 684 return None 685 686 def get_period_values(d, name, tzid=None): 687 688 """ 689 Return period values from 'd' for the given property 'name', using 'tzid' 690 where specified to indicate the time zone. 691 """ 692 693 values = [] 694 for value, attr in get_items(d, name) or []: 695 if not attr.has_key("TZID") and tzid: 696 attr["TZID"] = tzid 697 start, end = get_period(value, attr) 698 values.append(Period(start, end, tzid=tzid)) 699 return values 700 701 def get_utc_datetime(d, name, date_tzid=None): 702 703 """ 704 Return the value provided by 'd' for 'name' as a datetime in the UTC zone 705 or as a date, converting any date to a datetime if 'date_tzid' is specified. 706 If no datetime or date is available, None is returned. 707 """ 708 709 t = get_datetime_item(d, name) 710 if not t: 711 return None 712 else: 713 dt, attr = t 714 return dt is not None and to_utc_datetime(dt, date_tzid) or None 715 716 def get_datetime_item(d, name): 717 718 """ 719 Return the value provided by 'd' for 'name' as a datetime or as a date, 720 together with the attributes describing it. Return None if no value exists 721 for 'name' in 'd'. 722 """ 723 724 t = get_item(d, name) 725 if not t: 726 return None 727 else: 728 value, attr = t 729 dt = get_datetime(value, attr) 730 tzid = get_datetime_tzid(dt) 731 if tzid: 732 attr["TZID"] = tzid 733 return dt, attr 734 735 # Conversion functions. 736 737 def get_address_parts(values): 738 739 "Return name and address tuples for each of the given 'values'." 740 741 l = [] 742 for name, address in values and email.utils.getaddresses(values) or []: 743 if is_mailto_uri(name): 744 name = name[7:] # strip "mailto:" 745 l.append((name, address)) 746 return l 747 748 def get_addresses(values): 749 750 """ 751 Return only addresses from the given 'values' which may be of the form 752 "Common Name <recipient@domain>", with the latter part being the address 753 itself. 754 """ 755 756 return [address for name, address in get_address_parts(values)] 757 758 def get_address(value): 759 760 "Return an e-mail address from the given 'value'." 761 762 if not value: return None 763 return get_addresses([value])[0] 764 765 def get_verbose_address(value, attr=None): 766 767 """ 768 Return a verbose e-mail address featuring any name from the given 'value' 769 and any accompanying 'attr' dictionary. 770 """ 771 772 name, address = get_address_parts([value])[0] 773 if not name: 774 name = attr and attr.get("CN") 775 if name and address: 776 return "%s <%s>" % (name, address) 777 else: 778 return address 779 780 def is_mailto_uri(value): 781 return value.lower().startswith("mailto:") 782 783 def get_uri(value): 784 785 "Return a URI for the given 'value'." 786 787 if not value: return None 788 return is_mailto_uri(value) and ("mailto:%s" % value[7:]) or \ 789 ":" in value and value or \ 790 "mailto:%s" % get_address(value) 791 792 def uri_parts(values): 793 794 "Return any common name plus the URI for each of the given 'values'." 795 796 return [(name, get_uri(address)) for name, address in get_address_parts(values)] 797 798 uri_value = get_uri 799 800 def uri_values(values): 801 return map(get_uri, values) 802 803 def uri_dict(d): 804 return dict([(get_uri(key), value) for key, value in d.items()]) 805 806 def uri_item(item): 807 return get_uri(item[0]), item[1] 808 809 def uri_items(items): 810 return [(get_uri(value), attr) for value, attr in items] 811 812 # Operations on structure data. 813 814 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp): 815 816 """ 817 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 818 'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object 819 providing the new information is really newer than the object providing the 820 old information. 821 """ 822 823 have_sequence = old_sequence is not None and new_sequence is not None 824 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 825 826 have_dtstamp = old_dtstamp and new_dtstamp 827 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 828 829 is_old_sequence = have_sequence and ( 830 int(new_sequence) < int(old_sequence) or 831 is_same_sequence and is_old_dtstamp 832 ) 833 834 return is_same_sequence and ignore_dtstamp or not is_old_sequence 835 836 def get_periods(obj, tzid, end=None, inclusive=False): 837 838 """ 839 Return periods for the given object 'obj', employing the given 'tzid' where 840 no time zone information is available (for whole day events, for example), 841 confining materialised periods to before the given 'end' datetime. 842 843 If 'end' is omitted, only explicit recurrences and recurrences from 844 explicitly-terminated rules will be returned. 845 846 If 'inclusive' is set to a true value, any period occurring at the 'end' 847 will be included. 848 """ 849 850 rrule = obj.get_value("RRULE") 851 parameters = rrule and get_parameters(rrule) 852 853 # Use localised datetimes. 854 855 main_period = obj.get_main_period(tzid) 856 857 dtstart = main_period.get_start() 858 dtstart_attr = main_period.get_start_attr() 859 dtend = main_period.get_end() 860 dtend_attr = main_period.get_end_attr() 861 862 duration = dtend - dtstart 863 864 # Attempt to get time zone details from the object, using the supplied zone 865 # only as a fallback. 866 867 obj_tzid = obj.get_tzid() 868 869 if not rrule: 870 periods = [main_period] 871 872 elif end or parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT"): 873 874 # Recurrence rules create multiple instances to be checked. 875 # Conflicts may only be assessed within a period defined by policy 876 # for the agent, with instances outside that period being considered 877 # unchecked. 878 879 selector = get_rule(dtstart, rrule) 880 periods = [] 881 882 until = parameters.get("UNTIL") 883 if until: 884 until_dt = to_timezone(get_datetime(until, dtstart_attr), obj_tzid) 885 end = end and min(until_dt, end) or until_dt 886 inclusive = True 887 888 for recurrence_start in selector.materialise(dtstart, end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive): 889 create = len(recurrence_start) == 3 and date or datetime 890 recurrence_start = to_timezone(create(*recurrence_start), obj_tzid) 891 recurrence_end = recurrence_start + duration 892 periods.append(RecurringPeriod(recurrence_start, recurrence_end, tzid, "RRULE", dtstart_attr)) 893 894 else: 895 periods = [] 896 897 # Add recurrence dates. 898 899 rdates = obj.get_date_value_items("RDATE", tzid) 900 901 if rdates: 902 for rdate, rdate_attr in rdates: 903 if isinstance(rdate, tuple): 904 periods.append(RecurringPeriod(rdate[0], rdate[1], tzid, "RDATE", rdate_attr)) 905 else: 906 periods.append(RecurringPeriod(rdate, rdate + duration, tzid, "RDATE", rdate_attr)) 907 908 # Return a sorted list of the periods. 909 910 periods.sort() 911 912 # Exclude exception dates. 913 914 exdates = obj.get_date_value_items("EXDATE", tzid) 915 916 if exdates: 917 for exdate, exdate_attr in exdates: 918 if isinstance(exdate, tuple): 919 period = RecurringPeriod(exdate[0], exdate[1], tzid, "EXDATE", exdate_attr) 920 else: 921 period = RecurringPeriod(exdate, exdate + duration, tzid, "EXDATE", exdate_attr) 922 i = bisect_left(periods, period) 923 while i < len(periods) and periods[i] == period: 924 del periods[i] 925 926 return periods 927 928 def get_sender_identities(mapping): 929 930 """ 931 Return a mapping from actual senders to the identities for which they 932 have provided data, extracting this information from the given 933 'mapping'. 934 """ 935 936 senders = {} 937 938 for value, attr in mapping.items(): 939 sent_by = attr.get("SENT-BY") 940 if sent_by: 941 sender = get_uri(sent_by) 942 else: 943 sender = value 944 945 if not senders.has_key(sender): 946 senders[sender] = [] 947 948 senders[sender].append(value) 949 950 return senders 951 952 def get_window_end(tzid, days=100): 953 954 """ 955 Return a datetime in the time zone indicated by 'tzid' marking the end of a 956 window of the given number of 'days'. 957 """ 958 959 return to_timezone(datetime.now(), tzid) + timedelta(days) 960 961 # vim: tabstop=4 expandtab shiftwidth=4