1.1 --- a/imip_agent.py Thu Oct 09 22:50:41 2014 +0200
1.2 +++ b/imip_agent.py Tue Oct 21 19:58:20 2014 +0200
1.3 @@ -1,23 +1,13 @@
1.4 #!/usr/bin/env python
1.5
1.6 -from bisect import bisect_left, insort_left
1.7 -from datetime import date, datetime, timedelta
1.8 from email import message_from_file
1.9 from email.mime.multipart import MIMEMultipart
1.10 from email.mime.text import MIMEText
1.11 -from pytz import timezone, UnknownTimeZoneError
1.12 from smtplib import SMTP
1.13 -from vCalendar import parse, ParseError, to_dict, to_node
1.14 -from vRecurrence import get_parameters, get_rule, to_tuple
1.15 -import imip_store
1.16 -import re
1.17 +from imiptools.content import handle_itip_part
1.18 +import imip_resource
1.19 import sys
1.20
1.21 -try:
1.22 - from cStringIO import StringIO
1.23 -except ImportError:
1.24 - from StringIO import StringIO
1.25 -
1.26 MESSAGE_SENDER = "resources+agent@example.com"
1.27
1.28 MESSAGE_SUBJECT = "Calendar system message"
1.29 @@ -37,134 +27,6 @@
1.30 "text/x-vcalendar", "application/ics", # other possibilities
1.31 ]
1.32
1.33 -# iCalendar date and datetime parsing (from DateSupport in MoinSupport).
1.34 -
1.35 -date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
1.36 -datetime_icalendar_regexp_str = date_icalendar_regexp_str + \
1.37 - ur'(?:' \
1.38 - ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \
1.39 - ur'(?P<utc>Z)?' \
1.40 - ur')?'
1.41 -
1.42 -match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match
1.43 -match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match
1.44 -
1.45 -# Content interpretation.
1.46 -
1.47 -def get_items(d, name, all=True):
1.48 - if d.has_key(name):
1.49 - values = d[name]
1.50 - if not all and len(values) == 1:
1.51 - return values[0]
1.52 - else:
1.53 - return values
1.54 - else:
1.55 - return None
1.56 -
1.57 -def get_item(d, name):
1.58 - return get_items(d, name, False)
1.59 -
1.60 -def get_value_map(d, name):
1.61 - items = get_items(d, name)
1.62 - if items:
1.63 - return dict(items)
1.64 - else:
1.65 - return {}
1.66 -
1.67 -def get_values(d, name, all=True):
1.68 - if d.has_key(name):
1.69 - values = d[name]
1.70 - if not all and len(values) == 1:
1.71 - return values[0][0]
1.72 - else:
1.73 - return map(lambda x: x[0], values)
1.74 - else:
1.75 - return None
1.76 -
1.77 -def get_value(d, name):
1.78 - return get_values(d, name, False)
1.79 -
1.80 -def get_utc_datetime(d, name):
1.81 - value, attr = get_item(d, name)
1.82 - dt = get_datetime(value, attr)
1.83 - return to_utc_datetime(dt)
1.84 -
1.85 -def to_utc_datetime(dt):
1.86 - if not dt:
1.87 - return None
1.88 - elif isinstance(dt, datetime):
1.89 - return dt.astimezone(timezone("UTC"))
1.90 - else:
1.91 - return dt
1.92 -
1.93 -def format_datetime(dt):
1.94 - if not dt:
1.95 - return None
1.96 - elif isinstance(dt, datetime):
1.97 - return dt.strftime("%Y%m%dT%H%M%SZ")
1.98 - else:
1.99 - return dt.strftime("%Y%m%d")
1.100 -
1.101 -def get_address(value):
1.102 - return value.startswith("mailto:") and value[7:] or value
1.103 -
1.104 -def get_uri(value):
1.105 - return value.startswith("mailto:") and value or "mailto:%s" % value
1.106 -
1.107 -def get_datetime(value, attr):
1.108 - try:
1.109 - tz = attr.has_key("TZID") and timezone(attr["TZID"]) or None
1.110 - except UnknownTimeZoneError:
1.111 - tz = None
1.112 -
1.113 - if attr.get("VALUE") in (None, "DATE-TIME"):
1.114 - m = match_datetime_icalendar(value)
1.115 - if m:
1.116 - dt = datetime(
1.117 - int(m.group("year")), int(m.group("month")), int(m.group("day")),
1.118 - int(m.group("hour")), int(m.group("minute")), int(m.group("second"))
1.119 - )
1.120 -
1.121 - # Impose the indicated timezone.
1.122 - # NOTE: This needs an ambiguity policy for DST changes.
1.123 -
1.124 - tz = m.group("utc") and timezone("UTC") or tz or None
1.125 - if tz is not None:
1.126 - return tz.localize(dt)
1.127 - else:
1.128 - return dt
1.129 -
1.130 - if attr.get("VALUE") == "DATE":
1.131 - m = match_date_icalendar(value)
1.132 - if m:
1.133 - return date(
1.134 - int(m.group("year")), int(m.group("month")), int(m.group("day"))
1.135 - )
1.136 - return None
1.137 -
1.138 -# Time management.
1.139 -
1.140 -def insert_period(freebusy, period):
1.141 - insort_left(freebusy, period)
1.142 -
1.143 -def remove_period(freebusy, uid):
1.144 - i = 0
1.145 - while i < len(freebusy):
1.146 - t = freebusy[i]
1.147 - if len(t) >= 3 and t[2] == uid:
1.148 - del freebusy[i]
1.149 - else:
1.150 - i += 1
1.151 -
1.152 -def period_overlaps(freebusy, period):
1.153 - dtstart, dtend = period[:2]
1.154 - i = bisect_left(freebusy, (dtstart, dtend, None))
1.155 - return (
1.156 - i < len(freebusy) and (dtend is None or freebusy[i][0] < dtend)
1.157 - or
1.158 - i > 0 and freebusy[i - 1][1] > dtstart
1.159 - )
1.160 -
1.161 # Sending of outgoing messages.
1.162
1.163 def sendmail(sender, recipients, data):
1.164 @@ -194,7 +56,9 @@
1.165 if part.get_content_type() in itip_content_types and \
1.166 part.get_param("method"):
1.167
1.168 - all_parts += handle_itip_part(part, original_recipients)
1.169 + # NOTE: Act on behalf of resources for now.
1.170 +
1.171 + all_parts += handle_itip_part(part, original_recipients, imip_resource.handlers)
1.172
1.173 # Pack the parts into a single message.
1.174
1.175 @@ -216,447 +80,10 @@
1.176
1.177 def get_all_values(msg, key):
1.178 l = []
1.179 - for v in msg.get_all(key):
1.180 + for v in msg.get_all(key) or []:
1.181 l += [s.strip() for s in v.split(",")]
1.182 return l
1.183
1.184 -def to_part(method, calendar):
1.185 -
1.186 - """
1.187 - Write using the given 'method', the 'calendar' details to a MIME
1.188 - text/calendar part.
1.189 - """
1.190 -
1.191 - encoding = "utf-8"
1.192 - out = StringIO()
1.193 - try:
1.194 - imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding)
1.195 - part = MIMEText(out.getvalue(), "calendar", encoding)
1.196 - part.set_param("method", method)
1.197 - return part
1.198 -
1.199 - finally:
1.200 - out.close()
1.201 -
1.202 -def parse_object(f, encoding, objtype):
1.203 -
1.204 - """
1.205 - Parse the iTIP content from 'f' having the given 'encoding'. Return None if
1.206 - the content was not readable or suitable.
1.207 - """
1.208 -
1.209 - try:
1.210 - try:
1.211 - doctype, attrs, elements = obj = parse(f, encoding=encoding)
1.212 - if doctype == objtype:
1.213 - return to_dict(obj)[objtype][0]
1.214 - finally:
1.215 - f.close()
1.216 - except (ParseError, ValueError):
1.217 - pass
1.218 -
1.219 - return None
1.220 -
1.221 -def handle_itip_part(part, recipients):
1.222 -
1.223 - "Handle the given iTIP 'part' for the given 'recipients'."
1.224 -
1.225 - method = part.get_param("method")
1.226 -
1.227 - # Decode the data and parse it.
1.228 -
1.229 - f = StringIO(part.get_payload(decode=True))
1.230 -
1.231 - itip = parse_object(f, part.get_content_charset(), "VCALENDAR")
1.232 -
1.233 - # Ignore the part if not a calendar object.
1.234 -
1.235 - if not itip:
1.236 - return []
1.237 -
1.238 - # Only handle calendar information.
1.239 -
1.240 - all_parts = []
1.241 -
1.242 - # Require consistency between declared and employed methods.
1.243 -
1.244 - if get_value(itip, "METHOD") == method:
1.245 -
1.246 - # Look for different kinds of sections.
1.247 -
1.248 - all_objects = []
1.249 -
1.250 - for name, cls in handlers:
1.251 - for details in get_values(itip, name) or []:
1.252 -
1.253 - # Dispatch to a handler and obtain any response.
1.254 -
1.255 - handler = cls(details, recipients)
1.256 - object = methods[method](handler)()
1.257 -
1.258 - # Concatenate responses for a single calendar object.
1.259 -
1.260 - if object:
1.261 - all_objects += object
1.262 -
1.263 - # Obtain a message part for the objects.
1.264 -
1.265 - if all_objects:
1.266 - all_parts.append(to_part(response_methods[method], all_objects))
1.267 -
1.268 - return all_parts
1.269 -
1.270 -class Handler:
1.271 -
1.272 - "General handler support."
1.273 -
1.274 - def __init__(self, details, recipients):
1.275 -
1.276 - """
1.277 - Initialise the handler with the 'details' of a calendar object and the
1.278 - 'recipients' of the object.
1.279 - """
1.280 -
1.281 - self.details = details
1.282 - self.recipients = set(recipients)
1.283 -
1.284 - self.uid = get_value(details, "UID")
1.285 - self.sequence = get_value(details, "SEQUENCE")
1.286 - self.dtstamp = get_value(details, "DTSTAMP")
1.287 -
1.288 - self.store = imip_store.FileStore()
1.289 -
1.290 - try:
1.291 - self.publisher = imip_store.FilePublisher()
1.292 - except OSError:
1.293 - self.publisher = None
1.294 -
1.295 - def get_items(self, name, all=True):
1.296 - return get_items(self.details, name, all)
1.297 -
1.298 - def get_item(self, name):
1.299 - return get_item(self.details, name)
1.300 -
1.301 - def get_value_map(self, name):
1.302 - return get_value_map(self.details, name)
1.303 -
1.304 - def get_values(self, name, all=True):
1.305 - return get_values(self.details, name, all)
1.306 -
1.307 - def get_value(self, name):
1.308 - return get_value(self.details, name)
1.309 -
1.310 - def get_utc_datetime(self, name):
1.311 - return get_utc_datetime(self.details, name)
1.312 -
1.313 - def filter_by_recipients(self, values):
1.314 - return self.recipients.intersection(map(get_address, values))
1.315 -
1.316 - def require_organiser_and_attendees(self):
1.317 - attendee_map = self.get_value_map("ATTENDEE")
1.318 - organiser = self.get_item("ORGANIZER")
1.319 -
1.320 - # Only provide details for recipients who are also attendees.
1.321 -
1.322 - attendees = {}
1.323 - for attendee in map(get_uri, self.filter_by_recipients(attendee_map)):
1.324 - attendees[attendee] = attendee_map[attendee]
1.325 -
1.326 - if not attendees and not organiser:
1.327 - return None
1.328 -
1.329 - return organiser, attendees
1.330 -
1.331 -class Event(Handler):
1.332 -
1.333 - "An event handler."
1.334 -
1.335 - def add(self):
1.336 - pass
1.337 -
1.338 - def cancel(self):
1.339 - pass
1.340 -
1.341 - def counter(self):
1.342 -
1.343 - "Since this handler does not send requests, it will not handle replies."
1.344 -
1.345 - pass
1.346 -
1.347 - def declinecounter(self):
1.348 -
1.349 - """
1.350 - Since this handler does not send counter proposals, it will not handle
1.351 - replies to such proposals.
1.352 - """
1.353 -
1.354 - pass
1.355 -
1.356 - def publish(self):
1.357 - pass
1.358 -
1.359 - def refresh(self):
1.360 - pass
1.361 -
1.362 - def reply(self):
1.363 -
1.364 - "Since this handler does not send requests, it will not handle replies."
1.365 -
1.366 - pass
1.367 -
1.368 - def request(self):
1.369 -
1.370 - """
1.371 - Respond to a request by preparing a reply containing accept/decline
1.372 - information for each indicated attendee.
1.373 -
1.374 - No support for countering requests is implemented.
1.375 - """
1.376 -
1.377 - oa = self.require_organiser_and_attendees()
1.378 - if not oa:
1.379 - return None
1.380 -
1.381 - (organiser, organiser_attr), attendees = oa
1.382 -
1.383 - # Process each attendee separately.
1.384 -
1.385 - calendar = []
1.386 -
1.387 - for attendee, attendee_attr in attendees.items():
1.388 -
1.389 - # Check for event using UID.
1.390 -
1.391 - f = self.store.get_event(attendee, self.uid)
1.392 - event = f and parse_object(f, "utf-8", "VEVENT")
1.393 -
1.394 - # If found, compare SEQUENCE and potentially DTSTAMP.
1.395 -
1.396 - if event:
1.397 - sequence = get_value(event, "SEQUENCE")
1.398 - dtstamp = get_value(event, "DTSTAMP")
1.399 -
1.400 - # If the request refers to an older version of the event, ignore
1.401 - # it.
1.402 -
1.403 - old_dtstamp = self.dtstamp < dtstamp
1.404 -
1.405 - if sequence is not None and (
1.406 - int(self.sequence) < int(sequence) or
1.407 - int(self.sequence) == int(sequence) and old_dtstamp
1.408 - ) or old_dtstamp:
1.409 -
1.410 - continue
1.411 -
1.412 - # If newer than any old version, discard old details from the
1.413 - # free/busy record and check for suitability.
1.414 -
1.415 - dtstart = self.get_utc_datetime("DTSTART")
1.416 - dtend = self.get_utc_datetime("DTEND")
1.417 -
1.418 - # NOTE: Need also DURATION support.
1.419 -
1.420 - duration = dtend - dtstart
1.421 -
1.422 - # Recurrence rules create multiple instances to be checked.
1.423 - # Conflicts may only be assessed within a period defined by policy
1.424 - # for the agent, with instances outside that period being considered
1.425 - # unchecked.
1.426 -
1.427 - window_end = datetime.now() + timedelta(100)
1.428 -
1.429 - # NOTE: Need also RDATE and EXDATE support.
1.430 -
1.431 - rrule = self.get_value("RRULE")
1.432 -
1.433 - if rrule:
1.434 - selector = get_rule(dtstart, rrule)
1.435 - parameters = get_parameters(rrule)
1.436 - periods = []
1.437 - for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")):
1.438 - start = datetime(*start, tzinfo=timezone("UTC"))
1.439 - end = start + duration
1.440 - periods.append((format_datetime(start), format_datetime(end)))
1.441 - else:
1.442 - periods = [(format_datetime(dtstart), format_datetime(dtend))]
1.443 -
1.444 - conflict = False
1.445 - freebusy = self.store.get_freebusy(attendee)
1.446 -
1.447 - if freebusy:
1.448 - remove_period(freebusy, self.uid)
1.449 - conflict = True
1.450 - for start, end in periods:
1.451 - if period_overlaps(freebusy, (start, end)):
1.452 - break
1.453 - else:
1.454 - conflict = False
1.455 - else:
1.456 - freebusy = []
1.457 -
1.458 - # If the event can be scheduled, it is registered and a reply sent
1.459 - # accepting the event. (The attendee has PARTSTAT=ACCEPTED as an
1.460 - # attribute.)
1.461 -
1.462 - if not conflict:
1.463 - for start, end in periods:
1.464 - insert_period(freebusy, (start, end, self.uid))
1.465 -
1.466 - if self.get_value("TRANSP") in (None, "OPAQUE"):
1.467 - self.store.set_freebusy(attendee, freebusy)
1.468 -
1.469 - if self.publisher:
1.470 - self.publisher.set_freebusy(attendee, freebusy)
1.471 -
1.472 - self.store.set_event(attendee, self.uid, to_node(
1.473 - {"VEVENT" : [(self.details, {})]}
1.474 - ))
1.475 - attendee_attr["PARTSTAT"] = "ACCEPTED"
1.476 -
1.477 - # If the event cannot be scheduled, it is not registered and a reply
1.478 - # sent declining the event. (The attendee has PARTSTAT=DECLINED as an
1.479 - # attribute.)
1.480 -
1.481 - else:
1.482 - attendee_attr["PARTSTAT"] = "DECLINED"
1.483 -
1.484 - self.details["ATTENDEE"] = [(attendee, attendee_attr)]
1.485 - calendar.append(to_node(
1.486 - {"VEVENT" : [(self.details, {})]}
1.487 - ))
1.488 -
1.489 - return calendar
1.490 -
1.491 -class Freebusy(Handler):
1.492 -
1.493 - "A free/busy handler."
1.494 -
1.495 - def publish(self):
1.496 - pass
1.497 -
1.498 - def reply(self):
1.499 -
1.500 - "Since this handler does not send requests, it will not handle replies."
1.501 -
1.502 - pass
1.503 -
1.504 - def request(self):
1.505 -
1.506 - """
1.507 - Respond to a request by preparing a reply containing free/busy
1.508 - information for each indicated attendee.
1.509 - """
1.510 -
1.511 - oa = self.require_organiser_and_attendees()
1.512 - if not oa:
1.513 - return None
1.514 -
1.515 - (organiser, organiser_attr), attendees = oa
1.516 -
1.517 - # Construct an appropriate fragment.
1.518 -
1.519 - calendar = []
1.520 - cwrite = calendar.append
1.521 -
1.522 - # Get the details for each attendee.
1.523 -
1.524 - for attendee, attendee_attr in attendees.items():
1.525 - freebusy = self.store.get_freebusy(attendee)
1.526 -
1.527 - if freebusy:
1.528 - record = []
1.529 - rwrite = record.append
1.530 -
1.531 - rwrite(("ORGANIZER", organiser_attr, organiser))
1.532 - rwrite(("ATTENDEE", attendee_attr, attendee))
1.533 - rwrite(("UID", {}, self.uid))
1.534 -
1.535 - for start, end, uid in freebusy:
1.536 - rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, [start, end]))
1.537 -
1.538 - cwrite(("VFREEBUSY", {}, record))
1.539 -
1.540 - # Return the reply.
1.541 -
1.542 - return calendar
1.543 -
1.544 -class Journal(Handler):
1.545 -
1.546 - "A journal entry handler."
1.547 -
1.548 - def add(self):
1.549 - pass
1.550 -
1.551 - def cancel(self):
1.552 - pass
1.553 -
1.554 - def publish(self):
1.555 - pass
1.556 -
1.557 -class Todo(Handler):
1.558 -
1.559 - "A to-do item handler."
1.560 -
1.561 - def add(self):
1.562 - pass
1.563 -
1.564 - def cancel(self):
1.565 - pass
1.566 -
1.567 - def counter(self):
1.568 -
1.569 - "Since this handler does not send requests, it will not handle replies."
1.570 -
1.571 - pass
1.572 -
1.573 - def declinecounter(self):
1.574 -
1.575 - """
1.576 - Since this handler does not send counter proposals, it will not handle
1.577 - replies to such proposals.
1.578 - """
1.579 -
1.580 - pass
1.581 -
1.582 - def publish(self):
1.583 - pass
1.584 -
1.585 - def refresh(self):
1.586 - pass
1.587 -
1.588 - def reply(self):
1.589 -
1.590 - "Since this handler does not send requests, it will not handle replies."
1.591 -
1.592 - pass
1.593 -
1.594 - def request(self):
1.595 - pass
1.596 -
1.597 -# Handler registry.
1.598 -
1.599 -handlers = [
1.600 - ("VFREEBUSY", Freebusy),
1.601 - ("VEVENT", Event),
1.602 - ("VTODO", Todo),
1.603 - ("VJOURNAL", Journal),
1.604 - ]
1.605 -
1.606 -methods = {
1.607 - "ADD" : lambda handler: handler.add,
1.608 - "CANCEL" : lambda handler: handler.cancel,
1.609 - "COUNTER" : lambda handler: handler.counter,
1.610 - "DECLINECOUNTER" : lambda handler: handler.declinecounter,
1.611 - "PUBLISH" : lambda handler: handler.publish,
1.612 - "REFRESH" : lambda handler: handler.refresh,
1.613 - "REPLY" : lambda handler: handler.reply,
1.614 - "REQUEST" : lambda handler: handler.request,
1.615 - }
1.616 -
1.617 -response_methods = {
1.618 - "REQUEST" : "REPLY",
1.619 - }
1.620 -
1.621 def main():
1.622
1.623 "Interpret program arguments and process input."
2.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000
2.2 +++ b/imip_resource.py Tue Oct 21 19:58:20 2014 +0200
2.3 @@ -0,0 +1,290 @@
2.4 +#!/usr/bin/env python
2.5 +
2.6 +"""
2.7 +Handlers for a resource.
2.8 +"""
2.9 +
2.10 +from datetime import date, datetime, timedelta
2.11 +from imiptools.content import Handler, format_datetime, get_value, parse_object
2.12 +from imiptools.period import insert_period, period_overlaps, remove_period
2.13 +from vCalendar import to_node
2.14 +from vRecurrence import get_parameters, get_rule
2.15 +
2.16 +class Event(Handler):
2.17 +
2.18 + "An event handler."
2.19 +
2.20 + def add(self):
2.21 + pass
2.22 +
2.23 + def cancel(self):
2.24 + pass
2.25 +
2.26 + def counter(self):
2.27 +
2.28 + "Since this handler does not send requests, it will not handle replies."
2.29 +
2.30 + pass
2.31 +
2.32 + def declinecounter(self):
2.33 +
2.34 + """
2.35 + Since this handler does not send counter proposals, it will not handle
2.36 + replies to such proposals.
2.37 + """
2.38 +
2.39 + pass
2.40 +
2.41 + def publish(self):
2.42 + pass
2.43 +
2.44 + def refresh(self):
2.45 + pass
2.46 +
2.47 + def reply(self):
2.48 +
2.49 + "Since this handler does not send requests, it will not handle replies."
2.50 +
2.51 + pass
2.52 +
2.53 + def request(self):
2.54 +
2.55 + """
2.56 + Respond to a request by preparing a reply containing accept/decline
2.57 + information for each indicated attendee.
2.58 +
2.59 + No support for countering requests is implemented.
2.60 + """
2.61 +
2.62 + oa = self.require_organiser_and_attendees()
2.63 + if not oa:
2.64 + return None
2.65 +
2.66 + (organiser, organiser_attr), attendees = oa
2.67 +
2.68 + # Process each attendee separately.
2.69 +
2.70 + calendar = []
2.71 +
2.72 + for attendee, attendee_attr in attendees.items():
2.73 +
2.74 + # Check for event using UID.
2.75 +
2.76 + f = self.store.get_event(attendee, self.uid)
2.77 + event = f and parse_object(f, "utf-8", "VEVENT")
2.78 +
2.79 + # If found, compare SEQUENCE and potentially DTSTAMP.
2.80 +
2.81 + if event:
2.82 + sequence = get_value(event, "SEQUENCE")
2.83 + dtstamp = get_value(event, "DTSTAMP")
2.84 +
2.85 + # If the request refers to an older version of the event, ignore
2.86 + # it.
2.87 +
2.88 + old_dtstamp = self.dtstamp < dtstamp
2.89 +
2.90 + if sequence is not None and (
2.91 + int(self.sequence) < int(sequence) or
2.92 + int(self.sequence) == int(sequence) and old_dtstamp
2.93 + ) or old_dtstamp:
2.94 +
2.95 + continue
2.96 +
2.97 + # If newer than any old version, discard old details from the
2.98 + # free/busy record and check for suitability.
2.99 +
2.100 + dtstart = self.get_utc_datetime("DTSTART")
2.101 + dtend = self.get_utc_datetime("DTEND")
2.102 +
2.103 + # NOTE: Need also DURATION support.
2.104 +
2.105 + duration = dtend - dtstart
2.106 +
2.107 + # Recurrence rules create multiple instances to be checked.
2.108 + # Conflicts may only be assessed within a period defined by policy
2.109 + # for the agent, with instances outside that period being considered
2.110 + # unchecked.
2.111 +
2.112 + # NOTE: Need to expose the 100 day window in the configuration.
2.113 +
2.114 + window_end = datetime.now() + timedelta(100)
2.115 +
2.116 + # NOTE: Need also RDATE and EXDATE support.
2.117 +
2.118 + rrule = self.get_value("RRULE")
2.119 +
2.120 + if rrule:
2.121 + selector = get_rule(dtstart, rrule)
2.122 + parameters = get_parameters(rrule)
2.123 + periods = []
2.124 + for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")):
2.125 + start = datetime(*start, tzinfo=timezone("UTC"))
2.126 + end = start + duration
2.127 + periods.append((format_datetime(start), format_datetime(end)))
2.128 + else:
2.129 + periods = [(format_datetime(dtstart), format_datetime(dtend))]
2.130 +
2.131 + conflict = False
2.132 + freebusy = self.store.get_freebusy(attendee)
2.133 +
2.134 + if freebusy:
2.135 + remove_period(freebusy, self.uid)
2.136 + conflict = True
2.137 + for start, end in periods:
2.138 + if period_overlaps(freebusy, (start, end)):
2.139 + break
2.140 + else:
2.141 + conflict = False
2.142 + else:
2.143 + freebusy = []
2.144 +
2.145 + # If the event can be scheduled, it is registered and a reply sent
2.146 + # accepting the event. (The attendee has PARTSTAT=ACCEPTED as an
2.147 + # attribute.)
2.148 +
2.149 + if not conflict:
2.150 + for start, end in periods:
2.151 + insert_period(freebusy, (start, end, self.uid))
2.152 +
2.153 + if self.get_value("TRANSP") in (None, "OPAQUE"):
2.154 + self.store.set_freebusy(attendee, freebusy)
2.155 +
2.156 + if self.publisher:
2.157 + self.publisher.set_freebusy(attendee, freebusy)
2.158 +
2.159 + self.store.set_event(attendee, self.uid, to_node(
2.160 + {"VEVENT" : [(self.details, {})]}
2.161 + ))
2.162 + attendee_attr["PARTSTAT"] = "ACCEPTED"
2.163 +
2.164 + # If the event cannot be scheduled, it is not registered and a reply
2.165 + # sent declining the event. (The attendee has PARTSTAT=DECLINED as an
2.166 + # attribute.)
2.167 +
2.168 + else:
2.169 + attendee_attr["PARTSTAT"] = "DECLINED"
2.170 +
2.171 + self.details["ATTENDEE"] = [(attendee, attendee_attr)]
2.172 + calendar.append(to_node(
2.173 + {"VEVENT" : [(self.details, {})]}
2.174 + ))
2.175 +
2.176 + return calendar
2.177 +
2.178 +class Freebusy(Handler):
2.179 +
2.180 + "A free/busy handler."
2.181 +
2.182 + def publish(self):
2.183 + pass
2.184 +
2.185 + def reply(self):
2.186 +
2.187 + "Since this handler does not send requests, it will not handle replies."
2.188 +
2.189 + pass
2.190 +
2.191 + def request(self):
2.192 +
2.193 + """
2.194 + Respond to a request by preparing a reply containing free/busy
2.195 + information for each indicated attendee.
2.196 + """
2.197 +
2.198 + oa = self.require_organiser_and_attendees()
2.199 + if not oa:
2.200 + return None
2.201 +
2.202 + (organiser, organiser_attr), attendees = oa
2.203 +
2.204 + # Construct an appropriate fragment.
2.205 +
2.206 + calendar = []
2.207 + cwrite = calendar.append
2.208 +
2.209 + # Get the details for each attendee.
2.210 +
2.211 + for attendee, attendee_attr in attendees.items():
2.212 + freebusy = self.store.get_freebusy(attendee)
2.213 +
2.214 + if freebusy:
2.215 + record = []
2.216 + rwrite = record.append
2.217 +
2.218 + rwrite(("ORGANIZER", organiser_attr, organiser))
2.219 + rwrite(("ATTENDEE", attendee_attr, attendee))
2.220 + rwrite(("UID", {}, self.uid))
2.221 +
2.222 + for start, end, uid in freebusy:
2.223 + rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, [start, end]))
2.224 +
2.225 + cwrite(("VFREEBUSY", {}, record))
2.226 +
2.227 + # Return the reply.
2.228 +
2.229 + return calendar
2.230 +
2.231 +class Journal(Handler):
2.232 +
2.233 + "A journal entry handler."
2.234 +
2.235 + def add(self):
2.236 + pass
2.237 +
2.238 + def cancel(self):
2.239 + pass
2.240 +
2.241 + def publish(self):
2.242 + pass
2.243 +
2.244 +class Todo(Handler):
2.245 +
2.246 + "A to-do item handler."
2.247 +
2.248 + def add(self):
2.249 + pass
2.250 +
2.251 + def cancel(self):
2.252 + pass
2.253 +
2.254 + def counter(self):
2.255 +
2.256 + "Since this handler does not send requests, it will not handle replies."
2.257 +
2.258 + pass
2.259 +
2.260 + def declinecounter(self):
2.261 +
2.262 + """
2.263 + Since this handler does not send counter proposals, it will not handle
2.264 + replies to such proposals.
2.265 + """
2.266 +
2.267 + pass
2.268 +
2.269 + def publish(self):
2.270 + pass
2.271 +
2.272 + def refresh(self):
2.273 + pass
2.274 +
2.275 + def reply(self):
2.276 +
2.277 + "Since this handler does not send requests, it will not handle replies."
2.278 +
2.279 + pass
2.280 +
2.281 + def request(self):
2.282 + pass
2.283 +
2.284 +# Handler registry.
2.285 +
2.286 +handlers = [
2.287 + ("VFREEBUSY", Freebusy),
2.288 + ("VEVENT", Event),
2.289 + ("VTODO", Todo),
2.290 + ("VJOURNAL", Journal),
2.291 + ]
2.292 +
2.293 +# vim: tabstop=4 expandtab shiftwidth=4
5.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000
5.2 +++ b/imiptools/content.py Tue Oct 21 19:58:20 2014 +0200
5.3 @@ -0,0 +1,308 @@
5.4 +#!/usr/bin/env python
5.5 +
5.6 +"""
5.7 +Interpretation and preparation of iMIP content, together with a content handling
5.8 +mechanism employed by specific recipients.
5.9 +"""
5.10 +
5.11 +from datetime import date, datetime
5.12 +from email.mime.text import MIMEText
5.13 +from pytz import timezone, UnknownTimeZoneError
5.14 +from vCalendar import parse, ParseError, to_dict
5.15 +import imip_store
5.16 +import re
5.17 +
5.18 +try:
5.19 + from cStringIO import StringIO
5.20 +except ImportError:
5.21 + from StringIO import StringIO
5.22 +
5.23 +# iCalendar date and datetime parsing (from DateSupport in MoinSupport).
5.24 +
5.25 +date_icalendar_regexp_str = ur'(?P<year>[0-9]{4})(?P<month>[0-9]{2})(?P<day>[0-9]{2})'
5.26 +datetime_icalendar_regexp_str = date_icalendar_regexp_str + \
5.27 + ur'(?:' \
5.28 + ur'T(?P<hour>[0-2][0-9])(?P<minute>[0-5][0-9])(?P<second>[0-6][0-9])' \
5.29 + ur'(?P<utc>Z)?' \
5.30 + ur')?'
5.31 +
5.32 +match_date_icalendar = re.compile(date_icalendar_regexp_str, re.UNICODE).match
5.33 +match_datetime_icalendar = re.compile(datetime_icalendar_regexp_str, re.UNICODE).match
5.34 +
5.35 +# Content interpretation.
5.36 +
5.37 +def get_items(d, name, all=True):
5.38 +
5.39 + """
5.40 + Get all items from 'd' with the given 'name', returning single items if
5.41 + 'all' is specified and set to a false value and if only one value is
5.42 + present for the name. Return None if no items are found for the name.
5.43 + """
5.44 +
5.45 + if d.has_key(name):
5.46 + values = d[name]
5.47 + if not all and len(values) == 1:
5.48 + return values[0]
5.49 + else:
5.50 + return values
5.51 + else:
5.52 + return None
5.53 +
5.54 +def get_item(d, name):
5.55 + return get_items(d, name, False)
5.56 +
5.57 +def get_value_map(d, name):
5.58 +
5.59 + """
5.60 + Return a dictionary for all items in 'd' having the given 'name'. The
5.61 + dictionary will map values for the name to any attributes or qualifiers
5.62 + that may have been present.
5.63 + """
5.64 +
5.65 + items = get_items(d, name)
5.66 + if items:
5.67 + return dict(items)
5.68 + else:
5.69 + return {}
5.70 +
5.71 +def get_values(d, name, all=True):
5.72 + if d.has_key(name):
5.73 + values = d[name]
5.74 + if not all and len(values) == 1:
5.75 + return values[0][0]
5.76 + else:
5.77 + return map(lambda x: x[0], values)
5.78 + else:
5.79 + return None
5.80 +
5.81 +def get_value(d, name):
5.82 + return get_values(d, name, False)
5.83 +
5.84 +def get_utc_datetime(d, name):
5.85 + value, attr = get_item(d, name)
5.86 + dt = get_datetime(value, attr)
5.87 + return to_utc_datetime(dt)
5.88 +
5.89 +def to_utc_datetime(dt):
5.90 + if not dt:
5.91 + return None
5.92 + elif isinstance(dt, datetime):
5.93 + return dt.astimezone(timezone("UTC"))
5.94 + else:
5.95 + return dt
5.96 +
5.97 +def format_datetime(dt):
5.98 + if not dt:
5.99 + return None
5.100 + elif isinstance(dt, datetime):
5.101 + return dt.strftime("%Y%m%dT%H%M%SZ")
5.102 + else:
5.103 + return dt.strftime("%Y%m%d")
5.104 +
5.105 +def get_address(value):
5.106 + return value.startswith("mailto:") and value[7:] or value
5.107 +
5.108 +def get_uri(value):
5.109 + return value.startswith("mailto:") and value or "mailto:%s" % value
5.110 +
5.111 +def get_datetime(value, attr):
5.112 + try:
5.113 + tz = attr.has_key("TZID") and timezone(attr["TZID"]) or None
5.114 + except UnknownTimeZoneError:
5.115 + tz = None
5.116 +
5.117 + if attr.get("VALUE") in (None, "DATE-TIME"):
5.118 + m = match_datetime_icalendar(value)
5.119 + if m:
5.120 + dt = datetime(
5.121 + int(m.group("year")), int(m.group("month")), int(m.group("day")),
5.122 + int(m.group("hour")), int(m.group("minute")), int(m.group("second"))
5.123 + )
5.124 +
5.125 + # Impose the indicated timezone.
5.126 + # NOTE: This needs an ambiguity policy for DST changes.
5.127 +
5.128 + tz = m.group("utc") and timezone("UTC") or tz or None
5.129 + if tz is not None:
5.130 + return tz.localize(dt)
5.131 + else:
5.132 + return dt
5.133 +
5.134 + if attr.get("VALUE") == "DATE":
5.135 + m = match_date_icalendar(value)
5.136 + if m:
5.137 + return date(
5.138 + int(m.group("year")), int(m.group("month")), int(m.group("day"))
5.139 + )
5.140 + return None
5.141 +
5.142 +# Handler mechanism objects.
5.143 +
5.144 +def handle_itip_part(part, recipients, handlers):
5.145 +
5.146 + """
5.147 + Handle the given iTIP 'part' for the given 'recipients' using the given
5.148 + 'handlers'.
5.149 + """
5.150 +
5.151 + method = part.get_param("method")
5.152 +
5.153 + # Decode the data and parse it.
5.154 +
5.155 + f = StringIO(part.get_payload(decode=True))
5.156 +
5.157 + itip = parse_object(f, part.get_content_charset(), "VCALENDAR")
5.158 +
5.159 + # Ignore the part if not a calendar object.
5.160 +
5.161 + if not itip:
5.162 + return []
5.163 +
5.164 + # Only handle calendar information.
5.165 +
5.166 + all_parts = []
5.167 +
5.168 + # Require consistency between declared and employed methods.
5.169 +
5.170 + if get_value(itip, "METHOD") == method:
5.171 +
5.172 + # Look for different kinds of sections.
5.173 +
5.174 + all_objects = []
5.175 +
5.176 + for name, cls in handlers:
5.177 + for details in get_values(itip, name) or []:
5.178 +
5.179 + # Dispatch to a handler and obtain any response.
5.180 +
5.181 + handler = cls(details, recipients)
5.182 + object = methods[method](handler)()
5.183 +
5.184 + # Concatenate responses for a single calendar object.
5.185 +
5.186 + if object:
5.187 + all_objects += object
5.188 +
5.189 + # Obtain a message part for the objects.
5.190 +
5.191 + if all_objects:
5.192 + all_parts.append(to_part(response_methods[method], all_objects))
5.193 +
5.194 + return all_parts
5.195 +
5.196 +def parse_object(f, encoding, objtype):
5.197 +
5.198 + """
5.199 + Parse the iTIP content from 'f' having the given 'encoding'. Return None if
5.200 + the content was not readable or suitable.
5.201 + """
5.202 +
5.203 + try:
5.204 + try:
5.205 + doctype, attrs, elements = obj = parse(f, encoding=encoding)
5.206 + if doctype == objtype:
5.207 + return to_dict(obj)[objtype][0]
5.208 + finally:
5.209 + f.close()
5.210 + except (ParseError, ValueError):
5.211 + pass
5.212 +
5.213 + return None
5.214 +
5.215 +def to_part(method, calendar):
5.216 +
5.217 + """
5.218 + Write using the given 'method', the 'calendar' details to a MIME
5.219 + text/calendar part.
5.220 + """
5.221 +
5.222 + encoding = "utf-8"
5.223 + out = StringIO()
5.224 + try:
5.225 + imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding)
5.226 + part = MIMEText(out.getvalue(), "calendar", encoding)
5.227 + part.set_param("method", method)
5.228 + return part
5.229 +
5.230 + finally:
5.231 + out.close()
5.232 +
5.233 +class Handler:
5.234 +
5.235 + "General handler support."
5.236 +
5.237 + def __init__(self, details, recipients):
5.238 +
5.239 + """
5.240 + Initialise the handler with the 'details' of a calendar object and the
5.241 + 'recipients' of the object.
5.242 + """
5.243 +
5.244 + self.details = details
5.245 + self.recipients = set(recipients)
5.246 +
5.247 + self.uid = get_value(details, "UID")
5.248 + self.sequence = get_value(details, "SEQUENCE")
5.249 + self.dtstamp = get_value(details, "DTSTAMP")
5.250 +
5.251 + self.store = imip_store.FileStore()
5.252 +
5.253 + try:
5.254 + self.publisher = imip_store.FilePublisher()
5.255 + except OSError:
5.256 + self.publisher = None
5.257 +
5.258 + def get_items(self, name, all=True):
5.259 + return get_items(self.details, name, all)
5.260 +
5.261 + def get_item(self, name):
5.262 + return get_item(self.details, name)
5.263 +
5.264 + def get_value_map(self, name):
5.265 + return get_value_map(self.details, name)
5.266 +
5.267 + def get_values(self, name, all=True):
5.268 + return get_values(self.details, name, all)
5.269 +
5.270 + def get_value(self, name):
5.271 + return get_value(self.details, name)
5.272 +
5.273 + def get_utc_datetime(self, name):
5.274 + return get_utc_datetime(self.details, name)
5.275 +
5.276 + def filter_by_recipients(self, values):
5.277 + return self.recipients.intersection(map(get_address, values))
5.278 +
5.279 + def require_organiser_and_attendees(self):
5.280 + attendee_map = self.get_value_map("ATTENDEE")
5.281 + organiser = self.get_item("ORGANIZER")
5.282 +
5.283 + # Only provide details for recipients who are also attendees.
5.284 +
5.285 + attendees = {}
5.286 + for attendee in map(get_uri, self.filter_by_recipients(attendee_map)):
5.287 + attendees[attendee] = attendee_map[attendee]
5.288 +
5.289 + if not attendees and not organiser:
5.290 + return None
5.291 +
5.292 + return organiser, attendees
5.293 +
5.294 +# Handler registry.
5.295 +
5.296 +methods = {
5.297 + "ADD" : lambda handler: handler.add,
5.298 + "CANCEL" : lambda handler: handler.cancel,
5.299 + "COUNTER" : lambda handler: handler.counter,
5.300 + "DECLINECOUNTER" : lambda handler: handler.declinecounter,
5.301 + "PUBLISH" : lambda handler: handler.publish,
5.302 + "REFRESH" : lambda handler: handler.refresh,
5.303 + "REPLY" : lambda handler: handler.reply,
5.304 + "REQUEST" : lambda handler: handler.request,
5.305 + }
5.306 +
5.307 +response_methods = {
5.308 + "REQUEST" : "REPLY",
5.309 + }
5.310 +
5.311 +# vim: tabstop=4 expandtab shiftwidth=4
6.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000
6.2 +++ b/imiptools/period.py Tue Oct 21 19:58:20 2014 +0200
6.3 @@ -0,0 +1,28 @@
6.4 +#!/usr/bin/env python
6.5 +
6.6 +from bisect import bisect_left, insort_left
6.7 +
6.8 +# Time management.
6.9 +
6.10 +def insert_period(freebusy, period):
6.11 + insort_left(freebusy, period)
6.12 +
6.13 +def remove_period(freebusy, uid):
6.14 + i = 0
6.15 + while i < len(freebusy):
6.16 + t = freebusy[i]
6.17 + if len(t) >= 3 and t[2] == uid:
6.18 + del freebusy[i]
6.19 + else:
6.20 + i += 1
6.21 +
6.22 +def period_overlaps(freebusy, period):
6.23 + dtstart, dtend = period[:2]
6.24 + i = bisect_left(freebusy, (dtstart, dtend, None))
6.25 + return (
6.26 + i < len(freebusy) and (dtend is None or freebusy[i][0] < dtend)
6.27 + or
6.28 + i > 0 and freebusy[i - 1][1] > dtstart
6.29 + )
6.30 +
6.31 +# vim: tabstop=4 expandtab shiftwidth=4