from Acquisition import aq_inner
from Products.ZCatalog.interfaces import ICatalogBrain
from datetime import datetime
from datetime import timedelta
from plone.app.contentlisting.interfaces import IContentListingObject
from plone.app.event.base import RET_MODE_BRAINS
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.interfaces import IOccurrence
from plone.event.utils import is_datetime
from plone.event.utils import tzdel
from plone.event.utils import utc
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_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) or\
IContentListingObject.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
if isinstance(tz, tuple):
tz_start, tz_end = tz
else:
tz_start = tz_end = tz
tzmap = add_to_zones_map(tzmap, tz_start, acc.start)
tzmap = add_to_zones_map(tzmap, tz_end, 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, ret_mode=RET_MODE_BRAINS,
expand=False, 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)
# The keyword argument brains=False was added to plone.app.contenttypes
# after 1.0
result = context.results(batch=False, sort_on='start')
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(datetime.now()))
ical.add('created', utc(event.created))
ical.add('last-modified', utc(event.last_modified))
if event.sync_uid:
# Re-Use existing icalendar event UID
ical.add('uid', event.sync_uid)
else:
# Else, use plone.uuid
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 and not IOccurrence.providedBy(self.context):
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
if isinstance(tzid, tuple):
tzid = tzid[0]
# 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())