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