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