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