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