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