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