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