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(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'. 410 """ 411 412 start_point = self.get_recurrence_start_point(recurrenceid) 413 414 for p in self.get_periods(end=start_point, inclusive=True): 415 if p.get_start_point() == start_point: 416 return True 417 418 return False 419 420 def get_updated_periods(self, start=None, end=None): 421 422 """ 423 Return pairs of periods specified by this object and any modifying or 424 cancelling objects, providing correspondences between the original 425 period definitions and those applying after modifications and 426 cancellations have been considered. 427 428 The fallback time zone is used to convert floating dates and datetimes, 429 and 'start' and 'end' respectively indicate the start and end of any 430 time window within which periods are considered. 431 """ 432 433 # Specific recurrences yield all specified periods. 434 435 original = self.get_periods(start, end) 436 437 if self.get_recurrenceid(): 438 updated = [] 439 for p in original: 440 updated.append((p, p)) 441 return updated 442 443 # Parent objects need to have their periods tested against redefined 444 # recurrences. 445 446 modified = {} 447 448 for obj in self.modifying: 449 periods = obj.get_periods(start, end) 450 if periods: 451 modified[obj.get_recurrenceid()] = periods[0] 452 453 cancelled = set() 454 455 for obj in self.cancelling: 456 cancelled.add(obj.get_recurrenceid()) 457 458 updated = [] 459 460 for p in original: 461 recurrenceid = p.is_replaced(modified.keys()) 462 463 # Produce an original-to-modified correspondence, setting the origin 464 # to distinguish the period from the main period. 465 466 if recurrenceid: 467 mp = modified.get(recurrenceid) 468 if mp.origin == "DTSTART" and p.origin != "DTSTART": 469 mp.origin = "DTSTART-RECUR" 470 updated.append((p, mp)) 471 continue 472 473 # Produce an original-to-null correspondence where cancellation has 474 # occurred. 475 476 recurrenceid = p.is_replaced(cancelled) 477 478 if recurrenceid: 479 updated.append((p, None)) 480 continue 481 482 # Produce an identity correspondence where no modification or 483 # cancellation has occurred. 484 485 updated.append((p, p)) 486 487 return updated 488 489 def get_active_periods(self, start=None, end=None): 490 491 """ 492 Return all periods specified by this object that are not replaced by 493 those defined by modifying or cancelling objects, using the fallback 494 time zone to convert floating dates and datetimes, and using 'start' and 495 'end' to respectively indicate the start and end of the time window 496 within which periods are considered. 497 """ 498 499 active = [] 500 501 for old, new in self.get_updated_periods(start, end): 502 if new: 503 active.append(new) 504 505 return active 506 507 def get_freebusy_period(self, period, only_organiser=False): 508 509 """ 510 Return a free/busy period for the given 'period' provided by this 511 object, using the 'only_organiser' status to produce a suitable 512 transparency value. 513 """ 514 515 return FreeBusyPeriod( 516 period.get_start_point(), 517 period.get_end_point(), 518 self.get_value("UID"), 519 only_organiser and "ORG" or self.get_value("TRANSP") or "OPAQUE", 520 self.get_recurrenceid(), 521 self.get_value("SUMMARY"), 522 self.get_uri("ORGANIZER") 523 ) 524 525 def get_participation_status(self, participant): 526 527 """ 528 Return the participation status of the given 'participant', with the 529 special value "ORG" indicating organiser-only participation. 530 """ 531 532 attendees = self.get_uri_map("ATTENDEE") 533 organiser = self.get_uri("ORGANIZER") 534 535 attendee_attr = attendees.get(participant) 536 if attendee_attr: 537 return attendee_attr.get("PARTSTAT", "NEEDS-ACTION") 538 elif organiser == participant: 539 return "ORG" 540 541 return None 542 543 def get_participation(self, partstat, include_needs_action=False): 544 545 """ 546 Return whether 'partstat' indicates some kind of participation in an 547 event. If 'include_needs_action' is specified as a true value, events 548 not yet responded to will be treated as events with tentative 549 participation. 550 """ 551 552 return not partstat in ("DECLINED", "DELEGATED", "NEEDS-ACTION") or \ 553 include_needs_action and partstat == "NEEDS-ACTION" or \ 554 partstat == "ORG" 555 556 def get_tzid(self): 557 558 """ 559 Return a time zone identifier used by the start or end datetimes, 560 potentially suitable for converting dates to datetimes. Where no 561 identifier is associated with the datetimes, provide any fallback time 562 zone identifier. 563 """ 564 565 (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_main_period_items() 566 return get_tzid(dtstart_attr, dtend_attr) or self.tzid 567 568 def is_shared(self): 569 570 """ 571 Return whether this object is shared based on the presence of a SEQUENCE 572 property. 573 """ 574 575 return self.get_value("SEQUENCE") is not None 576 577 def possibly_active_from(self, dt): 578 579 """ 580 Return whether the object is possibly active from or after the given 581 datetime 'dt' using the fallback time zone to convert any dates or 582 floating datetimes. 583 """ 584 585 dt = to_datetime(dt, self.tzid) 586 periods = self.get_periods() 587 588 for p in periods: 589 if p.get_end_point() > dt: 590 return True 591 592 return self.possibly_recurring_indefinitely() 593 594 def possibly_recurring_indefinitely(self): 595 596 "Return whether this object may recur indefinitely." 597 598 rrule = self.get_value("RRULE") 599 return rrule and not rule_has_end(rrule) 600 601 # Modification methods. 602 603 def set_datetime(self, name, dt): 604 605 """ 606 Set a datetime for property 'name' using 'dt' and the fallback time zone 607 where necessary, returning whether an update has occurred. 608 """ 609 610 if dt: 611 old_value = self.get_value(name) 612 self[name] = [get_item_from_datetime(dt, self.tzid)] 613 return format_datetime(dt) != old_value 614 615 return False 616 617 def set_period(self, period): 618 619 "Set the given 'period' as the main start and end." 620 621 result = self.set_datetime("DTSTART", period.get_start()) 622 result = self.set_datetime("DTEND", period.get_end()) or result 623 if self.has_key("DURATION"): 624 del self["DURATION"] 625 626 return result 627 628 def set_periods(self, periods): 629 630 """ 631 Set the given 'periods' as recurrence date properties, replacing the 632 previous RDATE properties and ignoring any RRULE properties. 633 """ 634 635 old_values = set(self.get_date_value_item_periods("RDATE") or []) 636 new_rdates = [] 637 638 if self.has_key("RDATE"): 639 del self["RDATE"] 640 641 main_changed = False 642 643 for p in periods: 644 if p.origin == "DTSTART": 645 main_changed = self.set_period(p) 646 elif p.origin != "RRULE" and p != self.get_main_period(): 647 new_rdates.append(get_period_item(p.get_start(), p.get_end())) 648 649 if new_rdates: 650 self["RDATE"] = new_rdates 651 652 return main_changed or old_values != set(self.get_date_value_item_periods("RDATE") or []) 653 654 def set_rule(self, rule): 655 656 """ 657 Set the given 'rule' in this object, replacing the previous RRULE 658 property, returning whether the object has changed. The provided 'rule' 659 must be an item. 660 """ 661 662 if not rule: 663 return False 664 665 old_rrule = self.get_item("RRULE") 666 if rule: 667 self["RRULE"] = [rule] 668 elif old_rrule: 669 del self["RRULE"] 670 return old_rrule != rule 671 672 def set_exceptions(self, exceptions): 673 674 """ 675 Set the given 'exceptions' in this object, replacing the previous EXDATE 676 properties, returning whether the object has changed. The provided 677 'exceptions' must be a collection of items. 678 """ 679 680 old_exdates = set(self.get_date_value_item_periods("EXDATE") or []) 681 if exceptions: 682 self["EXDATE"] = exceptions 683 return old_exdates != set(self.get_date_value_item_periods("EXDATE") or []) 684 elif old_exdates: 685 del self["EXDATE"] 686 return True 687 else: 688 return False 689 690 def update_dtstamp(self): 691 692 "Update the DTSTAMP in the object." 693 694 dtstamp = self.get_utc_datetime("DTSTAMP") 695 utcnow = get_time() 696 dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow) 697 self["DTSTAMP"] = [(dtstamp, {})] 698 return dtstamp 699 700 def update_sequence(self, increment=False): 701 702 "Set or update the SEQUENCE in the object." 703 704 sequence = self.get_value("SEQUENCE") or "0" 705 self["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 706 return sequence 707 708 def update_exceptions(self, excluded, asserted): 709 710 """ 711 Update the exceptions to any rule by applying the list of 'excluded' 712 periods. Where 'asserted' periods are provided, exceptions will be 713 removed corresponding to those periods. 714 """ 715 716 old_exdates = self.get_date_value_item_periods("EXDATE") or [] 717 new_exdates = set(old_exdates) 718 new_exdates.update(excluded) 719 new_exdates.difference_update(asserted) 720 721 if not new_exdates and self.has_key("EXDATE"): 722 del self["EXDATE"] 723 else: 724 self["EXDATE"] = [] 725 for p in new_exdates: 726 self["EXDATE"].append(get_period_item(p.get_start(), p.get_end())) 727 728 return set(old_exdates) != new_exdates 729 730 def correct_object(self, permitted_values): 731 732 "Correct the object's period details using the 'permitted_values'." 733 734 corrected = set() 735 rdates = [] 736 737 for period in self.get_periods(): 738 corrected_period = period.get_corrected(permitted_values) 739 740 if corrected_period is period: 741 if period.origin == "RDATE": 742 rdates.append(period) 743 continue 744 745 if period.origin == "DTSTART": 746 self.set_period(corrected_period) 747 corrected.add("DTSTART") 748 elif period.origin == "RDATE": 749 rdates.append(corrected_period) 750 corrected.add("RDATE") 751 752 if "RDATE" in corrected: 753 self.set_periods(rdates) 754 755 return corrected 756 757 # Construction and serialisation. 758 759 def make_calendar(nodes, method=None): 760 761 """ 762 Return a complete calendar node wrapping the given 'nodes' and employing the 763 given 'method', if indicated. 764 """ 765 766 return ("VCALENDAR", {}, 767 (method and [("METHOD", {}, method)] or []) + 768 [("VERSION", {}, "2.0")] + 769 nodes 770 ) 771 772 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, 773 attendee_attr=None, period=None): 774 775 """ 776 Return a calendar node defining the free/busy details described in the given 777 'freebusy' list, employing the given 'uid', for the given 'organiser' and 778 optional 'organiser_attr', with the optional 'attendee' providing recipient 779 details together with the optional 'attendee_attr'. 780 781 The result will be constrained to the 'period' if specified. 782 """ 783 784 record = [] 785 rwrite = record.append 786 787 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 788 789 if attendee: 790 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 791 792 rwrite(("UID", {}, uid)) 793 794 if freebusy: 795 796 # Get a constrained view if start and end limits are specified. 797 798 if period: 799 periods = freebusy.get_overlapping([period]) 800 else: 801 periods = freebusy 802 803 # Write the limits of the resource. 804 805 if periods: 806 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point()))) 807 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point()))) 808 else: 809 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point()))) 810 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point()))) 811 812 for p in periods: 813 if p.transp == "OPAQUE": 814 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 815 map(format_datetime, [p.get_start_point(), p.get_end_point()]) 816 ))) 817 818 return ("VFREEBUSY", {}, record) 819 820 def parse_calendar(f, encoding, tzid=None): 821 822 """ 823 Parse the iTIP content from 'f' having the given 'encoding'. Return a 824 mapping from object types to collections of calendar objects. If 'tzid' is 825 specified, use it to set the fallback time zone on all returned objects. 826 """ 827 828 cal = parse_object(f, encoding, "VCALENDAR") 829 d = {} 830 831 for objtype, values in cal.items(): 832 d[objtype] = l = [] 833 for value in values: 834 l.append(Object({objtype : value}, tzid)) 835 836 return d 837 838 def parse_object(f, encoding, objtype=None): 839 840 """ 841 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 842 given, only objects of that type will be returned. Otherwise, the root of 843 the content will be returned as a dictionary with a single key indicating 844 the object type. 845 846 Return None if the content was not readable or suitable. 847 """ 848 849 try: 850 try: 851 doctype, attrs, elements = obj = parse(f, encoding=encoding) 852 if objtype and doctype == objtype: 853 return to_dict(obj)[objtype][0] 854 elif not objtype: 855 return to_dict(obj) 856 finally: 857 f.close() 858 859 # NOTE: Handle parse errors properly. 860 861 except (ParseError, ValueError): 862 pass 863 864 return None 865 866 def parse_string(s, encoding, objtype=None): 867 868 """ 869 Parse the iTIP content from 's' having the given 'encoding'. If 'objtype' is 870 given, only objects of that type will be returned. Otherwise, the root of 871 the content will be returned as a dictionary with a single key indicating 872 the object type. 873 874 Return None if the content was not readable or suitable. 875 """ 876 877 return parse_object(StringIO(s), encoding, objtype) 878 879 def to_part(method, fragments, encoding="utf-8", line_length=None): 880 881 """ 882 Write using the given 'method', the given 'fragments' to a MIME 883 text/calendar part. 884 """ 885 886 out = StringIO() 887 try: 888 to_stream(out, make_calendar(fragments, method), encoding, line_length) 889 part = MIMEText(out.getvalue(), "calendar", encoding) 890 part.set_param("method", method) 891 return part 892 893 finally: 894 out.close() 895 896 def to_stream(out, fragment, encoding="utf-8", line_length=None): 897 898 "Write to the 'out' stream the given 'fragment'." 899 900 iterwrite(out, encoding=encoding, line_length=line_length).append(fragment) 901 902 def to_string(fragment, encoding="utf-8", line_length=None): 903 904 "Return a string encoding the given 'fragment'." 905 906 out = StringIO() 907 try: 908 to_stream(out, fragment, encoding, line_length) 909 return out.getvalue() 910 911 finally: 912 out.close() 913 914 def new_object(object_type, organiser=None, organiser_attr=None, tzid=None): 915 916 """ 917 Make a new object of the given 'object_type' and optional 'organiser', 918 with optional 'organiser_attr' describing any organiser identity in more 919 detail. An optional 'tzid' can also be provided. 920 """ 921 922 details = {} 923 924 if organiser: 925 details["UID"] = [(make_uid(organiser), {})] 926 details["ORGANIZER"] = [(organiser, organiser_attr or {})] 927 details["DTSTAMP"] = [(get_timestamp(), {})] 928 929 return Object({object_type : (details, {})}, tzid) 930 931 def make_uid(user): 932 933 "Return a unique identifier for a new object by the given 'user'." 934 935 utcnow = get_timestamp() 936 return "imip-agent-%s-%s" % (utcnow, get_address(user)) 937 938 # Structure access functions. 939 940 def get_items(d, name, all=True): 941 942 """ 943 Get all items from 'd' for the given 'name', returning single items if 944 'all' is specified and set to a false value and if only one value is 945 present for the name. Return None if no items are found for the name or if 946 many items are found but 'all' is set to a false value. 947 """ 948 949 if d.has_key(name): 950 items = [(value or None, attr) for value, attr in d[name]] 951 if all: 952 return items 953 elif len(items) == 1: 954 return items[0] 955 else: 956 return None 957 else: 958 return None 959 960 def get_item(d, name): 961 return get_items(d, name, False) 962 963 def get_value_map(d, name): 964 965 """ 966 Return a dictionary for all items in 'd' having the given 'name'. The 967 dictionary will map values for the name to any attributes or qualifiers 968 that may have been present. 969 """ 970 971 items = get_items(d, name) 972 if items: 973 return dict(items) 974 else: 975 return {} 976 977 def values_from_items(items): 978 return map(lambda x: x[0], items) 979 980 def get_values(d, name, all=True): 981 if d.has_key(name): 982 items = d[name] 983 if not all and len(items) == 1: 984 return items[0][0] 985 else: 986 return values_from_items(items) 987 else: 988 return None 989 990 def get_value(d, name): 991 return get_values(d, name, False) 992 993 def get_date_value_items(d, name, tzid=None): 994 995 """ 996 Obtain items from 'd' having the given 'name', where a single item yields 997 potentially many values. Return a list of tuples of the form (value, 998 attributes) where the attributes have been given for the property in 'd'. 999 """ 1000 1001 items = get_items(d, name) 1002 if items: 1003 all_items = [] 1004 for item in items: 1005 values, attr = item 1006 if not attr.has_key("TZID") and tzid: 1007 attr["TZID"] = tzid 1008 if not isinstance(values, list): 1009 values = [values] 1010 for value in values: 1011 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr)) 1012 return all_items 1013 else: 1014 return None 1015 1016 def get_date_value_item_periods(d, name, duration, tzid=None): 1017 1018 """ 1019 Obtain items from 'd' having the given 'name', where a single item yields 1020 potentially many values. The 'duration' must be provided to define the 1021 length of periods having only a start datetime. Return a list of periods 1022 corresponding to the property in 'd'. 1023 """ 1024 1025 items = get_date_value_items(d, name, tzid) 1026 if not items: 1027 return items 1028 1029 periods = [] 1030 1031 for value, attr in items: 1032 if isinstance(value, tuple): 1033 periods.append(RecurringPeriod(value[0], value[1], tzid, name, attr)) 1034 else: 1035 periods.append(RecurringPeriod(value, value + duration, tzid, name, attr)) 1036 1037 return periods 1038 1039 def get_period_values(d, name, tzid=None): 1040 1041 """ 1042 Return period values from 'd' for the given property 'name', using 'tzid' 1043 where specified to indicate the time zone. 1044 """ 1045 1046 values = [] 1047 for value, attr in get_items(d, name) or []: 1048 if not attr.has_key("TZID") and tzid: 1049 attr["TZID"] = tzid 1050 start, end = get_period(value, attr) 1051 values.append(Period(start, end, tzid=tzid)) 1052 return values 1053 1054 def get_utc_datetime(d, name, date_tzid=None): 1055 1056 """ 1057 Return the value provided by 'd' for 'name' as a datetime in the UTC zone 1058 or as a date, converting any date to a datetime if 'date_tzid' is specified. 1059 If no datetime or date is available, None is returned. 1060 """ 1061 1062 t = get_datetime_item(d, name) 1063 if not t: 1064 return None 1065 else: 1066 dt, attr = t 1067 return dt is not None and to_utc_datetime(dt, date_tzid) or None 1068 1069 def get_datetime_item(d, name): 1070 1071 """ 1072 Return the value provided by 'd' for 'name' as a datetime or as a date, 1073 together with the attributes describing it. Return None if no value exists 1074 for 'name' in 'd'. 1075 """ 1076 1077 t = get_item(d, name) 1078 if not t: 1079 return None 1080 else: 1081 value, attr = t 1082 dt = get_datetime(value, attr) 1083 tzid = get_datetime_tzid(dt) 1084 if tzid: 1085 attr["TZID"] = tzid 1086 return dt, attr 1087 1088 # Conversion functions. 1089 1090 def get_address_parts(values): 1091 1092 "Return name and address tuples for each of the given 'values'." 1093 1094 l = [] 1095 for name, address in values and email.utils.getaddresses(values) or []: 1096 if is_mailto_uri(name): 1097 name = name[7:] # strip "mailto:" 1098 l.append((name, address)) 1099 return l 1100 1101 def get_addresses(values): 1102 1103 """ 1104 Return only addresses from the given 'values' which may be of the form 1105 "Common Name <recipient@domain>", with the latter part being the address 1106 itself. 1107 """ 1108 1109 return [address for name, address in get_address_parts(values)] 1110 1111 def get_address(value): 1112 1113 "Return an e-mail address from the given 'value'." 1114 1115 if not value: return None 1116 return get_addresses([value])[0] 1117 1118 def get_verbose_address(value, attr=None): 1119 1120 """ 1121 Return a verbose e-mail address featuring any name from the given 'value' 1122 and any accompanying 'attr' dictionary. 1123 """ 1124 1125 l = get_address_parts([value]) 1126 if not l: 1127 return value 1128 name, address = l[0] 1129 if not name: 1130 name = attr and attr.get("CN") 1131 if name and address: 1132 return "%s <%s>" % (name, address) 1133 else: 1134 return address 1135 1136 def is_mailto_uri(value): 1137 1138 """ 1139 Return whether 'value' is a mailto: URI, with the protocol potentially being 1140 in upper case. 1141 """ 1142 1143 return value.lower().startswith("mailto:") 1144 1145 def get_uri(value): 1146 1147 "Return a URI for the given 'value'." 1148 1149 if not value: return None 1150 1151 # Normalise to "mailto:" or return other URI form. 1152 1153 return is_mailto_uri(value) and ("mailto:%s" % value[7:]) or \ 1154 ":" in value and value or \ 1155 "mailto:%s" % get_address(value) 1156 1157 def uri_parts(values): 1158 1159 "Return any common name plus the URI for each of the given 'values'." 1160 1161 return [(name, get_uri(address)) for name, address in get_address_parts(values)] 1162 1163 uri_value = get_uri 1164 1165 def uri_values(values): 1166 return values and map(get_uri, values) 1167 1168 def uri_dict(d): 1169 return dict([(get_uri(key), value) for key, value in d.items()]) 1170 1171 def uri_item(item): 1172 return get_uri(item[0]), item[1] 1173 1174 def uri_items(items): 1175 return items and [(get_uri(value), attr) for value, attr in items] 1176 1177 # Operations on structure data. 1178 1179 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp): 1180 1181 """ 1182 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 1183 'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object 1184 providing the new information is really newer than the object providing the 1185 old information. 1186 """ 1187 1188 have_sequence = old_sequence is not None and new_sequence is not None 1189 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 1190 1191 have_dtstamp = old_dtstamp and new_dtstamp 1192 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 1193 1194 is_old_sequence = have_sequence and ( 1195 int(new_sequence) < int(old_sequence) or 1196 is_same_sequence and is_old_dtstamp 1197 ) 1198 1199 return is_same_sequence and ignore_dtstamp or not is_old_sequence 1200 1201 def check_delegation(attendee_map, attendee, attendee_attr): 1202 1203 """ 1204 Using the 'attendee_map', check the attributes for the given 'attendee' 1205 provided as 'attendee_attr', following the delegation chain back to the 1206 delegators and forward again to yield the delegate identities in each 1207 case. Pictorially... 1208 1209 attendee -> DELEGATED-FROM -> delegator 1210 ? <- DELEGATED-TO <--- 1211 1212 Return whether 'attendee' was identified as a delegate by providing the 1213 identity of any delegators referencing the attendee. 1214 """ 1215 1216 delegators = [] 1217 1218 # The recipient should have a reference to the delegator. 1219 1220 delegated_from = attendee_attr and attendee_attr.get("DELEGATED-FROM") 1221 if delegated_from: 1222 1223 # Examine all delegators. 1224 1225 for delegator in delegated_from: 1226 delegator_attr = attendee_map.get(delegator) 1227 1228 # The delegator should have a reference to the recipient. 1229 1230 delegated_to = delegator_attr and delegator_attr.get("DELEGATED-TO") 1231 if delegated_to and attendee in delegated_to: 1232 delegators.append(delegator) 1233 1234 return delegators 1235 1236 def get_periods(obj, start=None, end=None, inclusive=False): 1237 1238 """ 1239 Return periods for the given object 'obj', employing the object's fallback 1240 time zone where no time zone information is available (for whole day events, 1241 for example), confining materialised periods to after the given 'start' 1242 datetime and before the given 'end' datetime. 1243 1244 If 'end' is omitted, only explicit recurrences and recurrences from 1245 explicitly-terminated rules will be returned. 1246 1247 If 'inclusive' is set to a true value, any period occurring at the 'end' 1248 will be included. 1249 """ 1250 1251 rrule = obj.get_value("RRULE") 1252 1253 # Recurrence rules create multiple instances to be checked. 1254 # Conflicts may only be assessed within a period defined by policy 1255 # for the agent, with instances outside that period being considered 1256 # unchecked. 1257 1258 if not end and not rule_has_end(rrule): 1259 return iter([]) 1260 1261 tzid = obj.get_tzid() 1262 main_period = obj.get_main_period() 1263 1264 start = main_period.get_start() 1265 selector = get_rule(start, rrule) 1266 1267 if not selector: 1268 return iter([main_period]) 1269 1270 # Obtain the periods generated by the rule. 1271 1272 rule_periods = get_periods_using_selector(selector, 1273 main_period, tzid, start, end, 1274 inclusive) 1275 1276 # Add recurrence dates. 1277 1278 rdates = obj.get_date_value_item_periods("RDATE") 1279 if rdates: 1280 rdates.sort() 1281 1282 # Return a sorted list of the periods. 1283 1284 periods = MergingIterator([rule_periods, iter(rdates or [])]) 1285 1286 # Exclude exception dates. 1287 1288 exdates = set(obj.get_date_value_item_periods("EXDATE") or []) 1289 1290 return filter(lambda p, excluded=exdates: p not in excluded, periods) 1291 1292 def get_periods_using_selector(selector, main_period, tzid, start, end, 1293 inclusive=False): 1294 1295 # Filter periods using a start point. The period from the given start 1296 # until the end of time must wrap each period for that period to be 1297 # included. The end will be handled in the materialisation process. 1298 1299 return ifilter(Period(start, None).wraps, 1300 RulePeriodCollection(selector, main_period, tzid, 1301 end, inclusive)) 1302 1303 def get_main_period(periods): 1304 1305 "Return the main period from 'periods' using origin information." 1306 1307 for p in periods: 1308 if p.origin == "DTSTART": 1309 return p 1310 return None 1311 1312 def get_recurrence_periods(periods): 1313 1314 "Return recurrence periods from 'periods' using origin information." 1315 1316 l = [] 1317 for p in periods: 1318 if p.origin != "DTSTART": 1319 l.append(p) 1320 return l 1321 1322 def get_sender_identities(mapping): 1323 1324 """ 1325 Return a mapping from actual senders to the identities for which they 1326 have provided data, extracting this information from the given 1327 'mapping'. The SENT-BY attribute provides sender information in preference 1328 to the property values given as the mapping keys. 1329 """ 1330 1331 senders = {} 1332 1333 for value, attr in mapping.items(): 1334 sent_by = attr.get("SENT-BY") 1335 if sent_by: 1336 sender = get_uri(sent_by) 1337 else: 1338 sender = value 1339 1340 if not senders.has_key(sender): 1341 senders[sender] = [] 1342 1343 senders[sender].append(value) 1344 1345 return senders 1346 1347 def get_window_end(tzid, days=100, start=None): 1348 1349 """ 1350 Return a datetime in the time zone indicated by 'tzid' marking the end of a 1351 window of the given number of 'days'. If 'start' is not indicated, the start 1352 of the window will be the current moment. 1353 """ 1354 1355 return to_timezone(start or datetime.now(), tzid) + timedelta(days) 1356 1357 def rule_has_end(rrule): 1358 1359 "Return whether 'rrule' defines an end." 1360 1361 parameters = rrule and get_parameters(rrule) 1362 return parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT") 1363 1364 def update_attendees_with_delegates(stored_attendees, attendees): 1365 1366 """ 1367 Update the 'stored_attendees' mapping with delegate information from the 1368 given 'attendees' mapping. 1369 """ 1370 1371 # Check for delegated attendees. 1372 1373 for attendee, attendee_attr in attendees.items(): 1374 1375 # Identify delegates and check the delegation using the updated 1376 # attendee information. 1377 1378 if not stored_attendees.has_key(attendee) and \ 1379 attendee_attr.has_key("DELEGATED-FROM") and \ 1380 check_delegation(stored_attendees, attendee, attendee_attr): 1381 1382 stored_attendees[attendee] = attendee_attr 1383 1384 # vim: tabstop=4 expandtab shiftwidth=4