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