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