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 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 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 self["RRULE"] = [rule] 639 return old_rrule != rule 640 641 def set_exceptions(self, exceptions): 642 643 """ 644 Set the given 'exceptions' in this object, replacing the previous EXDATE 645 properties, returning whether the object has changed. The provided 646 'exceptions' must be a collection of items. 647 """ 648 649 old_exdates = set(self.get_date_value_item_periods("EXDATE") or []) 650 if exceptions: 651 self["EXDATE"] = exceptions 652 return old_exdates != set(self.get_date_value_item_periods("EXDATE") or []) 653 elif old_exdates: 654 del self["EXDATE"] 655 return True 656 else: 657 return False 658 659 def update_dtstamp(self): 660 661 "Update the DTSTAMP in the object." 662 663 dtstamp = self.get_utc_datetime("DTSTAMP") 664 utcnow = get_time() 665 dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow) 666 self["DTSTAMP"] = [(dtstamp, {})] 667 return dtstamp 668 669 def update_sequence(self, increment=False): 670 671 "Set or update the SEQUENCE in the object." 672 673 sequence = self.get_value("SEQUENCE") or "0" 674 self["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 675 return sequence 676 677 def update_exceptions(self, excluded, asserted): 678 679 """ 680 Update the exceptions to any rule by applying the list of 'excluded' 681 periods. Where 'asserted' periods are provided, exceptions will be 682 removed corresponding to those periods. 683 """ 684 685 old_exdates = self.get_date_value_item_periods("EXDATE") or [] 686 new_exdates = set(old_exdates) 687 new_exdates.update(excluded) 688 new_exdates.difference_update(asserted) 689 690 if not new_exdates and self.has_key("EXDATE"): 691 del self["EXDATE"] 692 else: 693 self["EXDATE"] = [] 694 for p in new_exdates: 695 self["EXDATE"].append(get_period_item(p.get_start(), p.get_end())) 696 697 return set(old_exdates) != new_exdates 698 699 def correct_object(self, permitted_values): 700 701 "Correct the object's period details using the 'permitted_values'." 702 703 corrected = set() 704 rdates = [] 705 706 for period in self.get_periods(): 707 corrected_period = period.get_corrected(permitted_values) 708 709 if corrected_period is period: 710 if period.origin == "RDATE": 711 rdates.append(period) 712 continue 713 714 if period.origin == "DTSTART": 715 self.set_period(corrected_period) 716 corrected.add("DTSTART") 717 elif period.origin == "RDATE": 718 rdates.append(corrected_period) 719 corrected.add("RDATE") 720 721 if "RDATE" in corrected: 722 self.set_periods(rdates) 723 724 return corrected 725 726 # Construction and serialisation. 727 728 def make_calendar(nodes, method=None): 729 730 """ 731 Return a complete calendar node wrapping the given 'nodes' and employing the 732 given 'method', if indicated. 733 """ 734 735 return ("VCALENDAR", {}, 736 (method and [("METHOD", {}, method)] or []) + 737 [("VERSION", {}, "2.0")] + 738 nodes 739 ) 740 741 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, 742 attendee_attr=None, period=None): 743 744 """ 745 Return a calendar node defining the free/busy details described in the given 746 'freebusy' list, employing the given 'uid', for the given 'organiser' and 747 optional 'organiser_attr', with the optional 'attendee' providing recipient 748 details together with the optional 'attendee_attr'. 749 750 The result will be constrained to the 'period' if specified. 751 """ 752 753 record = [] 754 rwrite = record.append 755 756 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 757 758 if attendee: 759 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 760 761 rwrite(("UID", {}, uid)) 762 763 if freebusy: 764 765 # Get a constrained view if start and end limits are specified. 766 767 if period: 768 periods = freebusy.get_overlapping([period]) 769 else: 770 periods = freebusy 771 772 # Write the limits of the resource. 773 774 if periods: 775 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start_point()))) 776 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end_point()))) 777 else: 778 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_start_point()))) 779 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(period.get_end_point()))) 780 781 for p in periods: 782 if p.transp == "OPAQUE": 783 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 784 map(format_datetime, [p.get_start_point(), p.get_end_point()]) 785 ))) 786 787 return ("VFREEBUSY", {}, record) 788 789 def parse_calendar(f, encoding, tzid=None): 790 791 """ 792 Parse the iTIP content from 'f' having the given 'encoding'. Return a 793 mapping from object types to collections of calendar objects. If 'tzid' is 794 specified, use it to set the fallback time zone on all returned objects. 795 """ 796 797 cal = parse_object(f, encoding, "VCALENDAR") 798 d = {} 799 800 for objtype, values in cal.items(): 801 d[objtype] = l = [] 802 for value in values: 803 l.append(Object({objtype : value}, tzid)) 804 805 return d 806 807 def parse_object(f, encoding, objtype=None): 808 809 """ 810 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 811 given, only objects of that type will be returned. Otherwise, the root of 812 the content will be returned as a dictionary with a single key indicating 813 the object type. 814 815 Return None if the content was not readable or suitable. 816 """ 817 818 try: 819 try: 820 doctype, attrs, elements = obj = parse(f, encoding=encoding) 821 if objtype and doctype == objtype: 822 return to_dict(obj)[objtype][0] 823 elif not objtype: 824 return to_dict(obj) 825 finally: 826 f.close() 827 828 # NOTE: Handle parse errors properly. 829 830 except (ParseError, ValueError): 831 pass 832 833 return None 834 835 def parse_string(s, encoding, objtype=None): 836 837 """ 838 Parse the iTIP content from 's' having the given 'encoding'. If 'objtype' is 839 given, only objects of that type will be returned. Otherwise, the root of 840 the content will be returned as a dictionary with a single key indicating 841 the object type. 842 843 Return None if the content was not readable or suitable. 844 """ 845 846 return parse_object(StringIO(s), encoding, objtype) 847 848 def to_part(method, fragments, encoding="utf-8", line_length=None): 849 850 """ 851 Write using the given 'method', the given 'fragments' to a MIME 852 text/calendar part. 853 """ 854 855 out = StringIO() 856 try: 857 to_stream(out, make_calendar(fragments, method), encoding, line_length) 858 part = MIMEText(out.getvalue(), "calendar", encoding) 859 part.set_param("method", method) 860 return part 861 862 finally: 863 out.close() 864 865 def to_stream(out, fragment, encoding="utf-8", line_length=None): 866 867 "Write to the 'out' stream the given 'fragment'." 868 869 iterwrite(out, encoding=encoding, line_length=line_length).append(fragment) 870 871 def to_string(fragment, encoding="utf-8", line_length=None): 872 873 "Return a string encoding the given 'fragment'." 874 875 out = StringIO() 876 try: 877 to_stream(out, fragment, encoding, line_length) 878 return out.getvalue() 879 880 finally: 881 out.close() 882 883 def new_object(object_type, organiser=None, organiser_attr=None, tzid=None): 884 885 """ 886 Make a new object of the given 'object_type' and optional 'organiser', 887 with optional 'organiser_attr' describing any organiser identity in more 888 detail. An optional 'tzid' can also be provided. 889 """ 890 891 details = {} 892 893 if organiser: 894 details["UID"] = [(make_uid(organiser), {})] 895 details["ORGANIZER"] = [(organiser, organiser_attr or {})] 896 details["DTSTAMP"] = [(get_timestamp(), {})] 897 898 return Object({object_type : (details, {})}, tzid) 899 900 def make_uid(user): 901 902 "Return a unique identifier for a new object by the given 'user'." 903 904 utcnow = get_timestamp() 905 return "imip-agent-%s-%s" % (utcnow, get_address(user)) 906 907 # Structure access functions. 908 909 def get_items(d, name, all=True): 910 911 """ 912 Get all items from 'd' for the given 'name', returning single items if 913 'all' is specified and set to a false value and if only one value is 914 present for the name. Return None if no items are found for the name or if 915 many items are found but 'all' is set to a false value. 916 """ 917 918 if d.has_key(name): 919 items = [(value or None, attr) for value, attr in d[name]] 920 if all: 921 return items 922 elif len(items) == 1: 923 return items[0] 924 else: 925 return None 926 else: 927 return None 928 929 def get_item(d, name): 930 return get_items(d, name, False) 931 932 def get_value_map(d, name): 933 934 """ 935 Return a dictionary for all items in 'd' having the given 'name'. The 936 dictionary will map values for the name to any attributes or qualifiers 937 that may have been present. 938 """ 939 940 items = get_items(d, name) 941 if items: 942 return dict(items) 943 else: 944 return {} 945 946 def values_from_items(items): 947 return map(lambda x: x[0], items) 948 949 def get_values(d, name, all=True): 950 if d.has_key(name): 951 items = d[name] 952 if not all and len(items) == 1: 953 return items[0][0] 954 else: 955 return values_from_items(items) 956 else: 957 return None 958 959 def get_value(d, name): 960 return get_values(d, name, False) 961 962 def get_date_value_items(d, name, tzid=None): 963 964 """ 965 Obtain items from 'd' having the given 'name', where a single item yields 966 potentially many values. Return a list of tuples of the form (value, 967 attributes) where the attributes have been given for the property in 'd'. 968 """ 969 970 items = get_items(d, name) 971 if items: 972 all_items = [] 973 for item in items: 974 values, attr = item 975 if not attr.has_key("TZID") and tzid: 976 attr["TZID"] = tzid 977 if not isinstance(values, list): 978 values = [values] 979 for value in values: 980 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr)) 981 return all_items 982 else: 983 return None 984 985 def get_date_value_item_periods(d, name, duration, tzid=None): 986 987 """ 988 Obtain items from 'd' having the given 'name', where a single item yields 989 potentially many values. The 'duration' must be provided to define the 990 length of periods having only a start datetime. Return a list of periods 991 corresponding to the property in 'd'. 992 """ 993 994 items = get_date_value_items(d, name, tzid) 995 if not items: 996 return items 997 998 periods = [] 999 1000 for value, attr in items: 1001 if isinstance(value, tuple): 1002 periods.append(RecurringPeriod(value[0], value[1], tzid, name, attr)) 1003 else: 1004 periods.append(RecurringPeriod(value, value + duration, tzid, name, attr)) 1005 1006 return periods 1007 1008 def get_period_values(d, name, tzid=None): 1009 1010 """ 1011 Return period values from 'd' for the given property 'name', using 'tzid' 1012 where specified to indicate the time zone. 1013 """ 1014 1015 values = [] 1016 for value, attr in get_items(d, name) or []: 1017 if not attr.has_key("TZID") and tzid: 1018 attr["TZID"] = tzid 1019 start, end = get_period(value, attr) 1020 values.append(Period(start, end, tzid=tzid)) 1021 return values 1022 1023 def get_utc_datetime(d, name, date_tzid=None): 1024 1025 """ 1026 Return the value provided by 'd' for 'name' as a datetime in the UTC zone 1027 or as a date, converting any date to a datetime if 'date_tzid' is specified. 1028 If no datetime or date is available, None is returned. 1029 """ 1030 1031 t = get_datetime_item(d, name) 1032 if not t: 1033 return None 1034 else: 1035 dt, attr = t 1036 return dt is not None and to_utc_datetime(dt, date_tzid) or None 1037 1038 def get_datetime_item(d, name): 1039 1040 """ 1041 Return the value provided by 'd' for 'name' as a datetime or as a date, 1042 together with the attributes describing it. Return None if no value exists 1043 for 'name' in 'd'. 1044 """ 1045 1046 t = get_item(d, name) 1047 if not t: 1048 return None 1049 else: 1050 value, attr = t 1051 dt = get_datetime(value, attr) 1052 tzid = get_datetime_tzid(dt) 1053 if tzid: 1054 attr["TZID"] = tzid 1055 return dt, attr 1056 1057 # Conversion functions. 1058 1059 def get_address_parts(values): 1060 1061 "Return name and address tuples for each of the given 'values'." 1062 1063 l = [] 1064 for name, address in values and email.utils.getaddresses(values) or []: 1065 if is_mailto_uri(name): 1066 name = name[7:] # strip "mailto:" 1067 l.append((name, address)) 1068 return l 1069 1070 def get_addresses(values): 1071 1072 """ 1073 Return only addresses from the given 'values' which may be of the form 1074 "Common Name <recipient@domain>", with the latter part being the address 1075 itself. 1076 """ 1077 1078 return [address for name, address in get_address_parts(values)] 1079 1080 def get_address(value): 1081 1082 "Return an e-mail address from the given 'value'." 1083 1084 if not value: return None 1085 return get_addresses([value])[0] 1086 1087 def get_verbose_address(value, attr=None): 1088 1089 """ 1090 Return a verbose e-mail address featuring any name from the given 'value' 1091 and any accompanying 'attr' dictionary. 1092 """ 1093 1094 l = get_address_parts([value]) 1095 if not l: 1096 return value 1097 name, address = l[0] 1098 if not name: 1099 name = attr and attr.get("CN") 1100 if name and address: 1101 return "%s <%s>" % (name, address) 1102 else: 1103 return address 1104 1105 def is_mailto_uri(value): 1106 1107 """ 1108 Return whether 'value' is a mailto: URI, with the protocol potentially being 1109 in upper case. 1110 """ 1111 1112 return value.lower().startswith("mailto:") 1113 1114 def get_uri(value): 1115 1116 "Return a URI for the given 'value'." 1117 1118 if not value: return None 1119 1120 # Normalise to "mailto:" or return other URI form. 1121 1122 return is_mailto_uri(value) and ("mailto:%s" % value[7:]) or \ 1123 ":" in value and value or \ 1124 "mailto:%s" % get_address(value) 1125 1126 def uri_parts(values): 1127 1128 "Return any common name plus the URI for each of the given 'values'." 1129 1130 return [(name, get_uri(address)) for name, address in get_address_parts(values)] 1131 1132 uri_value = get_uri 1133 1134 def uri_values(values): 1135 return values and map(get_uri, values) 1136 1137 def uri_dict(d): 1138 return dict([(get_uri(key), value) for key, value in d.items()]) 1139 1140 def uri_item(item): 1141 return get_uri(item[0]), item[1] 1142 1143 def uri_items(items): 1144 return items and [(get_uri(value), attr) for value, attr in items] 1145 1146 # Operations on structure data. 1147 1148 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, ignore_dtstamp): 1149 1150 """ 1151 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 1152 'new_dtstamp', and the 'ignore_dtstamp' indication, whether the object 1153 providing the new information is really newer than the object providing the 1154 old information. 1155 """ 1156 1157 have_sequence = old_sequence is not None and new_sequence is not None 1158 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 1159 1160 have_dtstamp = old_dtstamp and new_dtstamp 1161 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 1162 1163 is_old_sequence = have_sequence and ( 1164 int(new_sequence) < int(old_sequence) or 1165 is_same_sequence and is_old_dtstamp 1166 ) 1167 1168 return is_same_sequence and ignore_dtstamp or not is_old_sequence 1169 1170 def check_delegation(attendee_map, attendee, attendee_attr): 1171 1172 """ 1173 Using the 'attendee_map', check the attributes for the given 'attendee' 1174 provided as 'attendee_attr', following the delegation chain back to the 1175 delegators and forward again to yield the delegate identities in each 1176 case. Pictorially... 1177 1178 attendee -> DELEGATED-FROM -> delegator 1179 ? <- DELEGATED-TO <--- 1180 1181 Return whether 'attendee' was identified as a delegate by providing the 1182 identity of any delegators referencing the attendee. 1183 """ 1184 1185 delegators = [] 1186 1187 # The recipient should have a reference to the delegator. 1188 1189 delegated_from = attendee_attr and attendee_attr.get("DELEGATED-FROM") 1190 if delegated_from: 1191 1192 # Examine all delegators. 1193 1194 for delegator in delegated_from: 1195 delegator_attr = attendee_map.get(delegator) 1196 1197 # The delegator should have a reference to the recipient. 1198 1199 delegated_to = delegator_attr and delegator_attr.get("DELEGATED-TO") 1200 if delegated_to and attendee in delegated_to: 1201 delegators.append(delegator) 1202 1203 return delegators 1204 1205 def get_periods(obj, start=None, end=None, inclusive=False): 1206 1207 """ 1208 Return periods for the given object 'obj', employing the object's fallback 1209 time zone where no time zone information is available (for whole day events, 1210 for example), confining materialised periods to after the given 'start' 1211 datetime and before the given 'end' datetime. 1212 1213 If 'end' is omitted, only explicit recurrences and recurrences from 1214 explicitly-terminated rules will be returned. 1215 1216 If 'inclusive' is set to a true value, any period occurring at the 'end' 1217 will be included. 1218 """ 1219 1220 tzid = obj.get_tzid() 1221 rrule = obj.get_value("RRULE") 1222 1223 # Use localised datetimes. 1224 1225 main_period = obj.get_main_period() 1226 1227 if not rrule: 1228 rule_periods = iter([main_period]) 1229 1230 # Recurrence rules create multiple instances to be checked. 1231 # Conflicts may only be assessed within a period defined by policy 1232 # for the agent, with instances outside that period being considered 1233 # unchecked. 1234 1235 elif end or rule_has_end(rrule): 1236 1237 # Filter periods using a start point. The end will be handled in the 1238 # materialisation process. 1239 1240 rule_periods = ifilter(Period(start, None).wraps, 1241 RulePeriodCollection(rrule, main_period, tzid, 1242 end, inclusive)) 1243 else: 1244 rule_periods = iter([]) 1245 1246 # Add recurrence dates. 1247 1248 rdates = obj.get_date_value_item_periods("RDATE") 1249 if rdates: 1250 rdates.sort() 1251 1252 # Return a sorted list of the periods. 1253 1254 periods = MergingIterator([rule_periods, iter(rdates or [])]) 1255 1256 # Exclude exception dates. 1257 1258 exdates = set(obj.get_date_value_item_periods("EXDATE") or []) 1259 1260 return filter(lambda p, excluded=exdates: p not in excluded, periods) 1261 1262 def get_main_period(periods): 1263 1264 "Return the main period from 'periods' using origin information." 1265 1266 for p in periods: 1267 if p.origin == "DTSTART": 1268 return p 1269 return None 1270 1271 def get_recurrence_periods(periods): 1272 1273 "Return recurrence periods from 'periods' using origin information." 1274 1275 l = [] 1276 for p in periods: 1277 if p.origin != "DTSTART": 1278 l.append(p) 1279 return l 1280 1281 def get_sender_identities(mapping): 1282 1283 """ 1284 Return a mapping from actual senders to the identities for which they 1285 have provided data, extracting this information from the given 1286 'mapping'. The SENT-BY attribute provides sender information in preference 1287 to the property values given as the mapping keys. 1288 """ 1289 1290 senders = {} 1291 1292 for value, attr in mapping.items(): 1293 sent_by = attr.get("SENT-BY") 1294 if sent_by: 1295 sender = get_uri(sent_by) 1296 else: 1297 sender = value 1298 1299 if not senders.has_key(sender): 1300 senders[sender] = [] 1301 1302 senders[sender].append(value) 1303 1304 return senders 1305 1306 def get_window_end(tzid, days=100, start=None): 1307 1308 """ 1309 Return a datetime in the time zone indicated by 'tzid' marking the end of a 1310 window of the given number of 'days'. If 'start' is not indicated, the start 1311 of the window will be the current moment. 1312 """ 1313 1314 return to_timezone(start or datetime.now(), tzid) + timedelta(days) 1315 1316 def rule_has_end(rrule): 1317 1318 "Return whether 'rrule' defines an end." 1319 1320 parameters = rrule and get_parameters(rrule) 1321 return parameters and parameters.has_key("UNTIL") or parameters.has_key("COUNT") 1322 1323 def update_attendees_with_delegates(stored_attendees, attendees): 1324 1325 """ 1326 Update the 'stored_attendees' mapping with delegate information from the 1327 given 'attendees' mapping. 1328 """ 1329 1330 # Check for delegated attendees. 1331 1332 for attendee, attendee_attr in attendees.items(): 1333 1334 # Identify delegates and check the delegation using the updated 1335 # attendee information. 1336 1337 if not stored_attendees.has_key(attendee) and \ 1338 attendee_attr.has_key("DELEGATED-FROM") and \ 1339 check_delegation(stored_attendees, attendee, attendee_attr): 1340 1341 stored_attendees[attendee] = attendee_attr 1342 1343 # vim: tabstop=4 expandtab shiftwidth=4