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