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

# TODO:
#  - implement sync strategies,
#  - cleanup,
#  - tests
from Products.CMFPlone.utils import safe_unicode
from plone.app.event import base
from plone.event.interfaces import IEventAccessor
from plone.event.utils import date_to_datetime
from plone.event.utils import is_date
from plone.event.utils import is_datetime
from zope.container.interfaces import INameChooser
from zope.event import notify
from zope.lifecycleevent import ObjectModifiedEvent

import datetime
import icalendar
import random
import transaction
import urllib2


def ical_import(container, ics_resource, event_type):
    cal = icalendar.Calendar.from_ical(ics_resource)
    events = cal.walk('VEVENT')

    def _get_prop(prop, item):
        ret = None
        if prop in item:
            ret = safe_unicode(item.decoded(prop))
        return ret

    def _from_list(ical, prop):
        """For EXDATE and RDATE recurrence component properties, the dates can
        be defined within one EXDATE/RDATE line or for each date an individual
        line.
        In the latter case, icalendar creates a list.
        This method handles this case.

        TODO: component property parameters like TZID are not used here.
        """
        val = prop in ical and ical[prop] or []
        if not isinstance(val, list):
            val = [val]
        #ret = ''
        #for item in val:
        #    ret = ret and '%s\n' % ret or ret  # insert linebreak
        #    ret = '%s%s:%s' % (ret, prop, item.to_ical())
        #return ret

        # Zip multiple lines into one, since jquery.recurrenceinput.js does
        # not support multiple lines here
        # https://github.com/collective/jquery.recurrenceinput.js/issues/15
        ret = ''
        for item in val:
            ret = ret and '%s,' % ret or ret  # insert linebreak
            ret = '%s%s' % (ret, item.to_ical())
        return ret and '%s:%s' % (prop, ret) or None

    count = 0
    for item in events:
        start = _get_prop('DTSTART', item)
        end = _get_prop('DTEND', item)
        if not end:
            duration = _get_prop('DURATION', item)
            if duration:
                end = start + duration
            # else: whole day or open end

        timezone = getattr(getattr(start, 'tzinfo', None), 'zone', None) or\
            base.default_timezone(container)

        whole_day = False
        open_end = False
        if is_date(start) and (is_date(end) or end is None):
            # All day / whole day events
            # End must be same type as start (RFC5545, 3.8.2.2)
            whole_day = True
            if end is None:
                end = start
            if start < end:
                # RFC5545 doesn't define clearly, if all day events should have
                # a end date one day after the start day at 0:00.
                # Internally, we handle all day events with start=0:00,
                # end=:23:59:59, so we substract one day here.
                end = end - datetime.timedelta(days=1)
            start = base.dt_start_of_day(date_to_datetime(start))
            end = base.dt_end_of_day(date_to_datetime(end))
        elif is_datetime(start) and end is None:
            # Open end event, see RFC 5545, 3.6.1
            open_end = True
            end = base.dt_end_of_day(date_to_datetime(start))
        assert(is_datetime(start))
        assert(is_datetime(end))

        title = _get_prop('SUMMARY', item)
        description = _get_prop('DESCRIPTION', item)
        location = _get_prop('LOCATION', item)

        url = _get_prop('URL', item)

        rrule = _get_prop('RRULE', item)
        rrule = rrule and 'RRULE:%s' % rrule.to_ical() or ''
        rdates = _from_list(item, 'RDATE')
        exdates = _from_list(item, 'EXDATE')
        rrule = '\n'.join([it for it in [rrule, rdates, exdates] if it])

        # TODO: attendee-lists are not decoded properly and contain only
        # vCalAddress values
        attendees = _get_prop('ATTENDEE', item)

        contact = _get_prop('CONTACT', item)
        categories = _get_prop('CATEGORIES', item)
        if hasattr(categories, '__iter__'):
            categories = [safe_unicode(it) for it in categories]

        ## for sync
        #created = _get_prop('CREATED', item)
        #modified = _get_prop('LAST-MODIFIED', item)

        # TODO: better use plone.api, from which some of the code here is
        # copied
        content_id = str(random.randint(0, 99999999))

        # TODO: if AT had the same attrs like IDXEventBase, we could set
        # everything within this invokeFactory call.
        container.invokeFactory(event_type,
                                id=content_id,
                                title=title,
                                description=description)
        content = container[content_id]

        event = IEventAccessor(content)
        event.start = start
        event.end = end
        event.timezone = timezone
        event.whole_day = whole_day
        event.open_end = open_end
        event.location = location
        event.event_url = url
        event.recurrence = rrule
        event.attendees = attendees
        event.contact_name = contact
        event.subjects = categories
        notify(ObjectModifiedEvent(content))

        # Archetypes specific code
        if getattr(content, 'processForm', False):
            # Will finish Archetypes content item creation process,
            # rename-after-creation and such
            content.processForm()

        if content_id in container:
            # Rename with new id from title, if processForm didn't do it.
            chooser = INameChooser(container)
            new_id = chooser.chooseName(title, content)
            transaction.savepoint(optimistic=True)  # Commit before renaming
            content.aq_parent.manage_renameObject(content_id, new_id)
        else:
            transaction.savepoint(optimistic=True)

        count += 1

    return {'count': count}


