# -*- coding: utf-8 -*-
from Acquisition import aq_inner
from datetime import datetime
from datetime import timedelta
from plone.app.contentlisting.interfaces import IContentListingObject
from plone.app.event.base import default_timezone
from plone.app.event.base import get_events
from plone.app.event.base import RET_MODE_BRAINS
from plone.event.interfaces import IEvent
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 Products.ZCatalog.interfaces import ICatalogBrain
from zope.interface import implementer
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 getattr(events, '__getitem__', False):
events = [events]
for event in events:
if ICatalogBrain.providedBy(event) or\
IContentListingObject.providedBy(event):
event = event.getObject()
if not (IEvent.providedBy(event) or IOccurrence.providedBy(event)):
# Must be an event.
continue
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
[docs]def add_to_zones_map(tzmap, tzid, dt):
"""Build a dictionary of timezone information from a timezone identifier
and a date/time object for which the timezone information should be
calculated.
:param tzmap: An existing dictionary of timezone information to be extended
or an empty dictionary.
:type tzmap: dictionary
:param tzid: A timezone identifier.
:type tzid: string
:param dt: A datetime object.
:type dt: datetime
:returns: A dictionary with timezone information needed to build VTIMEZONE
entries.
:rtype: dictionary
"""
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 if item <= dtzl else null)
# get previous transition to calculate tzoffsetfrom
idx = transitions.index(transition)
prev_idx = idx - 1 if idx > 0 else 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
[docs]@implementer(IICalendar)
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])
[docs]@implementer(IICalendar)
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)
[docs]@implementer(IICalendar)
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]@implementer(IICalendarEventComponent)
class ICalendarEventComponent(object):
"""Returns an icalendar object of the event.
"""
def __init__(self, context):
self.context = context
self.event = IEventAccessor(self.context)
self.ical = icalendar.Event()
@property
def dtstamp(self):
# must be in uc
return {'value': utc(datetime.now())}
@property
def created(self):
# must be in uc
return {'value': utc(self.event.created)}
@property
def last_modified(self):
# must be in uc
return {'value': utc(self.event.last_modified)}
@property
def uid(self):
return {'value': self.event.sync_uid}
@property
def url(self):
return {'value': self.event.url}
@property
def summary(self):
return {'value': self.event.title}
@property
def description(self):
return {'value': self.event.description}
@property
def dtstart(self):
if self.event.whole_day:
# 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.
return {'value': self.event.start.date()}
# Normal case + Open End case:
# 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.
return {'value': self.event.start}
@property
def dtend(self):
if self.event.whole_day:
# 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
return {'value': self.event.end.date() + timedelta(days=1)}
elif self.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.
return None
return {'value': self.event.end}
@property
def recurrence(self):
if not self.event.recurrence or IOccurrence.providedBy(self.context):
return None
ret = []
for recdef in self.event.recurrence.split():
prop, val = recdef.split(':')
if prop == 'RRULE':
ret.append({
'property': prop,
'value': 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 = self.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
ret.append({
'property': prop,
'value': dtlist
})
return ret
@property
def location(self):
return {'value': self.event.location}
@property
def attendee(self):
# TODO: revisit and implement attendee export according to RFC
ret = []
for attendee in self.event.attendees or []:
att = icalendar.prop.vCalAddress(attendee)
att.params['cn'] = icalendar.prop.vText(attendee)
att.params['ROLE'] = icalendar.prop.vText('REQ-PARTICIPANT')
ret.append(att)
return {'value': ret}
@property
def contact(self):
cn = []
event = self.event
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)
return {'value': u', '.join(cn)}
@property
def categories(self):
ret = []
for cat in self.event.subjects or []:
ret.append(cat)
if ret:
return {'value': ret}
@property
def geo(self):
"""Not implemented.
"""
return
def ical_add(self, prop, val):
if not val:
return
if not isinstance(val, list):
val = [val]
for _val in val:
assert(isinstance(_val, dict))
value = _val['value']
if not value:
continue
prop = _val.get('property', prop)
params = _val.get('parameters', None)
self.ical.add(prop, value, params)
def to_ical(self):
# TODO: event.text
ical_add = self.ical_add
ical_add('dtstamp', self.dtstamp)
ical_add('created', self.created)
ical_add('last-modified', self.last_modified)
ical_add('uid', self.uid)
ical_add('url', self.url)
ical_add('summary', self.summary)
ical_add('description', self.description)
ical_add('dtstart', self.dtstart)
ical_add('dtend', self.dtend)
ical_add(None, self.recurrence) # property key set via val
ical_add('location', self.location)
ical_add('attendee', self.attendee)
ical_add('contact', self.contact)
ical_add('categories', self.categories)
ical_add('geo', self.geo)
return self.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):
ical = self.get_ical_string()
name = '{0}.ics'.format(self.context.getId())
self.request.response.setHeader('Content-Type', 'text/calendar')
self.request.response.setHeader(
'Content-Disposition',
'attachment; filename="{0}"'.format(name)
)
self.request.response.setHeader('Content-Length', len(ical))
self.request.response.write(ical)