1 #!/usr/bin/env python 2 3 """ 4 Interpretation of vCalendar content. 5 6 Copyright (C) 2014, 2015 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 datetime, timedelta 24 from email.mime.text import MIMEText 25 from imiptools.dates import format_datetime, get_datetime, get_duration, \ 26 get_freebusy_period, get_period, to_datetime, \ 27 to_timezone, to_utc_datetime 28 from imiptools.period import Period, period_overlaps 29 from pytz import timezone 30 from vCalendar import iterwrite, parse, ParseError, to_dict, to_node 31 from vRecurrence import get_parameters, get_rule 32 import email.utils 33 34 try: 35 from cStringIO import StringIO 36 except ImportError: 37 from StringIO import StringIO 38 39 class Object: 40 41 "Access to calendar structures." 42 43 def __init__(self, fragment): 44 self.objtype, (self.details, self.attr) = fragment.items()[0] 45 46 def copy(self): 47 return Object(to_dict(self.to_node())) 48 49 def get_items(self, name, all=True): 50 return get_items(self.details, name, all) 51 52 def get_item(self, name): 53 return get_item(self.details, name) 54 55 def get_value_map(self, name): 56 return get_value_map(self.details, name) 57 58 def get_values(self, name, all=True): 59 return get_values(self.details, name, all) 60 61 def get_value(self, name): 62 return get_value(self.details, name) 63 64 def get_utc_datetime(self, name, date_tzid=None): 65 return get_utc_datetime(self.details, name, date_tzid) 66 67 def get_date_values(self, name, tzid=None): 68 items = get_date_value_items(self.details, name, tzid) 69 return items and [value for value, attr in items] 70 71 def get_date_value_items(self, name, tzid=None): 72 return get_date_value_items(self.details, name, tzid) 73 74 def get_datetime(self, name): 75 dt, attr = get_datetime_item(self.details, name) 76 return dt 77 78 def get_datetime_item(self, name): 79 return get_datetime_item(self.details, name) 80 81 def get_duration(self, name): 82 return get_duration(self.get_value(name)) 83 84 def to_node(self): 85 return to_node({self.objtype : [(self.details, self.attr)]}) 86 87 def to_part(self, method): 88 return to_part(method, [self.to_node()]) 89 90 # Direct access to the structure. 91 92 def has_key(self, name): 93 return self.details.has_key(name) 94 95 def get(self, name): 96 return self.details.get(name) 97 98 def __getitem__(self, name): 99 return self.details[name] 100 101 def __setitem__(self, name, value): 102 self.details[name] = value 103 104 def __delitem__(self, name): 105 del self.details[name] 106 107 def remove(self, name): 108 try: 109 del self[name] 110 except KeyError: 111 pass 112 113 def remove_all(self, names): 114 for name in names: 115 self.remove(name) 116 117 # Computed results. 118 119 def has_recurrence(self, tzid, recurrence): 120 recurrences = [p.start for p in get_periods(self, tzid, recurrence, inclusive=True)] 121 return recurrence in recurrences 122 123 def get_periods(self, tzid, end): 124 return get_periods(self, tzid, end) 125 126 def get_periods_for_freebusy(self, tzid, end): 127 periods = self.get_periods(tzid, end) 128 return get_periods_for_freebusy(self, periods, tzid) 129 130 def get_tzid(self): 131 dtstart, dtstart_attr = self.get_datetime_item("DTSTART") 132 dtend, dtend_attr = self.get_datetime_item("DTEND") 133 return get_tzid(dtstart_attr, dtend_attr) 134 135 # Construction and serialisation. 136 137 def make_calendar(nodes, method=None): 138 139 """ 140 Return a complete calendar node wrapping the given 'nodes' and employing the 141 given 'method', if indicated. 142 """ 143 144 return ("VCALENDAR", {}, 145 (method and [("METHOD", {}, method)] or []) + 146 [("VERSION", {}, "2.0")] + 147 nodes 148 ) 149 150 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None, 151 attendee_attr=None, dtstart=None, dtend=None): 152 153 """ 154 Return a calendar node defining the free/busy details described in the given 155 'freebusy' list, employing the given 'uid', for the given 'organiser' and 156 optional 'organiser_attr', with the optional 'attendee' providing recipient 157 details together with the optional 'attendee_attr'. 158 159 The result will be constrained to the 'dtstart' and 'dtend' period if these 160 parameters are given. 161 """ 162 163 record = [] 164 rwrite = record.append 165 166 rwrite(("ORGANIZER", organiser_attr or {}, organiser)) 167 168 if attendee: 169 rwrite(("ATTENDEE", attendee_attr or {}, attendee)) 170 171 rwrite(("UID", {}, uid)) 172 173 if freebusy: 174 175 # Get a constrained view if start and end limits are specified. 176 177 periods = dtstart and dtend and \ 178 period_overlaps(freebusy, Period(dtstart, dtend), True) or \ 179 freebusy 180 181 # Write the limits of the resource. 182 183 rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, periods[0].start)) 184 rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, periods[-1].end)) 185 186 for p in periods: 187 if p.transp == "OPAQUE": 188 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join([p.start, p.end]))) 189 190 return ("VFREEBUSY", {}, record) 191 192 def parse_object(f, encoding, objtype=None): 193 194 """ 195 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 196 given, only objects of that type will be returned. Otherwise, the root of 197 the content will be returned as a dictionary with a single key indicating 198 the object type. 199 200 Return None if the content was not readable or suitable. 201 """ 202 203 try: 204 try: 205 doctype, attrs, elements = obj = parse(f, encoding=encoding) 206 if objtype and doctype == objtype: 207 return to_dict(obj)[objtype][0] 208 elif not objtype: 209 return to_dict(obj) 210 finally: 211 f.close() 212 213 # NOTE: Handle parse errors properly. 214 215 except (ParseError, ValueError): 216 pass 217 218 return None 219 220 def to_part(method, calendar): 221 222 """ 223 Write using the given 'method', the 'calendar' details to a MIME 224 text/calendar part. 225 """ 226 227 encoding = "utf-8" 228 out = StringIO() 229 try: 230 to_stream(out, make_calendar(calendar, method), encoding) 231 part = MIMEText(out.getvalue(), "calendar", encoding) 232 part.set_param("method", method) 233 return part 234 235 finally: 236 out.close() 237 238 def to_stream(out, fragment, encoding="utf-8"): 239 iterwrite(out, encoding=encoding).append(fragment) 240 241 # Structure access functions. 242 243 def get_items(d, name, all=True): 244 245 """ 246 Get all items from 'd' for the given 'name', returning single items if 247 'all' is specified and set to a false value and if only one value is 248 present for the name. Return None if no items are found for the name or if 249 many items are found but 'all' is set to a false value. 250 """ 251 252 if d.has_key(name): 253 items = d[name] 254 if all: 255 return items 256 elif len(items) == 1: 257 return items[0] 258 else: 259 return None 260 else: 261 return None 262 263 def get_item(d, name): 264 return get_items(d, name, False) 265 266 def get_value_map(d, name): 267 268 """ 269 Return a dictionary for all items in 'd' having the given 'name'. The 270 dictionary will map values for the name to any attributes or qualifiers 271 that may have been present. 272 """ 273 274 items = get_items(d, name) 275 if items: 276 return dict(items) 277 else: 278 return {} 279 280 def values_from_items(items): 281 return map(lambda x: x[0], items) 282 283 def get_values(d, name, all=True): 284 if d.has_key(name): 285 items = d[name] 286 if not all and len(items) == 1: 287 return items[0][0] 288 else: 289 return values_from_items(items) 290 else: 291 return None 292 293 def get_value(d, name): 294 return get_values(d, name, False) 295 296 def get_date_value_items(d, name, tzid=None): 297 298 """ 299 Obtain items from 'd' having the given 'name', where a single item yields 300 potentially many values. Return a list of tuples of the form (value, 301 attributes) where the attributes have been given for the property in 'd'. 302 """ 303 304 items = get_items(d, name) 305 if items: 306 all_items = [] 307 for item in items: 308 values, attr = item 309 if not attr.has_key("TZID") and tzid: 310 attr["TZID"] = tzid 311 if not isinstance(values, list): 312 values = [values] 313 for value in values: 314 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr)) 315 return all_items 316 else: 317 return None 318 319 def get_utc_datetime(d, name, date_tzid=None): 320 321 """ 322 Return the value provided by 'd' for 'name' as a datetime in the UTC zone 323 or as a date, converting any date to a datetime if 'date_tzid' is specified. 324 """ 325 326 t = get_datetime_item(d, name) 327 if not t: 328 return None 329 else: 330 dt, attr = t 331 return to_utc_datetime(dt, date_tzid) 332 333 def get_datetime_item(d, name): 334 t = get_item(d, name) 335 if not t: 336 return None 337 else: 338 value, attr = t 339 return get_datetime(value, attr), attr 340 341 # Conversion functions. 342 343 def get_addresses(values): 344 return [address for name, address in email.utils.getaddresses(values)] 345 346 def get_address(value): 347 value = value.lower() 348 return value.startswith("mailto:") and value[7:] or value 349 350 def get_uri(value): 351 return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower() 352 353 uri_value = get_uri 354 355 def uri_values(values): 356 return map(get_uri, values) 357 358 def uri_dict(d): 359 return dict([(get_uri(key), value) for key, value in d.items()]) 360 361 def uri_item(item): 362 return get_uri(item[0]), item[1] 363 364 def uri_items(items): 365 return [(get_uri(value), attr) for value, attr in items] 366 367 # Operations on structure data. 368 369 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set): 370 371 """ 372 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 373 'new_dtstamp', and the 'partstat_set' indication, whether the object 374 providing the new information is really newer than the object providing the 375 old information. 376 """ 377 378 have_sequence = old_sequence is not None and new_sequence is not None 379 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 380 381 have_dtstamp = old_dtstamp and new_dtstamp 382 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 383 384 is_old_sequence = have_sequence and ( 385 int(new_sequence) < int(old_sequence) or 386 is_same_sequence and is_old_dtstamp 387 ) 388 389 return is_same_sequence and partstat_set or not is_old_sequence 390 391 # NOTE: Need to expose the 100 day window for recurring events in the 392 # NOTE: configuration. 393 394 def get_window_end(tzid, window_size=100): 395 return to_timezone(datetime.now(), tzid) + timedelta(window_size) 396 397 def get_tzid(dtstart_attr, dtend_attr): 398 return dtstart_attr.get("TZID") or dtend_attr.get("TZID") 399 400 class RecurringPeriod(Period): 401 402 "A period with origin information from the object." 403 404 def __init__(self, start, end, origin, start_attr=None, end_attr=None): 405 Period.__init__(self, start, end, origin) 406 self.start_attr = start_attr 407 self.end_attr = end_attr 408 409 def get_start_item(self): 410 return self.start, self.start_attr 411 412 def get_end_item(self): 413 return self.end, self.end_attr 414 415 def get_tzid(self): 416 return get_tzid(self.start_attr, self.end_attr) 417 418 def as_tuple(self): 419 return self.start, self.end, self.origin, self.start_attr, self.end_attr 420 421 def __repr__(self): 422 return "RecurringPeriod(%r, %r, %r, %r, %r)" % (self.start, self.end, self.origin, self.start_attr, self.end_attr) 423 424 def get_periods(obj, tzid, window_end, inclusive=False): 425 426 """ 427 Return periods for the given object 'obj', confining materialised periods 428 to before the given 'window_end' datetime. If 'inclusive' is set to a true 429 value, any period occurring at the 'window_end' will be included. 430 """ 431 432 rrule = obj.get_value("RRULE") 433 434 # Use localised datetimes. 435 436 dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") 437 438 if obj.has_key("DTEND"): 439 dtend, dtend_attr = obj.get_datetime_item("DTEND") 440 duration = dtend - dtstart 441 elif obj.has_key("DURATION"): 442 duration = obj.get_duration("DURATION") 443 dtend = dtstart + duration 444 dtend_attr = dtstart_attr 445 else: 446 dtend, dtend_attr = dtstart, dtstart_attr 447 448 tzid = get_tzid(dtstart_attr, dtend_attr) or tzid 449 450 if not rrule: 451 periods = [RecurringPeriod(dtstart, dtend, "DTSTART", dtstart_attr, dtend_attr)] 452 else: 453 # Recurrence rules create multiple instances to be checked. 454 # Conflicts may only be assessed within a period defined by policy 455 # for the agent, with instances outside that period being considered 456 # unchecked. 457 458 selector = get_rule(dtstart, rrule) 459 parameters = get_parameters(rrule) 460 periods = [] 461 462 until = parameters.get("UNTIL") 463 if until: 464 window_end = min(to_timezone(get_datetime(until, dtstart_attr), tzid), window_end) 465 inclusive = True 466 467 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive): 468 start = to_timezone(datetime(*start), tzid) 469 end = start + duration 470 periods.append(RecurringPeriod(start, end, "RRULE")) 471 472 # Add recurrence dates. 473 474 rdates = obj.get_date_value_items("RDATE", tzid) 475 476 if rdates: 477 for rdate, rdate_attr in rdates: 478 if isinstance(rdate, tuple): 479 periods.append(RecurringPeriod(rdate[0], rdate[1], "RDATE", rdate_attr)) 480 else: 481 periods.append(RecurringPeriod(rdate, rdate + duration, "RDATE", rdate_attr)) 482 483 # Return a sorted list of the periods. 484 485 periods.sort(cmp=compare_periods(tzid)) 486 487 # Exclude exception dates. 488 489 exdates = obj.get_date_values("EXDATE", tzid) 490 491 if exdates: 492 for exdate in exdates: 493 if isinstance(exdate, tuple): 494 period = Period(exdate[0], exdate[1]) 495 else: 496 period = Period(exdate, exdate + duration) 497 i = bisect_left(periods, period) 498 while i < len(periods) and periods[i] == period: 499 del periods[i] 500 501 return periods 502 503 class compare_periods: 504 505 "Compare periods for exception date purposes." 506 507 def __init__(self, tzid): 508 self.tzid = tzid 509 510 def __call__(self, first, second): 511 return cmp( 512 (to_datetime(first.start, self.tzid), to_datetime(first.end, self.tzid)), 513 (to_datetime(second.start, self.tzid), to_datetime(second.end, self.tzid)) 514 ) 515 516 def get_periods_for_freebusy(obj, periods, tzid): 517 518 """ 519 Get free/busy-compliant periods employed by 'obj' from the given 'periods', 520 using the indicated 'tzid' to convert dates to datetimes. 521 """ 522 523 start, start_attr = obj.get_datetime_item("DTSTART") 524 if obj.has_key("DTEND"): 525 end, end_attr = obj.get_datetime_item("DTEND") 526 elif obj.has_key("DURATION"): 527 duration = obj.get_duration("DURATION") 528 end = start + duration 529 else: 530 end, end_attr = start, start_attr 531 532 tzid = get_tzid(start_attr, end_attr) or tzid 533 534 l = [] 535 536 for p in periods: 537 start, end = get_freebusy_period(p.start, p.end, tzid) 538 start, end = [to_timezone(x, "UTC") for x in start, end] 539 540 # Create a new period for free/busy purposes with the converted 541 # datetime information. 542 543 l.append(p.__class__( 544 *((format_datetime(start), format_datetime(end)) + p.as_tuple()[2:]) 545 )) 546 547 return l 548 549 # vim: tabstop=4 expandtab shiftwidth=4