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