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