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