from zope.interface import Interface
from zope import schema
from plone.app.event import messageFactory as _
from plone.namedfile.field import NamedFile


class IIcalendarImportSettings(Interface):

    event_type = schema.Choice(
        title=_(u'Event Type'),
        vocabulary='plone.app.event.EventTypes',
        required=True
    )

    ical_url = schema.URI(
        title=_(u'Icalendar URL'),
        required=False
    )

    ical_file = NamedFile(
        title=_(u"Icalendar File"),
        required=False
    )

    # TODO: to implement
    #sync_strategy = schema.Choice(
    #    title=_(u"Synchronization Strategy"),
    #    vocabulary='plone.app.event.SynchronizationStrategies',
    #    required=True
    #)


from zope.component import adapts
from zope.interface import implements
from plone.app.event.base import AnnotationAdapter
#from plone.folder.interfaces import IFolder


[docs]class IcalendarImportSettings(AnnotationAdapter): """Annotation Adapter for IIcalendarImportSettings. """ implements(IIcalendarImportSettings) adapts(Interface) #adapts(IFolder) ## ?? TODO: when adapting this in z3c.form, why is a ## ATFolder not adaptable to this adapter, when it ## implements IFolder? ANNOTATION_KEY = "icalendar_import_settings"
from Products.Five.browser import BrowserView from plone.folder.interfaces import IFolder from plone.app.event.interfaces import IICalendarImportEnabled class IcalendarImportTool(BrowserView): @property def available(self): return IFolder.providedBy(self.context) @property def available_disabled(self): return self.available and not self.enabled @property def enabled(self): return IICalendarImportEnabled.providedBy(self.context) from Products.statusmessages.interfaces import IStatusMessage from z3c.form import button from z3c.form import form, field from zope.interface import alsoProvides, noLongerProvides class IcalendarImportSettingsForm(form.Form): fields = field.Fields(IIcalendarImportSettings) ignoreContext = False #TODO: needed? def updateWidgets(self): super(IcalendarImportSettingsForm, self).updateWidgets() def getContent(self): data = {} settings = IIcalendarImportSettings(self.context) data['event_type'] = settings.event_type data['ical_url'] = settings.ical_url #data['sync_strategy'] = settings.sync_strategy return data @button.buttonAndHandler(u'Save and Import') def handleSave(self, action): data, errors = self.extractData() if errors: return False settings = IIcalendarImportSettings(self.context) ical_file = data['ical_file'] if ical_file: # File upload is not saved in settings ical_resource = ical_file.data ical_import_from = ical_file.filename else: ical_url = settings.ical_url = data['ical_url'] ical_resource = urllib2.urlopen(ical_url, 'rb').read() ical_import_from = ical_url event_type = settings.event_type = data['event_type'] import_metadata = ical_import( self.context, ics_resource=ical_resource, event_type=event_type ) count = import_metadata['count'] IStatusMessage(self.request).addStatusMessage( "%s events imported from %s" % (count, ical_import_from), 'info') self.request.response.redirect(self.context.absolute_url()) @button.buttonAndHandler(u'Cancel') def handleCancel(self, action): self.request.response.redirect(self.context.absolute_url()) from plone.z3cform.layout import FormWrapper class IcalendarImportSettingsFormView(FormWrapper): form = IcalendarImportSettingsForm def enable(self): """Enable icalendar import on this context. """ alsoProvides(self.context, IICalendarImportEnabled) self.context.reindexObject(idxs=('object_provides')) self.request.response.redirect(self.context.absolute_url()) def disable(self): """Disable icalendar import on this context. """ noLongerProvides(self.context, IICalendarImportEnabled) self.context.reindexObject(idxs=('object_provides')) self.request.response.redirect(self.context.absolute_url())