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