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