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)) 206 207 if transp in (None, "OPAQUE"): 208 store.set_freebusy(attendee, freebusy) 209 210 def can_schedule(freebusy, periods, uid): 211 212 """ 213 Return whether the 'freebusy' list can accommodate the given 'periods' 214 employing the specified 'uid'. 215 """ 216 217 for conflict in have_conflict(freebusy, periods, True): 218 start, end, found_uid = conflict 219 if found_uid != uid: 220 return False 221 222 return True 223 224 # Handler mechanism objects. 225 226 def handle_itip_part(part, senders, recipients, handlers, messenger): 227 228 """ 229 Handle the given iTIP 'part' from the given 'senders' for the given 230 'recipients' using the given 'handlers' and information provided by the 231 given 'messenger'. Return a list of responses, each response being a tuple 232 of the form (is-outgoing, message-part). 233 """ 234 235 method = part.get_param("method") 236 237 # Decode the data and parse it. 238 239 f = StringIO(part.get_payload(decode=True)) 240 241 itip = parse_object(f, part.get_content_charset(), "VCALENDAR") 242 243 # Ignore the part if not a calendar object. 244 245 if not itip: 246 return [] 247 248 # Require consistency between declared and employed methods. 249 250 if get_value(itip, "METHOD") == method: 251 252 # Look for different kinds of sections. 253 254 all_results = [] 255 256 for name, cls in handlers: 257 for details in get_values(itip, name) or []: 258 259 # Dispatch to a handler and obtain any response. 260 261 handler = cls(details, senders, recipients, messenger) 262 result = methods[method](handler)() 263 264 # Aggregate responses for a single message. 265 266 if result: 267 response_method, part = result 268 outgoing = method != response_method 269 all_results.append((outgoing, part)) 270 271 return all_results 272 273 return [] 274 275 def parse_object(f, encoding, objtype=None): 276 277 """ 278 Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is 279 given, only objects of that type will be returned. Otherwise, the root of 280 the content will be returned as a dictionary with a single key indicating 281 the object type. 282 283 Return None if the content was not readable or suitable. 284 """ 285 286 try: 287 try: 288 doctype, attrs, elements = obj = parse(f, encoding=encoding) 289 if objtype and doctype == objtype: 290 return to_dict(obj)[objtype][0] 291 elif not objtype: 292 return to_dict(obj) 293 finally: 294 f.close() 295 296 # NOTE: Handle parse errors properly. 297 298 except (ParseError, ValueError): 299 pass 300 301 return None 302 303 def to_part(method, calendar): 304 305 """ 306 Write using the given 'method', the 'calendar' details to a MIME 307 text/calendar part. 308 """ 309 310 encoding = "utf-8" 311 out = StringIO() 312 try: 313 imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding) 314 part = MIMEText(out.getvalue(), "calendar", encoding) 315 part.set_param("method", method) 316 return part 317 318 finally: 319 out.close() 320 321 class Handler: 322 323 "General handler support." 324 325 def __init__(self, details, senders=None, recipients=None, messenger=None): 326 327 """ 328 Initialise the handler with the 'details' of a calendar object and the 329 'senders' and 'recipients' of the object (if specifically indicated). 330 """ 331 332 self.details = details 333 self.senders = senders and set(senders) 334 self.recipients = recipients and set(recipients) 335 self.messenger = messenger 336 337 self.uid = get_value(details, "UID") 338 self.sequence = get_value(details, "SEQUENCE") 339 self.dtstamp = get_value(details, "DTSTAMP") 340 341 self.store = imip_store.FileStore() 342 343 try: 344 self.publisher = imip_store.FilePublisher() 345 except OSError: 346 self.publisher = None 347 348 # Access to calendar structures and other data. 349 350 def get_items(self, name, all=True): 351 return get_items(self.details, name, all) 352 353 def get_item(self, name): 354 return get_item(self.details, name) 355 356 def get_value_map(self, name): 357 return get_value_map(self.details, name) 358 359 def get_values(self, name, all=True): 360 return get_values(self.details, name, all) 361 362 def get_value(self, name): 363 return get_value(self.details, name) 364 365 def get_utc_datetime(self, name): 366 return get_utc_datetime(self.details, name) 367 368 def get_periods(self): 369 return get_periods(self.details) 370 371 def update_freebusy(self, freebusy, attendee, periods): 372 return update_freebusy(freebusy, attendee, periods, self.get_value("TRANSP"), self.uid, self.store) 373 374 def can_schedule(self, freebusy, periods): 375 return can_schedule(freebusy, periods, self.uid) 376 377 def filter_by_senders(self, values): 378 addresses = map(get_address, values) 379 if self.senders: 380 return self.senders.intersection(addresses) 381 else: 382 return addresses 383 384 def filter_by_recipients(self, values): 385 addresses = map(get_address, values) 386 if self.recipients: 387 return self.recipients.intersection(addresses) 388 else: 389 return addresses 390 391 def require_organiser_and_attendees(self, from_organiser=True): 392 393 """ 394 Return the organiser and attendees for the current object, filtered by 395 the recipients of interest. Return None if no identities are eligible. 396 """ 397 398 attendee_map = self.get_value_map("ATTENDEE") 399 organiser = self.get_item("ORGANIZER") 400 401 # Only provide details for recipients who are also attendees. 402 403 filter_fn = from_organiser and self.filter_by_recipients or self.filter_by_senders 404 405 attendees = {} 406 for attendee in map(get_uri, filter_fn(attendee_map)): 407 attendees[attendee] = attendee_map[attendee] 408 409 if not attendees or not organiser: 410 return None 411 412 return organiser, attendees 413 414 def validate_identities(self, items): 415 416 """ 417 Validate the 'items' against the known senders, obtaining sent-by 418 addresses from attributes provided by the items. 419 """ 420 421 # Reject organisers that do not match any senders. 422 423 identities = [] 424 425 for value, attr in items: 426 identities.append(value) 427 sent_by = attr.get("SENT-BY") 428 if sent_by: 429 identities.append(sent_by) 430 431 return self.filter_by_senders(identities) 432 433 def get_object(self, user, objtype): 434 435 """ 436 Return the stored object to which the current object refers for the 437 given 'user' and for the given 'objtype'. 438 """ 439 440 f = self.store.get_event(user, self.uid) 441 obj = f and parse_object(f, "utf-8", objtype) 442 return obj 443 444 def have_new_object(self, attendee, objtype, obj=None): 445 446 """ 447 Return whether the current object is new to the 'attendee' for the 448 given 'objtype'. 449 """ 450 451 obj = obj or self.get_object(attendee, objtype) 452 453 # If found, compare SEQUENCE and potentially DTSTAMP. 454 455 if obj: 456 sequence = get_value(obj, "SEQUENCE") 457 dtstamp = get_value(obj, "DTSTAMP") 458 459 # If the request refers to an older version of the object, ignore 460 # it. 461 462 old_dtstamp = self.dtstamp < dtstamp 463 464 if sequence is not None and ( 465 int(self.sequence) < int(sequence) or 466 int(self.sequence) == int(sequence) and old_dtstamp 467 ) or old_dtstamp: 468 469 return False 470 471 return True 472 473 # Handler registry. 474 475 methods = { 476 "ADD" : lambda handler: handler.add, 477 "CANCEL" : lambda handler: handler.cancel, 478 "COUNTER" : lambda handler: handler.counter, 479 "DECLINECOUNTER" : lambda handler: handler.declinecounter, 480 "PUBLISH" : lambda handler: handler.publish, 481 "REFRESH" : lambda handler: handler.refresh, 482 "REPLY" : lambda handler: handler.reply, 483 "REQUEST" : lambda handler: handler.request, 484 } 485 486 # vim: tabstop=4 expandtab shiftwidth=4