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