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 main_changed = False 415 416 for p in periods: 417 if p.origin == "RDATE": 418 new_rdates.append(get_period_item(p.get_start(), p.get_end())) 419 elif p.origin == "DTSTART": 420 main_changed = self.set_period(p) 421 422 if new_rdates: 423 self["RDATE"] = new_rdates 424 425 return main_changed or old_values != set(self.get_date_values("RDATE") or []) 426 427 def update_dtstamp(self): 428 429 "Update the DTSTAMP in the object." 430 431 dtstamp = self.get_utc_datetime("DTSTAMP") 432 utcnow = get_time() 433 dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow) 434 self["DTSTAMP"] = [(dtstamp, {})] 435 return dtstamp 436 437 def update_sequence(self, increment=False): 438 439 "Set or update the SEQUENCE in the object." 440 441 sequence = self.get_value("SEQUENCE") or "0" 442 self["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 443 return sequence 444 445 def update_exceptions(self, excluded): 446 447 """ 448 Update the exceptions to any rule by applying the list of 'excluded' 449 periods. 450 """ 451 452 to_exclude = set(excluded).difference(self.get_date_values("EXDATE") or []) 453 if not to_exclude: 454 return False 455 456 if not self.has_key("EXDATE"): 457 self["EXDATE"] = [] 458 459 for p in to_exclude: 460 self["EXDATE"].append(get_period_item(p.get_start(), p.get_end())) 461 462 return True 463 464 def correct_object(self, tzid, permitted_values): 465 466 "Correct the object's period details." 467 468 corrected = set() 469 rdates = [] 470 471 for period in self.get_periods(tzid): 472 start = period.get_start() 473 end = period.get_end() 474 start_errors = check_permitted_values(start, permitted_values) 475 end_errors = check_permitted_values(end, permitted_values) 476 477 if not (start_errors or end_errors): 478 if period.origin == "RDATE": 479 rdates.append(period) 480 continue 481 482 if start_errors: 483 start = correct_datetime(start, permitted_values) 484 if end_errors: 485 end = correct_datetime(end, permitted_values) 486 period = RecurringPeriod(start, end, period.tzid, period.origin, period.get_start_attr(), period.get_end_attr()) 487 488 if period.origin == "DTSTART": 489 self.set_period(period) 490 corrected.add("DTSTART") 491 elif period.origin == "RDATE": 492 rdates.append(period) 493 corrected.add("RDATE") 494 495 if "RDATE" in corrected: 496 self.set_periods(rdates) 497 498 return corrected 499 500 # Construction and serialisation. 501 502 def make_calendar(nodes, method=None): 503 504 """ 505 Return a complete calendar node wrapping the given 'nodes' and employing the 506 given 'method', if indicated. 507 """ 508 509 return ("VCALENDAR", {}, 510 (method and [("METHOD", {}, method)] or []) + 511 [("VERSION", {}, "2.0")] + 512 nodes 513 ) 514 515 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, 516 attendee_attr=None, period=None): 517 518 """ 519 Return a calendar node defining the free/busy details described in the given 520 'freebusy' list, employing the given 'uid', for the given 'organiser' and 521 optional 'organiser_attr', with the optional 'attendee' providing recipient 522 details together with the optional 'attendee_attr'. 523 524 The result will be constrained to the 'period' if specified. 525 """ 526 527 record = [] 528 rwrite = record.append 529 530 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 531 532 if attendee: 533 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 534 535 rwrite(("UID", {}, uid)) 536 537 if freebusy: 538 539 # Get a constrained view if start and end limits are specified. 540 541 if period: 542 periods = period_overlaps(freebusy, period, True) 543 else: 544 periods = freebusy 545 546 # Write the limits of the resource. 547 548 if periods: 549 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point()))) 550 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point()))) 551 else: 552 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point()))) 553 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point()))) 554 555 for p in periods: 556 if p.transp == "OPAQUE": 557 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 558 map(format_datetime, [p.get_start_point(), p.get_end_point()]) 559 ))) 560 561 return ("VFREEBUSY", {}, record) 562 563 def parse_object(f, encoding, objtype=None): 564 565 """ 566 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 567 given, only objects of that type will be returned. Otherwise, the root of 568 the content will be returned as a dictionary with a single key indicating 569 the object type. 570 571 Return None if the content was not readable or suitable. 572 """ 573 574 try: 575 try: 576 doctype, attrs, elements = obj = parse(f, encoding=encoding) 577 if objtype and doctype == objtype: 578 return to_dict(obj)[objtype][0] 579 elif not objtype: 580 return to_dict(obj) 581 finally: 582 f.close() 583 584 # NOTE: Handle parse errors properly. 585 586 except (ParseError, ValueError): 587 pass 588 589 return None 590 591 def to_part(method, calendar): 592 593 """ 594 Write using the given 'method', the 'calendar' details to a MIME 595 text/calendar part. 596 """ 597 598 encoding = "utf-8" 599 out = StringIO() 600 try: 601 to_stream(out, make_calendar(calendar, method), encoding) 602 part = MIMEText(out.getvalue(), "calendar", encoding) 603 part.set_param("method", method) 604 return part 605 606 finally: 607 out.close() 608 609 def to_stream(out, fragment, encoding="utf-8"): 610 iterwrite(out, encoding=encoding).append(fragment) 611 612 # Structure access functions. 613 614 def get_items(d, name, all=True): 615 616 """ 617 Get all items from 'd' for the given 'name', returning single items if 618 'all' is specified and set to a false value and if only one value is 619 present for the name. Return None if no items are found for the name or if 620 many items are found but 'all' is set to a false value. 621 """ 622 623 if d.has_key(name): 624 items = [(value or None, attr) for value, attr in d[name]] 625 if all: 626 return items 627 elif len(items) == 1: 628 return items[0] 629 else: 630 return None 631 else: 632 return None 633 634 def get_item(d, name): 635 return get_items(d, name, False) 636 637 def get_value_map(d, name): 638 639 """ 640 Return a dictionary for all items in 'd' having the given 'name'. The 641 dictionary will map values for the name to any attributes or qualifiers 642 that may have been present. 643 """ 644 645 items = get_items(d, name) 646 if items: 647 return dict(items) 648 else: 649 return {} 650 651 def values_from_items(items): 652 return map(lambda x: x[0], items) 653 654 def get_values(d, name, all=True): 655 if d.has_key(name): 656 items = d[name] 657 if not all and len(items) == 1: 658 return items[0][0] 659 else: 660 return values_from_items(items) 661 else: 662 return None 663 664 def get_value(d, name): 665 return get_values(d, name, False) 666 667 def get_date_value_items(d, name, tzid=None): 668 669 """ 670 Obtain items from 'd' having the given 'name', where a single item yields 671 potentially many values. Return a list of tuples of the form (value, 672 attributes) where the attributes have been given for the property in 'd'. 673 """ 674 675 items = get_items(d, name) 676 if items: 677 all_items = [] 678 for item in items: 679 values, attr = item 680 if not attr.has_key("TZID") and tzid: 681 attr["TZID"] = tzid 682 if not isinstance(values, list): 683 values = [values] 684 for value in values: 685 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr)) 686 return all_items 687 else: 688 return None 689 690 def get_period_values(d, name, tzid=None): 691 692 """ 693 Return period values from 'd' for the given property 'name', using 'tzid' 694 where specified to indicate the time zone. 695 """ 696 697 values = [] 698 for value, attr in get_items(d, name) or []: 699 if not attr.has_key("TZID") and tzid: 700 attr["TZID"] = tzid 701 start, end = get_period(value, attr) 702 values.append(Period(start, end, tzid=tzid)) 703 return values 704 705 def get_utc_datetime(d, name, date_tzid=None): 706 707 """ 708 Return the value provided by 'd' for 'name' as a datetime in the UTC zone 709 or as a date, converting any date to a datetime if 'date_tzid' is specified. 710 If no datetime or date is available, None is returned. 711 """ 712 713 t = get_datetime_item(d, name) 714 if not t: 715 return None 716 else: 717 dt, attr = t 718 return dt is not None and to_utc_datetime(dt, date_tzid) or None 719 720 def get_datetime_item(d, name): 721 722 """ 723 Return the value provided by 'd' for 'name' as a datetime or as a date, 724 together with the attributes describing it. Return None if no value exists 725 for 'name' in 'd'. 726 """ 727 728 t = get_item(d, name) 729 if not t: 730 return None 731 else: 732 value, attr = t 733 dt = get_datetime(value, attr) 734 tzid = get_datetime_tzid(dt) 735 if tzid: 736 attr["TZID"] = tzid 737 return dt, attr 738 739 # Conversion functions. 740 741 def get_address_parts(values): 742 743 "Return name and address tuples for each of the given 'values'." 744 745 l = [] 746 for name, address in values and email.utils.getaddresses(values) or []: 747 if is_mailto_uri(name): 748 name = name[7:] # strip "mailto:" 749 l.append((name, address)) 750 return l 751 752 def get_addresses(values): 753 754 """ 755 Return only addresses from the given 'values' which may be of the form 756 "Common Name <recipient@domain>", with the latter part being the address 757 itself. 758 """ 759 760 return [address for name, address in get_address_parts(values)] 761 762 def get_address(value): 763 764 "Return an e-mail address from the given 'value'." 765 766 if not value: return None 767 return get_addresses([value])[0] 768 769 def get_verbose_address(value, attr=None): 770 771 """ 772 Return a verbose e-mail address featuring any name from the given 'value' 773 and any accompanying 'attr' dictionary. 774 """ 775 776 l = get_address_parts([value]) 777 if not l: 778 return value 779 name, address = l[0] 780 if not name: 781 name = attr and attr.get("CN") 782 if name and address: 783 return "%s <%s>" % (name, address) 784 else: 785 return address 786 787 def is_mailto_uri(value): 788 return value.lower().startswith("mailto:") 789 790 def get_uri(value): 791 792 "Return a URI for the given 'value'." 793 794 if not value: return None 795 return is_mailto_uri(value) and ("mailto:%s" % value[7:]) or \ 796 ":" in value and value or \ 797 "mailto:%s" % get_address(value) 798 799 def uri_parts(values): 800 801 "Return any common name plus the URI for each of the given 'values'." 802 803 return [(name, get_uri(address)) for name, address in get_address_parts(values)] 804 805 uri_value = get_uri 806 807 def uri_values(values): 808 return map(get_uri, values) 809 810 def uri_dict(d): 811 return dict([(get_uri(key), value) for key, value in d.items()]) 812 813 def uri_item(item): 814 return get_uri(item[0]), item[1] 815 816 def uri_items(items): 817 return [(get_uri(value), attr) for value, attr in items] 818 819 # Operations on structure data. 820 821 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp): 822 823 """ 824 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 825 'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object 826 providing the new information is really newer than the object providing the 827 old information. 828 """ 829 830 have_sequence = old_sequence is not None and new_sequence is not None 831 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 832 833 have_dtstamp = old_dtstamp and new_dtstamp 834 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 835 836 is_old_sequence = have_sequence and ( 837 int(new_sequence) < int(old_sequence) or 838 is_same_sequence and is_old_dtstamp 839 ) 840 841 return is_same_sequence and ignore_dtstamp or not is_old_sequence 842 843 def get_periods(obj, tzid, end=None, inclusive=False): 844 845 """ 846 Return periods for the given object 'obj', employing the given 'tzid' where 847 no time zone information is available (for whole day events, for example), 848 confining materialised periods to before the given 'end' datetime. 849 850 If 'end' is omitted, only explicit recurrences and recurrences from 851 explicitly-terminated rules will be returned. 852 853 If 'inclusive' is set to a true value, any period occurring at the 'end' 854 will be included. 855 """ 856 857 rrule = obj.get_value("RRULE") 858 parameters = rrule and get_parameters(rrule) 859 860 # Use localised datetimes. 861 862 main_period = obj.get_main_period(tzid) 863 864 dtstart = main_period.get_start() 865 dtstart_attr = main_period.get_start_attr() 866 dtend = main_period.get_end() 867 dtend_attr = main_period.get_end_attr() 868 869 duration = dtend - dtstart 870 871 # Attempt to get time zone details from the object, using the supplied zone 872 # only as a fallback. 873 874 obj_tzid = obj.get_tzid() 875 876 if not rrule: 877 periods = [main_period] 878 879 elif end or parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT"): 880 881 # Recurrence rules create multiple instances to be checked. 882 # Conflicts may only be assessed within a period defined by policy 883 # for the agent, with instances outside that period being considered 884 # unchecked. 885 886 selector = get_rule(dtstart, rrule) 887 periods = [] 888 889 until = parameters.get("UNTIL") 890 if until: 891 until_dt = to_timezone(get_datetime(until, dtstart_attr), obj_tzid) 892 end = end and min(until_dt, end) or until_dt 893 inclusive = True 894 895 for recurrence_start in selector.materialise(dtstart, end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive): 896 create = len(recurrence_start) == 3 and date or datetime 897 recurrence_start = to_timezone(create(*recurrence_start), obj_tzid) 898 recurrence_end = recurrence_start + duration 899 periods.append(RecurringPeriod(recurrence_start, recurrence_end, tzid, "RRULE", dtstart_attr)) 900 901 else: 902 periods = [] 903 904 # Add recurrence dates. 905 906 rdates = obj.get_date_value_items("RDATE", tzid) 907 908 if rdates: 909 for rdate, rdate_attr in rdates: 910 if isinstance(rdate, tuple): 911 periods.append(RecurringPeriod(rdate[0], rdate[1], tzid, "RDATE", rdate_attr)) 912 else: 913 periods.append(RecurringPeriod(rdate, rdate + duration, tzid, "RDATE", rdate_attr)) 914 915 # Return a sorted list of the periods. 916 917 periods.sort() 918 919 # Exclude exception dates. 920 921 exdates = obj.get_date_value_items("EXDATE", tzid) 922 923 if exdates: 924 for exdate, exdate_attr in exdates: 925 if isinstance(exdate, tuple): 926 period = RecurringPeriod(exdate[0], exdate[1], tzid, "EXDATE", exdate_attr) 927 else: 928 period = RecurringPeriod(exdate, exdate + duration, tzid, "EXDATE", exdate_attr) 929 i = bisect_left(periods, period) 930 while i < len(periods) and periods[i] == period: 931 del periods[i] 932 933 return periods 934 935 def get_sender_identities(mapping): 936 937 """ 938 Return a mapping from actual senders to the identities for which they 939 have provided data, extracting this information from the given 940 'mapping'. 941 """ 942 943 senders = {} 944 945 for value, attr in mapping.items(): 946 sent_by = attr.get("SENT-BY") 947 if sent_by: 948 sender = get_uri(sent_by) 949 else: 950 sender = value 951 952 if not senders.has_key(sender): 953 senders[sender] = [] 954 955 senders[sender].append(value) 956 957 return senders 958 959 def get_window_end(tzid, days=100): 960 961 """ 962 Return a datetime in the time zone indicated by 'tzid' marking the end of a 963 window of the given number of 'days'. 964 """ 965 966 return to_timezone(datetime.now(), tzid) + timedelta(days) 967 968 # vim: tabstop=4 expandtab shiftwidth=4