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