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 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 return value.lower().startswith("mailto:") and value.lower()[7:] or value 264 265 def get_uri(value): 266 return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower() 267 268 uri_value = get_uri 269 270 def uri_values(values): 271 return map(get_uri, values) 272 273 def uri_dict(d): 274 return dict([(get_uri(key), value) for key, value in d.items()]) 275 276 def uri_item(item): 277 return get_uri(item[0]), item[1] 278 279 def uri_items(items): 280 return [(get_uri(value), attr) for value, attr in items] 281 282 # Operations on structure data. 283 284 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set): 285 286 """ 287 Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and 288 'new_dtstamp', and the 'partstat_set' indication, whether the object 289 providing the new information is really newer than the object providing the 290 old information. 291 """ 292 293 have_sequence = old_sequence is not None and new_sequence is not None 294 is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence) 295 296 have_dtstamp = old_dtstamp and new_dtstamp 297 is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp 298 299 is_old_sequence = have_sequence and ( 300 int(new_sequence) < int(old_sequence) or 301 is_same_sequence and is_old_dtstamp 302 ) 303 304 return is_same_sequence and partstat_set or not is_old_sequence 305 306 # NOTE: Need to expose the 100 day window for recurring events in the 307 # NOTE: configuration. 308 309 def get_periods(obj, tzid, window_size=100): 310 311 """ 312 Return periods for the given object 'obj', confining materialised periods 313 to the given 'window_size' in days starting from the present moment. 314 """ 315 316 # NOTE: Need also RDATE and EXDATE support. 317 318 rrule = obj.get_value("RRULE") 319 320 if not rrule: 321 return [(obj.get_datetime("DTSTART"), obj.get_datetime("DTEND"))] 322 323 # Use localised datetimes. 324 325 dtstart, start_attr = obj.get_datetime_item("DTSTART") 326 dtend, end_attr = obj.get_datetime_item("DTEND") 327 328 tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid 329 330 # NOTE: Need also DURATION support. 331 332 duration = dtend - dtstart 333 334 # Recurrence rules create multiple instances to be checked. 335 # Conflicts may only be assessed within a period defined by policy 336 # for the agent, with instances outside that period being considered 337 # unchecked. 338 339 window_end = to_timezone(datetime.now(), tzid) + timedelta(window_size) 340 341 selector = get_rule(dtstart, rrule) 342 parameters = get_parameters(rrule) 343 periods = [] 344 345 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): 346 start = to_timezone(datetime(*start), tzid) 347 end = start + duration 348 periods.append((start, end)) 349 350 return periods 351 352 def get_periods_for_freebusy(obj, periods, tzid): 353 354 """ 355 Get free/busy-compliant periods employed by 'obj' from the given 'periods', 356 using the indicated 'tzid' to convert dates to datetimes. 357 """ 358 359 start, start_attr = obj.get_datetime_item("DTSTART") 360 end, end_attr = obj.get_datetime_item("DTEND") 361 362 tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid 363 364 l = [] 365 366 for start, end in periods: 367 start, end = get_freebusy_period(start, end, tzid) 368 start, end = [to_timezone(x, "UTC") for x in start, end] 369 l.append((format_datetime(start), format_datetime(end))) 370 371 return l 372 373 # vim: tabstop=4 expandtab shiftwidth=4