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