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