2 commits - pykolab/xml tests/functional tests/unit wallace/module_resources.py
Thomas Brüderli
bruederli at kolabsys.com
Sat Feb 21 18:09:32 CET 2015
pykolab/xml/event.py | 7
pykolab/xml/recurrence_rule.py | 16
tests/functional/test_wallace/test_005_resource_invitation.py | 235 ++++++++--
tests/unit/test-003-event.py | 17
wallace/module_resources.py | 192 ++++++--
5 files changed, 387 insertions(+), 80 deletions(-)
New commits:
commit 84ebf4d8a39c8b3781f3ca06e1f4cca258e0d886
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date: Sat Feb 21 03:07:54 2015 +0100
Support bookings for recurring events and single occurrences (#4632)
diff --git a/tests/functional/test_wallace/test_005_resource_invitation.py b/tests/functional/test_wallace/test_005_resource_invitation.py
index 98a6523..e7ac0f3 100644
--- a/tests/functional/test_wallace/test_005_resource_invitation.py
+++ b/tests/functional/test_wallace/test_005_resource_invitation.py
@@ -4,11 +4,13 @@ import smtplib
import email
import datetime
import uuid
+import re
from pykolab.imap import IMAP
from wallace import module_resources
from pykolab.translate import _
+from pykolab.xml import utils as xmlutils
from pykolab.xml import event_from_message
from pykolab.xml import participant_status_label
from pykolab.itip import events_from_message
@@ -26,7 +28,7 @@ PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
-UID:%s
+UID:%s%s
DTSTAMP:20140213T125414Z
DTSTART;TZID=Europe/London:%s
DTEND;TZID=Europe/London:%s
@@ -47,7 +49,7 @@ PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
-UID:%s
+UID:%s%s
DTSTAMP:20140215T125414Z
DTSTART;TZID=Europe/London:%s
DTEND;TZID=Europe/London:%s
@@ -69,7 +71,7 @@ PRODID:-//Roundcube//Roundcube libcalendaring 1.0-git//Sabre//Sabre VObject
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
-UID:%s
+UID:%s%s
DTSTAMP;VALUE=DATE-TIME:20140227T141939Z
DTSTART;VALUE=DATE-TIME;TZID=Europe/London:%s
DTEND;VALUE=DATE-TIME;TZID=Europe/London:%s
@@ -94,7 +96,7 @@ PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
CALSCALE:GREGORIAN
METHOD:CANCEL
BEGIN:VEVENT
-UID:%s
+UID:%s%s
DTSTAMP:20140218T125414Z
DTSTART;TZID=Europe/London:20120713T100000
DTEND;TZID=Europe/London:20120713T110000
@@ -116,7 +118,7 @@ PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
-UID:%s
+UID:%s%s
DTSTAMP:20140213T125414Z
DTSTART;VALUE=DATE:%s
DTEND;VALUE=DATE:%s
@@ -137,10 +139,10 @@ PRODID:-//Apple Inc.//Mac OS X 10.9.2//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
-UID:%s
+UID:%s%s
DTSTAMP:20140213T125414Z
-DTSTART;TZID=Europe/Zurich:%s
-DTEND;TZID=Europe/Zurich:%s
+DTSTART;TZID=Europe/London:%s
+DTEND;TZID=Europe/London:%s
RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=10
SUMMARY:test
DESCRIPTION:test
@@ -214,8 +216,8 @@ class TestResourceInvitation(unittest.TestCase):
}
from tests.functional.user_add import user_add
- user_add("John", "Doe")
- user_add("Jane", "Manager")
+ user_add("John", "Doe", kolabinvitationpolicy='ALL_MANUAL')
+ user_add("Jane", "Manager", kolabinvitationpolicy='ALL_MANUAL')
funcs.purge_resources()
self.audi = funcs.resource_add("car", "Audi A4")
@@ -242,7 +244,7 @@ class TestResourceInvitation(unittest.TestCase):
smtp.sendmail(from_addr, to_addr, mime_message % (to_addr, itip_payload))
smtp.quit()
- def send_itip_invitation(self, resource_email, start=None, allday=False, template=None, uid=None):
+ def send_itip_invitation(self, resource_email, start=None, allday=False, template=None, uid=None, instance=None):
if start is None:
start = datetime.datetime.now()
@@ -258,8 +260,13 @@ class TestResourceInvitation(unittest.TestCase):
default_template = itip_invitation
date_format = '%Y%m%dT%H%M%S'
+ recurrence_id = ''
+ if instance is not None:
+ recurrence_id = "\nRECURRENCE-ID;TZID=Europe/London:" + instance.strftime(date_format)
+
self.send_message((template if template is not None else default_template) % (
uid,
+ recurrence_id,
start.strftime(date_format),
end.strftime(date_format),
resource_email
@@ -268,13 +275,24 @@ class TestResourceInvitation(unittest.TestCase):
return uid
- def send_itip_update(self, resource_email, uid, start=None, template=None):
+ def send_itip_update(self, resource_email, uid, start=None, template=None, sequence=None, instance=None):
if start is None:
start = datetime.datetime.now()
end = start + datetime.timedelta(hours=4)
+
+ recurrence_id = ''
+ if instance is not None:
+ recurrence_id = "\nRECURRENCE-ID;TZID=Europe/London:" + instance.strftime('%Y%m%dT%H%M%S')
+
+ if sequence is not None:
+ if not template:
+ template = itip_update
+ template = re.sub(r'SEQUENCE:\d+', 'SEQUENCE:' + str(sequence), template)
+
self.send_message((template if template is not None else itip_update) % (
uid,
+ recurrence_id,
start.strftime('%Y%m%dT%H%M%S'),
end.strftime('%Y%m%dT%H%M%S'),
resource_email
@@ -283,9 +301,14 @@ class TestResourceInvitation(unittest.TestCase):
return uid
- def send_itip_cancel(self, resource_email, uid):
+ def send_itip_cancel(self, resource_email, uid, instance=None):
+ recurrence_id = ''
+ if instance is not None:
+ recurrence_id = "\nRECURRENCE-ID;TZID=Europe/London:" + instance.strftime('%Y%m%dT%H%M%S')
+
self.send_message(itip_cancellation % (
uid,
+ recurrence_id,
resource_email
),
resource_email)
@@ -293,6 +316,21 @@ class TestResourceInvitation(unittest.TestCase):
return uid
+ def send_owner_response(self, event, partstat, from_addr=None):
+ if from_addr is None:
+ from_addr = self.jane['mail']
+
+ itip_reply = event.to_message_itip(from_addr,
+ method="REPLY",
+ participant_status=partstat,
+ message_text="Request " + partstat,
+ subject="Booking has been %s" % (partstat)
+ )
+
+ smtp = smtplib.SMTP('localhost', 10026)
+ smtp.sendmail(from_addr, str(event.get_organizer().email()), str(itip_reply))
+ smtp.quit()
+
def check_message_received(self, subject, from_addr=None, mailbox=None):
if mailbox is None:
mailbox = self.john['mailbox']
@@ -322,7 +360,7 @@ class TestResourceInvitation(unittest.TestCase):
return found
- def check_resource_calendar_event(self, mailbox, uid=None):
+ def check_resource_calendar_event(self, mailbox, uid=None, instance=None):
imap = IMAP()
imap.connect()
@@ -345,7 +383,7 @@ class TestResourceInvitation(unittest.TestCase):
continue
found = event_from_message(event_message)
- if found:
+ if found and (instance is None or found.is_recurring() or xmlutils.dates_equal(instance, found.get_recurrence_id())):
break
time.sleep(1)
@@ -656,19 +694,9 @@ class TestResourceInvitation(unittest.TestCase):
notify = self.check_message_received(_('Booking request for %s requires confirmation') % (self.room3['cn']), mailbox=self.jane['mailbox'])
self.assertIsInstance(notify, email.message.Message)
- itip_event = events_from_message(notify)[0]
-
# resource owner confirms reservation request
- itip_reply = itip_event['xml'].to_message_itip(self.jane['mail'],
- method="REPLY",
- participant_status='ACCEPTED',
- message_text="Request accepted",
- subject=_('Booking for %s has been %s') % (self.room3['cn'], participant_status_label('ACCEPTED'))
- )
-
- smtp = smtplib.SMTP('localhost', 10026)
- smtp.sendmail(self.jane['mail'], str(itip_event['organizer']), str(itip_reply))
- smtp.quit()
+ itip_event = events_from_message(notify)[0]
+ self.send_owner_response(itip_event['xml'], 'ACCEPTED', from_addr=self.jane['mail'])
# requester (john) now gets the ACCEPTED response
response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') }, self.room3['mail'])
@@ -802,3 +830,156 @@ class TestResourceInvitation(unittest.TestCase):
# check confirmation message sent to resource owner (jane)
notify = self.check_message_received(_('Booking request for %s requires confirmation') % (self.room3['cn']), mailbox=self.jane['mailbox'])
self.assertIsInstance(notify, email.message.Message)
+
+
+ def test_017_reschedule_single_occurrence(self):
+ self.purge_mailbox(self.john['mailbox'])
+
+ # register a recurring resource invitation
+ start = datetime.datetime(2015,2,10, 12,0,0)
+ uid = self.send_itip_invitation(self.audi['mail'], start, template=itip_recurring)
+
+ accept = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') })
+ self.assertIsInstance(accept, email.message.Message)
+
+ self.purge_mailbox(self.john['mailbox'])
+
+ # send rescheduling request to a single instance
+ exdate = start + datetime.timedelta(days=14)
+ exstart = exdate + datetime.timedelta(hours=5)
+ self.send_itip_update(self.audi['mail'], uid, exstart, instance=exdate)
+
+ accept = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') })
+ self.assertIsInstance(accept, email.message.Message)
+ self.assertIn("RECURRENCE-ID;TZID=Europe/London:" + exdate.strftime('%Y%m%dT%H%M%S'), str(accept))
+
+ event = self.check_resource_calendar_event(self.audi['kolabtargetfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertEqual(len(event.get_exceptions()), 1)
+
+ # send new invitation for now free slot
+ uid = self.send_itip_invitation(self.audi['mail'], exdate, template=itip_invitation.replace('SUMMARY:test', 'SUMMARY:new'))
+
+ accept = self.check_message_received(self.itip_reply_subject % { 'summary':'new', 'status':participant_status_label('ACCEPTED') })
+ self.assertIsInstance(accept, email.message.Message)
+
+ # send rescheduling request to that single instance again: now conflicting
+ exdate = start + datetime.timedelta(days=14)
+ exstart = exdate + datetime.timedelta(hours=2)
+ self.send_itip_update(self.audi['mail'], uid, exstart, instance=exdate, sequence=3)
+
+ response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('DECLINED') })
+ self.assertIsInstance(response, email.message.Message)
+ self.assertIn("RECURRENCE-ID;TZID=Europe/London:", str(response))
+
+
+ def test_018_invite_single_occurrence(self):
+ self.purge_mailbox(self.john['mailbox'])
+ self.purge_mailbox(self.boxter['kolabtargetfolder'])
+
+ start = datetime.datetime(2015,3,2, 18,30,0)
+ uid = self.send_itip_invitation(self.boxter['mail'], start, instance=start)
+
+ accept = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') })
+ self.assertIsInstance(accept, email.message.Message)
+ self.assertIn("RECURRENCE-ID;TZID=Europe/London:" + start.strftime('%Y%m%dT%H%M%S'), str(accept))
+
+ self.purge_mailbox(self.john['mailbox'])
+
+ # send a second invitation for another instance with the same UID
+ nextstart = datetime.datetime(2015,3,9, 18,30,0)
+ self.send_itip_invitation(self.boxter['mail'], nextstart, uid=uid, instance=nextstart)
+
+ accept = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') })
+ self.assertIsInstance(accept, email.message.Message)
+ self.assertIn("RECURRENCE-ID;TZID=Europe/London:" + nextstart.strftime('%Y%m%dT%H%M%S'), str(accept))
+
+ self.purge_mailbox(self.john['mailbox'])
+
+ # send rescheduling request to the first instance
+ exstart = start + datetime.timedelta(hours=2)
+ self.send_itip_update(self.boxter['mail'], uid, exstart, instance=start)
+
+ accept = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') })
+ self.assertIsInstance(accept, email.message.Message)
+ self.assertIn("RECURRENCE-ID;TZID=Europe/London:" + start.strftime('%Y%m%dT%H%M%S'), str(accept))
+
+ # the resource calendar now has two reservations stored
+ one = self.check_resource_calendar_event(self.boxter['kolabtargetfolder'], uid, start)
+ self.assertIsInstance(one, pykolab.xml.Event)
+ self.assertIsInstance(one.get_recurrence_id(), datetime.datetime)
+ self.assertEqual(one.get_start().hour, exstart.hour)
+
+ two = self.check_resource_calendar_event(self.boxter['kolabtargetfolder'], uid, nextstart)
+ self.assertIsInstance(two, pykolab.xml.Event)
+ self.assertIsInstance(two.get_recurrence_id(), datetime.datetime)
+
+
+ def test_019_cancel_single_occurrence(self):
+ self.purge_mailbox(self.john['mailbox'])
+
+ # register a recurring resource invitation
+ start = datetime.datetime(2015,2,12, 14,0,0)
+ uid = self.send_itip_invitation(self.passat['mail'], start, template=itip_recurring)
+
+ accept = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') })
+ self.assertIsInstance(accept, email.message.Message)
+
+ exdate = start + datetime.timedelta(days=7)
+ self.send_itip_cancel(self.passat['mail'], uid, instance=exdate)
+
+ time.sleep(5) # wait for IMAP to update
+ event = self.check_resource_calendar_event(self.passat['kolabtargetfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertEqual(len(event.get_exceptions()), 1)
+
+ exception = event.get_instance(exdate)
+ self.assertEqual(exception.get_status(True), 'CANCELLED')
+ self.assertTrue(exception.get_transparency())
+
+
+ def test_020_owner_confirmation_single_occurrence(self):
+ self.purge_mailbox(self.john['mailbox'])
+ self.purge_mailbox(self.jane['mailbox'])
+
+ start = datetime.datetime(2015,4,18, 14,0,0)
+ uid = self.send_itip_invitation(self.room3['mail'], start, template=itip_recurring)
+
+ notify = self.check_message_received(_('Booking request for %s requires confirmation') % (self.room3['cn']), mailbox=self.jane['mailbox'])
+ self.assertIsInstance(notify, email.message.Message)
+
+ # resource owner confirms reservation request (entire series)
+ itip_event = events_from_message(notify)[0]
+ self.send_owner_response(itip_event['xml'], 'ACCEPTED', from_addr=self.jane['mail'])
+
+ self.purge_mailbox(self.john['mailbox'])
+ self.purge_mailbox(self.jane['mailbox'])
+
+ # send rescheduling request to a single instance
+ exdate = start + datetime.timedelta(days=14)
+ exstart = exdate + datetime.timedelta(hours=4)
+ self.send_itip_update(self.room3['mail'], uid, exstart, instance=exdate)
+
+ # check confirmation message sent to resource owner (jane)
+ notify = self.check_message_received(_('Booking request for %s requires confirmation') % (self.room3['cn']), mailbox=self.jane['mailbox'])
+ self.assertIsInstance(notify, email.message.Message)
+ self.assertIn("RECURRENCE-ID;TZID=Europe/London:" + exdate.strftime('%Y%m%dT%H%M%S'), str(notify))
+
+ itip_event = events_from_message(notify)[0]
+ self.assertIsInstance(itip_event['xml'].get_recurrence_id(), datetime.datetime)
+
+ # resource owner declines reservation request
+ self.send_owner_response(itip_event['xml'], 'DECLINED', from_addr=self.jane['mail'])
+
+ # requester (john) now gets the DECLINED response
+ response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('DECLINED') }, self.room3['mail'])
+ self.assertIsInstance(response, email.message.Message)
+ self.assertIn("RECURRENCE-ID;TZID=Europe/London:" + exdate.strftime('%Y%m%dT%H%M%S'), str(response))
+
+ event = self.check_resource_calendar_event(self.room3['kolabtargetfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertEqual(len(event.get_exceptions()), 1)
+
+ exception = event.get_instance(exdate)
+ self.assertEqual(exception.get_attendee_by_email(self.room3['mail']).get_participant_status(True), 'DECLINED')
+
diff --git a/wallace/module_resources.py b/wallace/module_resources.py
index d1833eb..c1a684c 100644
--- a/wallace/module_resources.py
+++ b/wallace/module_resources.py
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
-# Copyright 2010-2013 Kolab Systems AG (http://www.kolabsys.com)
+# Copyright 2010-2015 Kolab Systems AG (http://www.kolabsys.com)
#
# Jeroen van Meeuwen (Kolab Systems) <vanmeeuwen a kolabsys.com>
#
@@ -44,6 +44,7 @@ from pykolab.auth import Auth
from pykolab.conf import Conf
from pykolab.imap import IMAP
from pykolab.xml import to_dt
+from pykolab.xml import utils as xmlutils
from pykolab.xml import event_from_message
from pykolab.xml import participant_status_label
from pykolab.itip import events_from_message
@@ -265,14 +266,16 @@ def execute(*args, **kw):
# find initial reservation referenced by the reply
if reference_uid:
- event = find_existing_event(reference_uid, receiving_resource)
+ (event, master) = find_existing_event(reference_uid, itip_event['recurrence-id'], receiving_resource)
+ log.debug(_("iTip REPLY to %r, %r; matches %r") % (reference_uid, itip_event['recurrence-id'], type(event)), level=8)
+
if event:
try:
sender_attendee = itip_event['xml'].get_attendee_by_email(sender_email)
owner_reply = sender_attendee.get_participant_status()
log.debug(_("Sender Attendee: %r => %r") % (sender_attendee, owner_reply), level=9)
except Exception, e:
- log.error("Could not find envelope sender attendee: %r" % (e))
+ log.error(_("Could not find envelope sender attendee: %r") % (e))
continue
# compare sequence number to avoid outdated replies
@@ -287,18 +290,16 @@ def execute(*args, **kw):
if comment:
event.set_comment(str(comment))
- itip_event_ = dict(xml=event, uid=event.get_uid())
+ _itip_event = dict(xml=event, uid=event.get_uid(), _master=master)
+ _itip_event['recurrence-id'] = event.get_recurrence_id()
if owner_reply == kolabformat.PartAccepted:
event.set_status(kolabformat.StatusConfirmed)
- accept_reservation_request(itip_event_, receiving_resource, confirmed=True)
+ accept_reservation_request(_itip_event, receiving_resource, confirmed=True)
elif owner_reply == kolabformat.PartDeclined:
- decline_reservation_request(itip_event_, receiving_resource)
- # TODO: set status=CANCELLED instead of deleting?
- # event.set_status(kolabformat.StatusCancelled)
- delete_resource_event(reference_uid, receiving_resource)
+ decline_reservation_request(_itip_event, receiving_resource)
else:
- log.info("Invalid response (%r) recieved from resource owner for event %r" % (
+ log.info(_("Invalid response (%r) recieved from resource owner for event %r") % (
sender_attendee.get_participant_status(True), reference_uid
))
else:
@@ -316,7 +317,7 @@ def execute(*args, **kw):
receiving_attendee = itip_event['xml'].get_attendee_by_email(receiving_resource['mail'])
log.debug(_("Receiving Resource: %r; %r") % (receiving_resource, receiving_attendee), level=9)
except Exception, e:
- log.error("Could not find envelope attendee: %r" % (e))
+ log.error(_("Could not find envelope attendee: %r") % (e))
continue
# ignore updates and cancellations to resource collections who already delegated the event
@@ -329,7 +330,19 @@ def execute(*args, **kw):
for resource in resource_dns:
if resources[resource]['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()] \
and resources[resource].has_key('kolabtargetfolder'):
- delete_resource_event(itip_event['uid'], resources[resource])
+ (event, master) = find_existing_event(itip_event['uid'], itip_event['recurrence-id'], resources[resource])
+ # remove entire event
+ if event and master is None:
+ log.debug(_("Cancellation for entire event %r: deleting") % (itip_event['uid']), level=8)
+ delete_resource_event(itip_event['uid'], resources[resource], event._msguid)
+ # just cancel one single occurrence: add exception with status=cancelled
+ elif master and master.is_recurring():
+ log.debug(_("Cancellation for a single occurrence %r of %r: updating...") % (itip_event['recurrence-id'], itip_event['uid']), level=8)
+ event.set_status('CANCELLED')
+ event.set_transparency(True)
+ _itip_event = dict(xml=event, uid=event.get_uid(), _master=master)
+ _itip_event['recurrence-id'] = event.get_recurrence_id()
+ save_resource_event(_itip_event, resources[resource])
done = True
@@ -345,11 +358,6 @@ def execute(*args, **kw):
# accept reservation
if available_resource is not None:
if available_resource['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()]:
- # replace existing copy of this event
- if len(available_resource['existing_events']) > 0:
- for uid in available_resource['existing_events']:
- delete_resource_event(uid, available_resource)
-
log.debug(_("Accept invitation for individual resource %r / %r") % (available_resource['dn'], available_resource['mail']), level=8)
# check if reservation was delegated
@@ -557,6 +565,10 @@ def check_availability(itip_events, resource_dns, resources, receiving_attendee=
# This is the event being conflicted with!
for itip_event in itip_events:
+ # do not re-assign single occurrences to another resource
+ if itip_event['recurrence-id'] is not None:
+ continue
+
# Now we have the event that was conflicting
if resources[resource]['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()]:
# this resource initially was delegated from a collection ?
@@ -583,8 +595,8 @@ def check_availability(itip_events, resource_dns, resources, receiving_attendee=
# remove existing_events as we now delegated back to the collection
if len(resources[resource]['existing_events']) > 0:
- for uid in resources[resource]['existing_events']:
- delete_resource_event(uid, resources[resource])
+ for existing in resources[resource]['existing_events']:
+ delete_resource_event(existing.uid, resources[resource], existing._msguid)
done = True
@@ -655,7 +667,13 @@ def read_resource_calendar(resource_rec, itip_events):
level=9
)
- typ, data = imap.imap.m.fetch(num, '(RFC822)')
+ typ, data = imap.imap.m.fetch(num, '(UID RFC822)')
+
+ try:
+ msguid = re.search(r"\WUID (\d+)", data[0][0]).group(1)
+ except Exception, e:
+ log.error(_("No UID found in IMAP response: %r") % (data[0][0]))
+ continue
event_message = message_from_string(data[0][1])
@@ -669,8 +687,12 @@ def read_resource_calendar(resource_rec, itip_events):
for itip in itip_events:
conflict = check_event_conflict(event, itip)
- if event.get_uid() == itip['uid']:
- resource_rec['existing_events'].append(itip['uid'])
+ if event.get_uid() == itip['uid'] and (event.is_recurring() or itip['recurrence-id'] == event.get_recurrence_id()):
+ setattr(event, '_msguid', msguid)
+ if event.is_recurring():
+ resource_rec['existing_master'] = event
+ else:
+ resource_rec['existing_events'].append(event)
if conflict:
log.info(
@@ -686,13 +708,14 @@ def read_resource_calendar(resource_rec, itip_events):
return num_messages
-def find_existing_event(uid, resource_rec):
+def find_existing_event(uid, recurrence_id, resource_rec):
"""
Search the resources's calendar folder for the given event (by UID)
"""
global imap
event = None
+ master = None
mailbox = resource_rec['kolabtargetfolder']
log.debug(_("Searching %r for event %r") % (mailbox, uid), level=9)
@@ -705,18 +728,39 @@ def find_existing_event(uid, resource_rec):
return event
for num in reversed(data[0].split()):
- typ, data = imap.imap.m.fetch(num, '(RFC822)')
+ typ, data = imap.imap.m.fetch(num, '(UID RFC822)')
+
+ try:
+ msguid = re.search(r"\WUID (\d+)", data[0][0]).group(1)
+ except Exception, e:
+ log.error(_("No UID found in IMAP response: %r") % (data[0][0]))
+ continue
try:
event = event_from_message(message_from_string(data[0][1]))
+
+ # find instance in a recurring series
+ if recurrence_id and event.is_recurring():
+ master = event
+ event = master.get_instance(recurrence_id)
+ setattr(master, '_msguid', msguid)
+
+ # compare recurrence-id and skip to next message if not matching
+ elif recurrence_id and not event.is_recurring() and not xmlutils.dates_equal(recurrence_id, event.get_recurrence_id()):
+ log.debug(_("Recurrence-ID not matching on message %s, skipping: %r != %r") % (
+ msguid, recurrence_id, event.get_recurrence_id()
+ ), level=8)
+ continue
+ setattr(event, '_msguid', msguid)
+
except Exception, e:
log.error(_("Failed to parse event from message %s/%s: %r") % (mailbox, num, e))
continue
if event and event.uid == uid:
- return event
+ return (event, master)
- return event
+ return (event, master)
def accept_reservation_request(itip_event, resource, delegator=None, confirmed=False):
@@ -746,7 +790,7 @@ def accept_reservation_request(itip_event, resource, delegator=None, confirmed=F
partstat
)
- saved = save_resource_event(itip_event, resource, replace=confirmed)
+ saved = save_resource_event(itip_event, resource)
log.debug(
_("Adding event to %r: %r") % (resource['kolabtargetfolder'], saved),
@@ -773,6 +817,20 @@ def decline_reservation_request(itip_event, resource):
"DECLINED"
)
+ # update master event
+ if resource.get('existing_master') is not None or itip_event.get('_master') is not None:
+ save_resource_event(itip_event, resource)
+
+ # remove old copy of the reservation
+ elif resource.get('existing_events', []) and len(resource['existing_events']) > 0:
+ for existing in resource['existing_events']:
+ delete_resource_event(existing.uid, resource, existing._msguid)
+
+ # delete old event referenced by itip_event (from owner confirmation)
+ elif hasattr(itip_event['xml'], '_msguid'):
+ delete_resource_event(itip_event['xml'].uid, resource, itip_event['xml']._msguid)
+
+ # send response and notification
owner = get_resource_owner(resource)
send_response(resource['mail'], itip_event, get_resource_owner(resource))
@@ -780,25 +838,41 @@ def decline_reservation_request(itip_event, resource):
send_owner_notification(resource, owner, itip_event, True)
-def save_resource_event(itip_event, resource, replace=False):
+def save_resource_event(itip_event, resource):
"""
Append the given event object to the resource's calendar
"""
try:
- # Administrator login name comes from configuration.
+ save_event = itip_event['xml']
targetfolder = imap.folder_quote(resource['kolabtargetfolder'])
+ # add exception to existing recurring main event
+ if resource.get('existing_master') is not None:
+ save_event = resource['existing_master']
+ save_event.add_exception(itip_event['xml'])
+
+ elif itip_event.get('_master') is not None:
+ save_event = itip_event['_master']
+ save_event.add_exception(itip_event['xml'])
+
# remove old copy of the reservation (also sets ACLs)
- if replace:
- delete_resource_event(itip_event['uid'], resource)
+ if resource.has_key('existing_events') and len(resource['existing_events']) > 0:
+ for existing in resource['existing_events']:
+ delete_resource_event(existing.uid, resource, existing._msguid)
+
+ # delete old version referenced save_event
+ elif hasattr(save_event, '_msguid'):
+ delete_resource_event(save_event.uid, resource, save_event._msguid)
+
else:
imap.set_acl(targetfolder, conf.get(conf.get('kolab', 'imap_backend'), 'admin_login'), "lrswipkxtecda")
+ # append new version
result = imap.imap.m.append(
targetfolder,
None,
None,
- itip_event['xml'].to_message(creator="Kolab Server <wallace at localhost>").as_string()
+ save_event.to_message(creator="Kolab Server <wallace at localhost>").as_string()
)
return result
@@ -810,24 +884,42 @@ def save_resource_event(itip_event, resource, replace=False):
return False
-def delete_resource_event(uid, resource):
+def delete_resource_event(uid, resource, msguid=None):
"""
Removes the IMAP object with the given UID from a resource's calendar folder
"""
targetfolder = imap.folder_quote(resource['kolabtargetfolder'])
- imap.set_acl(targetfolder, conf.get(conf.get('kolab', 'imap_backend'), 'admin_login'), "lrswipkxtecda")
- imap.imap.m.select(targetfolder)
- typ, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % uid)
+ try:
+ imap.set_acl(targetfolder, conf.get(conf.get('kolab', 'imap_backend'), 'admin_login'), "lrswipkxtecda")
+ imap.imap.m.select(targetfolder)
- log.debug(_("Delete resource calendar object %r in %r: %r") % (
- uid, resource['kolabtargetfolder'], data
- ), level=9)
+ # delete by IMAP UID
+ if msguid is not None:
+ log.debug(_("Delete resource calendar object from %r by UID %r") % (
+ targetfolder, msguid
+ ), level=8)
- for num in data[0].split():
- imap.imap.m.store(num, '+FLAGS', '\\Deleted')
+ imap.imap.m.uid('store', msguid, '+FLAGS', '(\\Deleted)')
+ else:
+ typ, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % uid)
- imap.imap.m.expunge()
+ log.debug(_("Delete resource calendar object %r in %r: %r") % (
+ uid, resource['kolabtargetfolder'], data
+ ), level=9)
+
+ for num in data[0].split():
+ imap.imap.m.store(num, '+FLAGS', '\\Deleted')
+
+ imap.imap.m.expunge()
+ return True
+
+ except Exception, e:
+ log.error(_("Failed to delete calendar object %r from folder %r: %r") % (
+ uid, targetfolder, e
+ ))
+
+ return False
def reject(filepath):
@@ -1116,7 +1208,7 @@ def get_resource_invitationpolicy(resource):
if not isinstance(collections, list):
collections = [ (collections['dn'],collections) ]
- log.debug("Check collections %r for kolabinvitationpolicy attributes" % (collections), level=9)
+ log.debug(_("Check collections %r for kolabinvitationpolicy attributes") % (collections), level=9)
for dn,collection in collections:
# ldap.search_entry_by_attribute() doesn't return the attributes lower-cased
@@ -1289,6 +1381,13 @@ def send_owner_confirmation(resource, owner, itip_event):
organizer = event.get_organizer()
event_attendees = [a.get_displayname() for a in event.get_attendees() if not a.get_cutype() == kolabformat.CutypeResource]
+ log.debug(
+ _("Clone invitation for owner confirmation: %r from %r") % (
+ itip_event['uid'], event.get_organizer().email()
+ ),
+ level=8
+ )
+
# generate new UID and set the resource as organizer
(mail, domain) = resource['mail'].split('@')
event.set_uid(str(uuid.uuid4()))
@@ -1302,13 +1401,6 @@ def send_owner_confirmation(resource, owner, itip_event):
# flag this iTip message as confirmation type
event.add_custom_property('X-Kolab-InvitationType', 'CONFIRMATION')
- log.debug(
- _("Clone invitation for owner confirmation: %r from %r") % (
- itip_event['uid'], event.get_organizer().email()
- ),
- level=8
- )
-
message_text = _("""
A reservation request for %(resource)s requires your approval!
Please either accept or decline this invitation without saving it to your calendar.
commit ffc31a01be880493afe867217c8df6a0f3d636ad
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date: Sat Feb 21 02:55:47 2015 +0100
Export recurrence rules to iCal
diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py
index 5633d0d..31ea476 100644
--- a/pykolab/xml/event.py
+++ b/pykolab/xml/event.py
@@ -644,6 +644,13 @@ class Event(object):
return None
+ def get_ical_rrule(self):
+ result = []
+ rrule = self.get_recurrence()
+ if rrule.isValid():
+ result.append(rrule.to_ical())
+ return result
+
def get_location(self):
return self.event.location()
diff --git a/pykolab/xml/recurrence_rule.py b/pykolab/xml/recurrence_rule.py
index a82fec7..30ab10a 100644
--- a/pykolab/xml/recurrence_rule.py
+++ b/pykolab/xml/recurrence_rule.py
@@ -142,6 +142,9 @@ class RecurrenceRule(kolabformat.RecurrenceRule):
def set_byday(self, bdays):
daypos = kolabformat.vectordaypos()
for wday in bdays:
+ if isinstance(wday, str):
+ wday = icalendar.vWeekday(wday)
+
weekday = str(wday)[-2:]
occurrence = int(wday.relative)
if str(wday)[0] == '-':
@@ -177,7 +180,11 @@ class RecurrenceRule(kolabformat.RecurrenceRule):
name_map = dict([(v, k) for (k, v) in map.iteritems()])
return name_map[val] if name_map.has_key(val) else 'UNKNOWN'
- def to_dict(self):
+ def to_ical(self):
+ rrule = icalendar.vRecur(dict((k,v) for k,v in self.to_dict(True).items() if not (type(v) == str and v == '' or type(v) == list and len(v) == 0)))
+ return rrule
+
+ def to_dict(self, raw=False):
if not self.isValid() or self.frequency() == kolabformat.RecurrenceRule.FreqNone:
return None
@@ -194,9 +201,12 @@ class RecurrenceRule(kolabformat.RecurrenceRule):
if isinstance(val, kolabformat.cDateTime):
val = xmlutils.from_cdatetime(val, True)
elif isinstance(val, kolabformat.vectori):
- val = ",".join([int(v) for x in val])
+ val = [int(x) for x in val]
elif isinstance(val, kolabformat.vectordaypos):
- val = ",".join(["%s%s" % (str(x.occurence()) if x.occurence() != 0 else '', self._translate_value(x.weekday(), self.weekday_map)) for x in val])
+ val = ["%s%s" % (str(x.occurence()) if x.occurence() != 0 else '', self._translate_value(x.weekday(), self.weekday_map)) for x in val]
+
+ if not raw and isinstance(val, list):
+ val = ",".join(val)
if val is not None:
data[p] = val
diff --git a/tests/unit/test-003-event.py b/tests/unit/test-003-event.py
index c3172f9..cc27e58 100644
--- a/tests/unit/test-003-event.py
+++ b/tests/unit/test-003-event.py
@@ -9,6 +9,7 @@ import pykolab
from pykolab.xml import Attendee
from pykolab.xml import Event
+from pykolab.xml import RecurrenceRule
from pykolab.xml import EventIntegrityError
from pykolab.xml import InvalidAttendeeParticipantStatusError
from pykolab.xml import InvalidEventDateError
@@ -536,6 +537,14 @@ END:VEVENT
self.event.add_custom_property('X-Custom', 'check')
self.event.set_recurrence_id(datetime.datetime(2014, 05, 23, 11, 0, 0), True)
+ rrule = RecurrenceRule()
+ rrule.set_frequency(kolabformat.RecurrenceRule.Weekly)
+ rrule.set_byday(['2WE','-1SU'])
+ rrule.setBymonth([2])
+ rrule.set_count(10)
+ rrule.set_until(datetime.datetime(2014,7,23, 11,0,0, tzinfo=pytz.utc))
+ self.event.set_recurrence(rrule);
+
ical = icalendar.Calendar.from_ical(self.event.as_string_itip())
event = ical.walk('VEVENT')[0]
@@ -548,6 +557,14 @@ END:VEVENT
self.assertIsInstance(event['recurrence-id'].dt, datetime.datetime)
self.assertEqual(event['recurrence-id'].params.get('RANGE'), 'THISANDFUTURE')
+ self.assertTrue(event.has_key('rrule'))
+ self.assertEqual(event['rrule']['FREQ'][0], 'WEEKLY')
+ self.assertEqual(event['rrule']['INTERVAL'][0], 1)
+ self.assertEqual(event['rrule']['COUNT'][0], 10)
+ self.assertEqual(event['rrule']['BYMONTH'][0], 2)
+ self.assertEqual(event['rrule']['BYDAY'], ['2WE','-1SU'])
+ self.assertIsInstance(event['rrule']['UNTIL'][0], datetime.datetime)
+
def test_019_to_message_itip(self):
self.event = Event()
self.event.set_summary("test")
More information about the commits
mailing list