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