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 attendee_attr = attendees.get(participant) 275 if attendee_attr: 276 return attendee_attr.get("PARTSTAT", "NEEDS-ACTION") 277 elif organiser == participant: 278 return "ORG" 279 280 return None 281 282 def get_participation(self, partstat, include_needs_action=False): 283 284 """ 285 Return whether 'partstat' indicates some kind of participation in an 286 event. If 'include_needs_action' is specified as a true value, events 287 not yet responded to will be treated as events with tentative 288 participation. 289 """ 290 291 return not partstat in ("DECLINED", "DELEGATED", "NEEDS-ACTION") or \ 292 include_needs_action and partstat == "NEEDS-ACTION" or \ 293 partstat == "ORG" 294 295 def get_tzid(self): 296 297 """ 298 Return a time zone identifier used by the start or end datetimes, 299 potentially suitable for converting dates to datetimes. 300 """ 301 302 if not self.has_key("DTSTART"): 303 return None 304 dtstart, dtstart_attr = self.get_datetime_item("DTSTART") 305 if self.has_key("DTEND"): 306 dtend, dtend_attr = self.get_datetime_item("DTEND") 307 else: 308 dtend_attr = None 309 return get_tzid(dtstart_attr, dtend_attr) 310 311 def is_shared(self): 312 313 """ 314 Return whether this object is shared based on the presence of a SEQUENCE 315 property. 316 """ 317 318 return self.get_value("SEQUENCE") is not None 319 320 def possibly_active_from(self, dt, tzid): 321 322 """ 323 Return whether the object is possibly active from or after the given 324 datetime 'dt' using 'tzid' to convert any dates or floating datetimes. 325 """ 326 327 dt = to_datetime(dt, tzid) 328 periods = self.get_periods(tzid) 329 330 for p in periods: 331 if p.get_end_point() > dt: 332 return True 333 334 return self.possibly_recurring_indefinitely() 335 336 def possibly_recurring_indefinitely(self): 337 338 "Return whether this object may recur indefinitely." 339 340 rrule = self.get_value("RRULE") 341 parameters = rrule and get_parameters(rrule) 342 until = parameters and parameters.get("UNTIL") 343 count = parameters and parameters.get("COUNT") 344 345 # Non-recurring periods or constrained recurrences. 346 347 if not rrule or until or count: 348 return False 349 350 # Unconstrained recurring periods will always lie beyond any specified 351 # datetime. 352 353 else: 354 return True 355 356 # Modification methods. 357 358 def set_datetime(self, name, dt, tzid=None): 359 360 """ 361 Set a datetime for property 'name' using 'dt' and the optional fallback 362 'tzid', returning whether an update has occurred. 363 """ 364 365 if dt: 366 old_value = self.get_value(name) 367 self[name] = [get_item_from_datetime(dt, tzid)] 368 return format_datetime(dt) != old_value 369 370 return False 371 372 def set_period(self, period): 373 374 "Set the given 'period' as the main start and end." 375 376 result = self.set_datetime("DTSTART", period.get_start()) 377 result = self.set_datetime("DTEND", period.get_end()) or result 378 if self.has_key("DURATION"): 379 del self["DURATION"] 380 381 return result 382 383 def set_periods(self, periods): 384 385 """ 386 Set the given 'periods' as recurrence date properties, replacing the 387 previous RDATE properties and ignoring any RRULE properties. 388 """ 389 390 old_values = set(self.get_date_values("RDATE")) 391 new_rdates = [] 392 393 if self.has_key("RDATE"): 394 del self["RDATE"] 395 396 for p in periods: 397 if p.origin != "RRULE": 398 new_rdates.append(get_period_item(p.get_start(), p.get_end())) 399 400 if new_rdates: 401 self["RDATE"] = new_rdates 402 403 return old_values != set(self.get_date_values("RDATE")) 404 405 def correct_object(self, tzid, permitted_values): 406 407 "Correct the object's period details." 408 409 corrected = set() 410 rdates = [] 411 412 for period in self.get_periods(tzid): 413 start = period.get_start() 414 end = period.get_end() 415 start_errors = check_permitted_values(start, permitted_values) 416 end_errors = check_permitted_values(end, permitted_values) 417 418 if not (start_errors or end_errors): 419 if period.origin == "RDATE": 420 rdates.append(period) 421 continue 422 423 if start_errors: 424 start = correct_datetime(start, permitted_values) 425 if end_errors: 426 end = correct_datetime(end, permitted_values) 427 period = RecurringPeriod(start, end, period.tzid, period.origin, period.get_start_attr(), period.get_end_attr()) 428 429 if period.origin == "DTSTART": 430 self.set_period(period) 431 corrected.add("DTSTART") 432 elif period.origin == "RDATE": 433 rdates.append(period) 434 corrected.add("RDATE") 435 436 if "RDATE" in corrected: 437 self.set_periods(rdates) 438 439 return corrected 440 441 # Construction and serialisation. 442 443 def make_calendar(nodes, method=None): 444 445 """ 446 Return a complete calendar node wrapping the given 'nodes' and employing the 447 given 'method', if indicated. 448 """ 449 450 return ("VCALENDAR", {}, 451 (method and [("METHOD", {}, method)] or []) + 452 [("VERSION", {}, "2.0")] + 453 nodes 454 ) 455 456 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, 457 attendee_attr=None, period=None): 458 459 """ 460 Return a calendar node defining the free/busy details described in the given 461 'freebusy' list, employing the given 'uid', for the given 'organiser' and 462 optional 'organiser_attr', with the optional 'attendee' providing recipient 463 details together with the optional 'attendee_attr'. 464 465 The result will be constrained to the 'period' if specified. 466 """ 467 468 record = [] 469 rwrite = record.append 470 471 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 472 473 if attendee: 474 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 475 476 rwrite(("UID", {}, uid)) 477 478 if freebusy: 479 480 # Get a constrained view if start and end limits are specified. 481 482 if period: 483 periods = period_overlaps(freebusy, period, True) 484 else: 485 periods = freebusy 486 487 # Write the limits of the resource. 488 489 if periods: 490 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point()))) 491 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point()))) 492 else: 493 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point()))) 494 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point()))) 495 496 for p in periods: 497 if p.transp == "OPAQUE": 498 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 499 map(format_datetime, [p.get_start_point(), p.get_end_point()]) 500 ))) 501 502 return ("VFREEBUSY", {}, record) 503 504 def parse_object(f, encoding, objtype=None): 505 506 """ 507 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 508 given, only objects of that type will be returned. Otherwise, the root of 509 the content will be returned as a dictionary with a single key indicating 510 the object type. 511 512 Return None if the content was not readable or suitable. 513 """ 514 515 try: 516 try: 517 doctype, attrs, elements = obj = parse(f, encoding=encoding) 518 if objtype and doctype == objtype: 519 return to_dict(obj)[objtype][0] 520 elif not objtype: 521 return to_dict(obj) 522 finally: 523 f.close() 524 525 # NOTE: Handle parse errors properly. 526 527 except (ParseError, ValueError): 528 pass 529 530 return None 531 532 def to_part(method, calendar): 533 534 """ 535 Write using the given 'method', the 'calendar' details to a MIME 536 text/calendar part. 537 """ 538 539 encoding = "utf-8" 540 out = StringIO() 541 try: 542 to_stream(out, make_calendar(calendar, method), encoding) 543 part = MIMEText(out.getvalue(), "calendar", encoding) 544 part.set_param("method", method) 545 return part 546 547 finally: 548 out.close() 549 550 def to_stream(out, fragment, encoding="utf-8"): 551 iterwrite(out, encoding=encoding).append(fragment) 552 553 # Structure access functions. 554 555 def get_items(d, name, all=True): 556 557 """ 558 Get all items from 'd' for the given 'name', returning single items if 559 'all' is specified and set to a false value and if only one value is 560 present for the name. Return None if no items are found for the name or if 561 many items are found but 'all' is set to a false value. 562 """ 563 564 if d.has_key(name): 565 items = [(value or None, attr) for value, attr in d[name]] 566 if all: 567 return items 568 elif len(items) == 1: 569 return items[0] 570 else: 571 return None 572 else: 573 return None 574 575 def get_item(d, name): 576 return get_items(d, name, False) 577 578 def get_value_map(d, name): 579 580 """ 581 Return a dictionary for all items in 'd' having the given 'name'. The 582 dictionary will map values for the name to any attributes or qualifiers 583 that may have been present. 584 """ 585 586 items = get_items(d, name) 587 if items: 588 return dict(items) 589 else: 590 return {} 591 592 def values_from_items(items): 593 return map(lambda x: x[0], items) 594 595 def get_values(d, name, all=True): 596 if d.has_key(name): 597 items = d[name] 598 if not all and len(items) == 1: 599 return items[0][0] 600 else: 601 return values_from_items(items) 602 else: 603 return None 604 605 def get_value(d, name): 606 return get_values(d, name, False) 607 608 def get_date_value_items(d, name, tzid=None): 609 610 """ 611 Obtain items from 'd' having the given 'name', where a single item yields 612 potentially many values. Return a list of tuples of the form (value, 613 attributes) where the attributes have been given for the property in 'd'. 614 """ 615 616 items = get_items(d, name) 617 if items: 618 all_items = [] 619 for item in items: 620 values, attr = item 621 if not attr.has_key("TZID") and tzid: 622 attr["TZID"] = tzid 623 if not isinstance(values, list): 624 values = [values] 625 for value in values: 626 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr)) 627 return all_items 628 else: 629 return None 630 631 def get_period_values(d, name, tzid=None): 632 633 """ 634 Return period values from 'd' for the given property 'name', using 'tzid' 635 where specified to indicate the time zone. 636 """ 637 638 values = [] 639 for value, attr in get_items(d, name) or []: 640 if not attr.has_key("TZID") and tzid: 641 attr["TZID"] = tzid 642 start, end = get_period(value, attr) 643 values.append(Period(start, end, tzid=tzid)) 644 return values 645 646 def get_utc_datetime(d, name, date_tzid=None): 647 648 """ 649 Return the value provided by 'd' for 'name' as a datetime in the UTC zone 650 or as a date, converting any date to a datetime if 'date_tzid' is specified. 651 """ 652 653 t = get_datetime_item(d, name) 654 if not t: 655 return None 656 else: 657 dt, attr = t 658 return to_utc_datetime(dt, date_tzid) 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