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