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