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