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