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