1 #!/usr/bin/env python 2 3 """ 4 Interpretation and preparation of iMIP content, together with a content handling 5 mechanism employed by specific recipients. 6 """ 7 8 from datetime import date, datetime, timedelta 9 from email.mime.text import MIMEText 10 from pytz import timezone, UnknownTimeZoneError 11 from vCalendar import parse, ParseError, to_dict 12 from vRecurrence import get_parameters, get_rule 13 import imip_store 14 import re 15 16 try: 17 from cStringIO import StringIO 18 except ImportError: 19 from StringIO import StringIO 20 21 # iCalendar date and datetime parsing (from DateSupport in MoinSupport). 22 23 date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})' 24 datetime_icalendar_regexp_str = date_icalendar_regexp_str + \ 25 ur'(?:' \ 26 ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \ 27 ur'(?P<utc>Z)?' \ 28 ur')?' 29 30 match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match 31 match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match 32 33 # Content interpretation. 34 35 def get_items(d, name, all=True): 36 37 """ 38 Get all items from 'd' with the given 'name', returning single items if 39 'all' is specified and set to a false value and if only one value is 40 present for the name. Return None if no items are found for the name. 41 """ 42 43 if d.has_key(name): 44 values = d[name] 45 if not all and len(values) == 1: 46 return values[0] 47 else: 48 return values 49 else: 50 return None 51 52 def get_item(d, name): 53 return get_items(d, name, False) 54 55 def get_value_map(d, name): 56 57 """ 58 Return a dictionary for all items in 'd' having the given 'name'. The 59 dictionary will map values for the name to any attributes or qualifiers 60 that may have been present. 61 """ 62 63 items = get_items(d, name) 64 if items: 65 return dict(items) 66 else: 67 return {} 68 69 def get_values(d, name, all=True): 70 if d.has_key(name): 71 values = d[name] 72 if not all and len(values) == 1: 73 return values[0][0] 74 else: 75 return map(lambda x: x[0], values) 76 else: 77 return None 78 79 def get_value(d, name): 80 return get_values(d, name, False) 81 82 def get_utc_datetime(d, name): 83 value, attr = get_item(d, name) 84 dt = get_datetime(value, attr) 85 return to_utc_datetime(dt) 86 87 def to_utc_datetime(dt): 88 if not dt: 89 return None 90 elif isinstance(dt, datetime): 91 return dt.astimezone(timezone("UTC")) 92 else: 93 return dt 94 95 def to_timezone(dt, name): 96 try: 97 tz = name and timezone(name) or None 98 except UnknownTimeZoneError: 99 tz = None 100 if tz is not None: 101 if not dt.tzinfo: 102 return tz.localize(dt) 103 else: 104 return dt.astimezone(tz) 105 else: 106 return dt 107 108 def format_datetime(dt): 109 if not dt: 110 return None 111 elif isinstance(dt, datetime): 112 if dt.tzname() == "UTC": 113 return dt.strftime("%Y%m%dT%H%M%SZ") 114 else: 115 return dt.strftime("%Y%m%dT%H%M%S") 116 else: 117 return dt.strftime("%Y%m%d") 118 119 def get_address(value): 120 return value.startswith("mailto:") and value[7:] or value 121 122 def get_uri(value): 123 return value.startswith("mailto:") and value or "mailto:%s" % value 124 125 def get_datetime(value, attr=None): 126 127 """ 128 Return a datetime object from the given 'value' in iCalendar format, using 129 the 'attr' mapping (if specified) to control the conversion. 130 """ 131 132 if not attr or attr.get("VALUE") in (None, "DATE-TIME"): 133 m = match_datetime_icalendar(value) 134 if m: 135 dt = datetime( 136 int(m.group("year")), int(m.group("month")), int(m.group("day")), 137 int(m.group("hour")), int(m.group("minute")), int(m.group("second")) 138 ) 139 140 # Impose the indicated timezone. 141 # NOTE: This needs an ambiguity policy for DST changes. 142 143 return to_timezone(dt, m.group("utc") and "UTC" or attr and attr.get("TZID") or None) 144 145 if not attr or attr.get("VALUE") == "DATE": 146 m = match_date_icalendar(value) 147 if m: 148 return date( 149 int(m.group("year")), int(m.group("month")), int(m.group("day")) 150 ) 151 return None 152 153 # NOTE: Need to expose the 100 day window for recurring events in the 154 # NOTE: configuration. 155 156 def get_periods(obj, window_size=100): 157 158 dtstart = get_utc_datetime(obj, "DTSTART") 159 dtend = get_utc_datetime(obj, "DTEND") 160 161 # NOTE: Need also DURATION support. 162 163 duration = dtend - dtstart 164 165 # Recurrence rules create multiple instances to be checked. 166 # Conflicts may only be assessed within a period defined by policy 167 # for the agent, with instances outside that period being considered 168 # unchecked. 169 170 window_end = datetime.now() + timedelta(window_size) 171 172 # NOTE: Need also RDATE and EXDATE support. 173 174 rrule = get_value(obj, "RRULE") 175 176 if rrule: 177 selector = get_rule(dtstart, rrule) 178 parameters = get_parameters(rrule) 179 periods = [] 180 for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")): 181 start = datetime(*start, tzinfo=timezone("UTC")) 182 end = start + duration 183 periods.append((format_datetime(start), format_datetime(end))) 184 else: 185 periods = [(format_datetime(dtstart), format_datetime(dtend))] 186 187 return periods 188 189 # Handler mechanism objects. 190 191 def handle_itip_part(part, recipients, handlers): 192 193 """ 194 Handle the given iTIP 'part' for the given 'recipients' using the given 195 'handlers'. Return a list of responses, each response being a tuple of the 196 form (is-outgoing, message-part). 197 """ 198 199 method = part.get_param("method") 200 201 # Decode the data and parse it. 202 203 f = StringIO(part.get_payload(decode=True)) 204 205 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 206 207 # Ignore the part if not a calendar object. 208 209 if not itip: 210 return [] 211 212 # Require consistency between declared and employed methods. 213 214 if get_value(itip, "METHOD") == method: 215 216 # Look for different kinds of sections. 217 218 all_results = [] 219 220 for name, cls in handlers: 221 for details in get_values(itip, name) or []: 222 223 # Dispatch to a handler and obtain any response. 224 225 handler = cls(details, recipients) 226 result = methods[method](handler)() 227 228 # Concatenate responses for a single calendar object. 229 230 if result: 231 response_method, part = result 232 outgoing = method != response_method 233 all_results.append((outgoing, part)) 234 235 return all_results 236 237 return [] 238 239 def parse_object(f, encoding, objtype=None): 240 241 """ 242 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 243 given, only objects of that type will be returned. 244 245 Return None if the content was not readable or suitable. 246 """ 247 248 try: 249 try: 250 doctype, attrs, elements = obj = parse(f, encoding=encoding) 251 if objtype and doctype == objtype: 252 return to_dict(obj)[objtype][0] 253 elif not objtype: 254 return to_dict(obj)[doctype][0] 255 finally: 256 f.close() 257 except (ParseError, ValueError): 258 pass 259 260 return None 261 262 def to_part(method, calendar): 263 264 """ 265 Write using the given 'method', the 'calendar' details to a MIME 266 text/calendar part. 267 """ 268 269 encoding = "utf-8" 270 out = StringIO() 271 try: 272 imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding) 273 part = MIMEText(out.getvalue(), "calendar", encoding) 274 part.set_param("method", method) 275 return part 276 277 finally: 278 out.close() 279 280 class Handler: 281 282 "General handler support." 283 284 def __init__(self, details, recipients): 285 286 """ 287 Initialise the handler with the 'details' of a calendar object and the 288 'recipients' of the object. 289 """ 290 291 self.details = details 292 self.recipients = set(recipients) 293 294 self.uid = get_value(details, "UID") 295 self.sequence = get_value(details, "SEQUENCE") 296 self.dtstamp = get_value(details, "DTSTAMP") 297 298 self.store = imip_store.FileStore() 299 300 try: 301 self.publisher = imip_store.FilePublisher() 302 except OSError: 303 self.publisher = None 304 305 # Access to calendar structures and other data. 306 307 def get_items(self, name, all=True): 308 return get_items(self.details, name, all) 309 310 def get_item(self, name): 311 return get_item(self.details, name) 312 313 def get_value_map(self, name): 314 return get_value_map(self.details, name) 315 316 def get_values(self, name, all=True): 317 return get_values(self.details, name, all) 318 319 def get_value(self, name): 320 return get_value(self.details, name) 321 322 def get_utc_datetime(self, name): 323 return get_utc_datetime(self.details, name) 324 325 def get_periods(self): 326 return get_periods(self.details) 327 328 def filter_by_recipients(self, values): 329 return self.recipients.intersection(map(get_address, values)) 330 331 def require_organiser_and_attendees(self): 332 333 """ 334 Return the organiser and attendees for the current object, filtered by 335 the recipients of interest. Return None if no identities are eligible. 336 """ 337 338 attendee_map = self.get_value_map("ATTENDEE") 339 organiser = self.get_item("ORGANIZER") 340 341 # Only provide details for recipients who are also attendees. 342 343 attendees = {} 344 for attendee in map(get_uri, self.filter_by_recipients(attendee_map)): 345 attendees[attendee] = attendee_map[attendee] 346 347 if not attendees and not organiser: 348 return None 349 350 return organiser, attendees 351 352 def have_new_object(self, attendee, objtype): 353 354 """ 355 Return whether the current object is new to the 'attendee' for the 356 given 'objtype'. 357 """ 358 359 f = self.store.get_event(attendee, self.uid) 360 event = f and parse_object(f, "utf-8", objtype) 361 362 # If found, compare SEQUENCE and potentially DTSTAMP. 363 364 if event: 365 sequence = get_value(event, "SEQUENCE") 366 dtstamp = get_value(event, "DTSTAMP") 367 368 # If the request refers to an older version of the event, ignore 369 # it. 370 371 old_dtstamp = self.dtstamp < dtstamp 372 373 if sequence is not None and ( 374 int(self.sequence) < int(sequence) or 375 int(self.sequence) == int(sequence) and old_dtstamp 376 ) or old_dtstamp: 377 378 return False 379 380 return True 381 382 # Handler registry. 383 384 methods = { 385 "ADD" : lambda handler: handler.add, 386 "CANCEL" : lambda handler: handler.cancel, 387 "COUNTER" : lambda handler: handler.counter, 388 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 389 "PUBLISH" : lambda handler: handler.publish, 390 "REFRESH" : lambda handler: handler.refresh, 391 "REPLY" : lambda handler: handler.reply, 392 "REQUEST" : lambda handler: handler.request, 393 } 394 395 # vim: tabstop=4 expandtab shiftwidth=4