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