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