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