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