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