Source code for plone.app.event.ical.exporter

from Acquisition import aq_inner
from Products.ZCatalog.interfaces import ICatalogBrain
from datetime import datetime
from datetime import timedelta
from plone.app.event.base import default_timezone
from plone.app.event.base import get_events
from plone.event.interfaces import IEventAccessor
from plone.event.interfaces import IICalendar
from plone.event.interfaces import IICalendarEventComponent
from plone.event.utils import is_datetime
from plone.event.utils import pydt
from plone.event.utils import tzdel
from plone.event.utils import utc
from plone.uuid.interfaces import IUUID
from zope.interface import implementer
from zope.interface import implements
from zope.publisher.browser import BrowserView

import icalendar
import pytz


PRODID = "-//Plone.org//NONSGML plone.app.event//EN"
VERSION = "2.0"


[docs]def construct_icalendar(context, events): """Returns an icalendar.Calendar object. :param context: A content object, which is used for calendar details like Title and Description. Usually a container, collection or the event itself. :param events: The list of event objects, which are included in this calendar. """ cal = icalendar.Calendar() cal.add('prodid', PRODID) cal.add('version', VERSION) cal_title = context.Title() if cal_title: cal.add('x-wr-calname', cal_title) cal_desc = context.Description() if cal_desc: cal.add('x-wr-caldesc', cal_desc) uuid = IUUID(context, None) if uuid: # portal object does not have UID cal.add('x-wr-relcalid', uuid) cal_tz = default_timezone(context) if cal_tz: cal.add('x-wr-timezone', cal_tz) tzmap = {} if not hasattr(events, '__getslice__'): # LazyMap doesn't have __iter__ events = [events] for event in events: if ICatalogBrain.providedBy(event): event = event.getObject() acc = IEventAccessor(event) tz = acc.timezone # TODO: the standard wants each recurrence to have a valid timezone # definition. sounds decent, but not realizable. if not acc.whole_day: # whole day events are exported as dates without # timezone information tzmap = add_to_zones_map(tzmap, tz, acc.start) tzmap = add_to_zones_map(tzmap, tz, acc.end) cal.add_component(IICalendarEventComponent(event).to_ical()) for (tzid, transitions) in tzmap.items(): cal_tz = icalendar.Timezone() cal_tz.add('tzid', tzid) cal_tz.add('x-lic-location', tzid) for (transition, tzinfo) in transitions.items(): if tzinfo['dst']: cal_tz_sub = icalendar.TimezoneDaylight() else: cal_tz_sub = icalendar.TimezoneStandard() cal_tz_sub.add('tzname', tzinfo['name']) cal_tz_sub.add('dtstart', transition) cal_tz_sub.add('tzoffsetfrom', tzinfo['tzoffsetfrom']) cal_tz_sub.add('tzoffsetto', tzinfo['tzoffsetto']) # TODO: add rrule # tzi.add('rrule', # {'freq': 'yearly', 'bymonth': 10, 'byday': '-1su'}) cal_tz.add_component(cal_tz_sub) cal.add_component(cal_tz) return cal
def add_to_zones_map(tzmap, tzid, dt): if tzid.lower() == 'utc' or not is_datetime(dt): # no need to define UTC nor timezones for date objects. return tzmap null = datetime(1, 1, 1) tz = pytz.timezone(tzid) transitions = getattr(tz, '_utc_transition_times', None) if not transitions: return tzmap # we need transition definitions dtzl = tzdel(utc(dt)) # get transition time, which is the dtstart of timezone. # the key function returns the value to compare with. as long as item # is smaller or equal like the dt value in UTC, return the item. as # soon as it becomes greater, compare with the smallest possible # datetime, which wouldn't create a match within the max-function. this # way we get the maximum transition time which is smaller than the # given datetime. transition = max(transitions, key=lambda item: item <= dtzl and item or null) # get previous transition to calculate tzoffsetfrom idx = transitions.index(transition) prev_idx = idx > 0 and idx - 1 or idx prev_transition = transitions[prev_idx] def localize(tz, dt): if dt is null: # dummy time, edge case # (dt at beginning of all transitions, see above.) return null return pytz.utc.localize(dt).astimezone(tz) # naive to utc + localize transition = localize(tz, transition) dtstart = tzdel(transition) # timezone dtstart must be in local time prev_transition = localize(tz, prev_transition) if tzid not in tzmap: tzmap[tzid] = {} # initial if dtstart in tzmap[tzid]: return tzmap # already there tzmap[tzid][dtstart] = { 'dst': transition.dst() > timedelta(0), 'name': transition.tzname(), 'tzoffsetfrom': prev_transition.utcoffset(), 'tzoffsetto': transition.utcoffset(), # TODO: recurrence rule } return tzmap @implementer(IICalendar)
[docs]def calendar_from_event(context): """Event adapter. Returns an icalendar.Calendar object from an Event context. """ context = aq_inner(context) return construct_icalendar(context, context)
@implementer(IICalendar)
[docs]def calendar_from_container(context): """Container adapter. Returns an icalendar.Calendar object from a Containerish context like a Folder. """ context = aq_inner(context) path = '/'.join(context.getPhysicalPath()) result = get_events(context, path=path) return construct_icalendar(context, result)
@implementer(IICalendar)
[docs]def calendar_from_collection(context): """Container/Event adapter. Returns an icalendar.Calendar object from a Collection. """ context = aq_inner(context) result = get_events(context) return construct_icalendar(context, result)
[docs]class ICalendarEventComponent(object): """Returns an icalendar object of the event. """ implements(IICalendarEventComponent) def __init__(self, context): self.context = context self.event = IEventAccessor(context) def to_ical(self): ical = icalendar.Event() event = self.event # TODO: event.text # must be in utc ical.add('dtstamp', utc(pydt(datetime.now()))) ical.add('created', utc(pydt(event.created))) ical.add('last-modified', utc(pydt(event.last_modified))) ical.add('uid', event.uid) ical.add('url', event.url) ical.add('summary', event.title) if event.description: ical.add('description', event.description) if event.whole_day: ical.add('dtstart', event.start.date()) # RFC5545, 3.6.1 # For cases where a "VEVENT" calendar component # specifies a "DTSTART" property with a DATE value type but no # "DTEND" nor "DURATION" property, the event's duration is taken to # be one day. # # RFC5545 doesn't define clearly, if all-day events should have # a end date on the same date or one day after the start day at # 0:00. Most icalendar libraries use the latter method. # Internally, whole_day events end on the same day one second # before midnight. Using the RFC5545 preferred method for # plone.app.event seems not appropriate, since we would have to fix # the date to end a day before for displaying. # For exporting, we let whole_day events end on the next day at # midnight. # See: # http://stackoverflow.com/questions/1716237/single-day-all-day # -appointments-in-ics-files # http://icalevents.com/1778-all-day-events-adding-a-day-or-not/ # http://www.innerjoin.org/iCalendar/all-day-events.html ical.add('dtend', event.end.date() + timedelta(days=1)) elif event.open_end: # RFC5545, 3.6.1 # For cases where a "VEVENT" calendar component # specifies a "DTSTART" property with a DATE-TIME value type but no # "DTEND" property, the event ends on the same calendar date and # time of day specified by the "DTSTART" property. ical.add('dtstart', event.start) else: ical.add('dtstart', event.start) ical.add('dtend', event.end) if event.recurrence: for recdef in event.recurrence.split(): prop, val = recdef.split(':') if prop == 'RRULE': ical.add(prop, icalendar.prop.vRecur.from_ical(val)) elif prop in ('EXDATE', 'RDATE'): factory = icalendar.prop.vDDDLists # localize ex/rdate # TODO: should better already be localized by event object tzid = event.timezone # get list of datetime values from ical string try: dtlist = factory.from_ical(val, timezone=tzid) except ValueError: # TODO: caused by a bug in plone.formwidget.recurrence, # where the recurrencewidget or plone.event fails with # COUNT=1 and a extra RDATE. # TODO: REMOVE this workaround, once this failure is # fixed in recurrence widget. continue ical.add(prop, dtlist) if event.location: ical.add('location', event.location) # TODO: revisit and implement attendee export according to RFC if event.attendees: for attendee in event.attendees: att = icalendar.prop.vCalAddress(attendee) att.params['cn'] = icalendar.prop.vText(attendee) att.params['ROLE'] = icalendar.prop.vText('REQ-PARTICIPANT') ical.add('attendee', att) cn = [] if event.contact_name: cn.append(event.contact_name) if event.contact_phone: cn.append(event.contact_phone) if event.contact_email: cn.append(event.contact_email) if event.event_url: cn.append(event.event_url) if cn: ical.add('contact', u', '.join(cn)) if event.subjects: for subject in event.subjects: ical.add('categories', subject) return ical
[docs]class EventsICal(BrowserView): """Returns events in iCal format. """ def get_ical_string(self): cal = IICalendar(self.context) return cal.to_ical() def __call__(self): name = '%s.ics' % self.context.getId() self.request.RESPONSE.setHeader('Content-Type', 'text/calendar') self.request.RESPONSE.setHeader( 'Content-Disposition', 'attachment; filename="%s"' % name ) self.request.RESPONSE.write(self.get_ical_string())