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