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