2 commits - pykolab/itip pykolab/xml tests/functional tests/unit wallace/module_invitationpolicy.py

Thomas Brüderli bruederli at kolabsys.com
Thu Feb 19 13:08:35 CET 2015


 pykolab/itip/__init__.py                                      |   14 
 pykolab/xml/event.py                                          |    5 
 pykolab/xml/utils.py                                          |    5 
 tests/functional/test_wallace/test_005_resource_invitation.py |    2 
 tests/functional/test_wallace/test_007_invitationpolicy.py    |  226 +++++++++
 tests/unit/test-011-itip.py                                   |   10 
 wallace/module_invitationpolicy.py                            |  230 +++++++---
 7 files changed, 425 insertions(+), 67 deletions(-)

New commits:
commit c8abb4150f7e9c1f80a7098a332561ae92e135a3
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Feb 17 20:51:29 2015 +0100

    Add support for invitations of recurring events and single occurrences (#4552)

diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py
index 398814f..2d9a424 100644
--- a/pykolab/xml/event.py
+++ b/pykolab/xml/event.py
@@ -1347,6 +1347,9 @@ class Event(object):
             instance._exceptions = []
             instance._isexception = False
 
+            # unset attachments list (only stored in main event)
+            instance.event.setAttachments(kolabformat.vectorattachment())
+
             # copy data from matching exception
             # (give precedence to single occurrence exceptions over thisandfuture)
             for exception in self._exceptions:
@@ -1374,7 +1377,7 @@ class Event(object):
         while instance:
             recurrence_id = instance.get_recurrence_id()
             if type(recurrence_id) == type(_datetime) and recurrence_id <= _datetime:
-                if recurrence_id == _datetime:
+                if xmlutils.dates_equal(recurrence_id, _datetime):
                     return instance
                 instance = self.get_next_instance(instance.get_start())
             else:
diff --git a/pykolab/xml/utils.py b/pykolab/xml/utils.py
index 9ff29b6..c92c52f 100644
--- a/pykolab/xml/utils.py
+++ b/pykolab/xml/utils.py
@@ -122,6 +122,11 @@ def to_cdatetime(_datetime, with_timezone=True, as_utc=False):
     return _cdatetime
 
 
+def dates_equal(a, b):
+    date_format = '%Y%m%d' if isinstance(a, datetime.date) and isinstance(b, datetime.date) else '%Y%m%dT%H%M%S'
+    return type(a) == type(b) and a.strftime(date_format) == b.strftime(date_format)
+
+
 property_labels = {
     "name":        N_("Name"),
     "summary":     N_("Summary"),
diff --git a/tests/functional/test_wallace/test_005_resource_invitation.py b/tests/functional/test_wallace/test_005_resource_invitation.py
index 885f4ba..98a6523 100644
--- a/tests/functional/test_wallace/test_005_resource_invitation.py
+++ b/tests/functional/test_wallace/test_005_resource_invitation.py
@@ -303,7 +303,7 @@ class TestResourceInvitation(unittest.TestCase):
         imap.imap.m.select(mailbox)
 
         found = None
-        retries = 10
+        retries = 15
 
         while not found and retries > 0:
             retries -= 1
diff --git a/tests/functional/test_wallace/test_007_invitationpolicy.py b/tests/functional/test_wallace/test_007_invitationpolicy.py
index cca5b96..4507dd1 100644
--- a/tests/functional/test_wallace/test_007_invitationpolicy.py
+++ b/tests/functional/test_wallace/test_007_invitationpolicy.py
@@ -29,7 +29,7 @@ PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
 CALSCALE:GREGORIAN
 METHOD:REQUEST
 BEGIN:VEVENT
-UID:%(uid)s
+UID:%(uid)s%(recurrenceid)s
 DTSTAMP:20140213T125414Z
 DTSTART;TZID=Europe/Berlin:%(start)s
 DTEND;TZID=Europe/Berlin:%(end)s
@@ -51,7 +51,7 @@ PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
 CALSCALE:GREGORIAN
 METHOD:CANCEL
 BEGIN:VEVENT
-UID:%(uid)s
+UID:%(uid)s%(recurrenceid)s
 DTSTAMP:20140218T125414Z
 DTSTART;TZID=Europe/Berlin:20120713T100000
 DTEND;TZID=Europe/Berlin:20120713T110000
@@ -74,8 +74,8 @@ METHOD:REQUEST
 BEGIN:VEVENT
 UID:%(uid)s
 DTSTAMP:20140213T125414Z
-DTSTART;TZID=Europe/Zurich:%(start)s
-DTEND;TZID=Europe/Zurich:%(end)s
+DTSTART;TZID=Europe/Berlin:%(start)s
+DTEND;TZID=Europe/Berlin:%(end)s
 RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=10
 SUMMARY:%(summary)s
 DESCRIPTION:test
@@ -95,7 +95,7 @@ CALSCALE:GREGORIAN
 METHOD:REPLY
 BEGIN:VEVENT
 SUMMARY:%(summary)s
-UID:%(uid)s
+UID:%(uid)s%(recurrenceid)s
 DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:%(start)s
 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:%(end)s
 DTSTAMP;VALUE=DATE-TIME:20140706T171038Z
@@ -115,7 +115,7 @@ CALSCALE:GREGORIAN
 METHOD:REPLY
 BEGIN:VEVENT
 SUMMARY:%(summary)s
-UID:%(uid)s
+UID:%(uid)s%(recurrenceid)s
 DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:%(start)s
 DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:%(end)s
 DTSTAMP;VALUE=DATE-TIME:20140706T171038Z
@@ -351,11 +351,12 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         smtp = smtplib.SMTP('localhost', 10026)
         smtp.sendmail(from_addr, to_addr, mime_message % (to_addr, method, itip_payload))
 
-    def send_itip_invitation(self, attendee_email, start=None, allday=False, template=None, summary="test", sequence=0, partstat='NEEDS-ACTION', from_addr=None):
+    def send_itip_invitation(self, attendee_email, start=None, allday=False, template=None, summary="test", sequence=0, partstat='NEEDS-ACTION', from_addr=None, instance=None):
         if start is None:
             start = datetime.datetime.now()
 
         uid = str(uuid.uuid4())
+        recurrence_id = ''
 
         if allday:
             default_template = itip_allday
@@ -372,8 +373,12 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
             else:
                 default_template = default_template.replace("john.doe at example.org", from_addr)
 
+        if instance is not None:
+            recurrence_id = "\nRECURRENCE-ID;TZID=Europe/Berlin:" + instance.strftime(date_format)
+
         self.send_message((template if template is not None else default_template) % {
                 'uid': uid,
+                'recurrenceid': recurrence_id,
                 'start': start.strftime(date_format),
                 'end': end.strftime(date_format),
                 'mailto': attendee_email,
@@ -385,15 +390,23 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
         return uid
 
-    def send_itip_update(self, attendee_email, uid, start=None, template=None, summary="test", sequence=1, partstat='ACCEPTED'):
+    def send_itip_update(self, attendee_email, uid, start=None, template=None, summary="test", sequence=1, partstat='ACCEPTED', instance=None):
         if start is None:
             start = datetime.datetime.now()
 
         end = start + datetime.timedelta(hours=4)
+
+        date_format = '%Y%m%dT%H%M%S'
+        recurrence_id = ''
+
+        if instance is not None:
+            recurrence_id = "\nRECURRENCE-ID;TZID=Europe/Berlin:" + instance.strftime(date_format)
+
         self.send_message((template if template is not None else itip_invitation) % {
                 'uid': uid,
-                'start': start.strftime('%Y%m%dT%H%M%S'),
-                'end': end.strftime('%Y%m%dT%H%M%S'),
+                'recurrenceid': recurrence_id,
+                'start': start.strftime(date_format),
+                'end': end.strftime(date_format),
                 'mailto': attendee_email,
                 'summary': summary,
                 'sequence': sequence,
@@ -403,15 +416,23 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
         return uid
 
-    def send_itip_reply(self, uid, attendee_email, mailto, start=None, template=None, summary="test", sequence=0, partstat='ACCEPTED'):
+    def send_itip_reply(self, uid, attendee_email, mailto, start=None, template=None, summary="test", sequence=0, partstat='ACCEPTED', instance=None):
         if start is None:
             start = datetime.datetime.now()
 
         end = start + datetime.timedelta(hours=4)
+
+        date_format = '%Y%m%dT%H%M%S'
+        recurrence_id = ''
+
+        if instance is not None:
+            recurrence_id = "\nRECURRENCE-ID;TZID=Europe/Berlin:" + instance.strftime(date_format)
+
         self.send_message((template if template is not None else itip_reply) % {
                 'uid': uid,
-                'start': start.strftime('%Y%m%dT%H%M%S'),
-                'end': end.strftime('%Y%m%dT%H%M%S'),
+                'recurrenceid': recurrence_id,
+                'start': start.strftime(date_format),
+                'end': end.strftime(date_format),
                 'mailto': attendee_email,
                 'organizer': mailto,
                 'summary': summary,
@@ -424,9 +445,15 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
         return uid
 
-    def send_itip_cancel(self, attendee_email, uid, template=None, summary="test", sequence=1):
+    def send_itip_cancel(self, attendee_email, uid, template=None, summary="test", sequence=1, instance=None):
+        recurrence_id = ''
+
+        if instance is not None:
+            recurrence_id = "\nRECURRENCE-ID;TZID=Europe/Berlin:" + instance.strftime('%Y%m%dT%H%M%S')
+
         self.send_message((template if template is not None else itip_cancellation) % {
                 'uid': uid,
+                'recurrenceid': recurrence_id,
                 'mailto': attendee_email,
                 'summary': summary,
                 'sequence': sequence,
@@ -436,7 +463,7 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
         return uid
 
-    def create_calendar_event(self, start=None, summary="test", sequence=0, user=None, attendees=None, folder=None):
+    def create_calendar_event(self, start=None, summary="test", sequence=0, user=None, attendees=None, folder=None, recurring=False, uid=None):
         if start is None:
             start = datetime.datetime.now(pytz.timezone("Europe/Berlin"))
         if user is None:
@@ -453,12 +480,23 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         event.set_end(end)
         event.set_organizer(user['mail'], user['displayname'])
 
+        if uid:
+            event.set_uid(uid)
+
         for attendee in attendees:
             event.add_attendee(attendee['mail'], attendee['displayname'], role="REQ-PARTICIPANT", participant_status="NEEDS-ACTION", rsvp=True)
 
         event.set_summary(summary)
         event.set_sequence(sequence)
 
+        if recurring and isinstance(recurring, kolabformat.RecurrenceRule):
+            event.set_recurrence(rrule)
+        else:
+            rrule = kolabformat.RecurrenceRule()
+            rrule.setFrequency(kolabformat.RecurrenceRule.Daily)
+            rrule.setCount(10)
+            event.set_recurrence(rrule)
+
         # create event with attachment
         vattach = event.get_attachments()
         attachment = kolabformat.Attachment()
@@ -1046,6 +1084,164 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.assertIsInstance(event, pykolab.xml.Event)
 
 
+    def test_015_update_single_occurrence(self):
+        self.purge_mailbox(self.john['mailbox'])
+
+        start = datetime.datetime(2015,4,2, 14,0,0)
+        uid = self.send_itip_invitation(self.jane['mail'], start, template=itip_recurring)
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertTrue(event.is_recurring())
+
+        # send update to a single instance with the same sequence: no re-scheduling
+        exdate = start + datetime.timedelta(days=14)
+        self.send_itip_update(self.jane['mail'], uid, exdate, summary="test exception", sequence=0, partstat='ACCEPTED', instance=exdate)
+
+        time.sleep(10)
+        event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(len(event.get_exceptions()), 1)
+
+        exception = event.get_instance(exdate)
+        self.assertEqual(exception.get_summary(), "test exception")
+        self.assertEqual(exception.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted)
+
+
+    def test_015_reschedule_single_occurrence(self):
+        self.purge_mailbox(self.john['mailbox'])
+
+        start = datetime.datetime(2015,4,10, 9,0,0)
+        uid = self.send_itip_invitation(self.jane['mail'], start, template=itip_recurring)
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        # send update to a single instance with the same sequence: no re-scheduling
+        exdate = start + datetime.timedelta(days=14)
+        exstart = exdate + datetime.timedelta(hours=5)
+        self.send_itip_update(self.jane['mail'], uid, exstart, summary="test resceduled", sequence=1, partstat='NEEDS-ACTION', instance=exdate)
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test resceduled', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        time.sleep(10)
+        event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(len(event.get_exceptions()), 1)
+
+        # re-schedule again, conflicts with itself
+        exstart = exdate + datetime.timedelta(hours=6)
+        self.send_itip_update(self.jane['mail'], uid, exstart, summary="test new", sequence=2, partstat='NEEDS-ACTION', instance=exdate)
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test new', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        # check for updated excaption
+        time.sleep(10)
+        event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(len(event.get_exceptions()), 1)
+
+        exception = event.get_instance(exdate)
+        self.assertIsInstance(exception, pykolab.xml.Event)
+        self.assertEqual(exception.get_start().strftime('%Y%m%dT%H%M%S'), exstart.strftime('%Y%m%dT%H%M%S'))
+
+
+    def test_016_reply_single_occurrence(self):
+        self.purge_mailbox(self.john['mailbox'])
+
+        start = datetime.datetime(2015,3,7, 10,0,0, tzinfo=pytz.timezone("Europe/Zurich"))
+        uid = self.create_calendar_event(start, attendees=[self.jane, self.mark], recurring=True)
+
+        event = self.check_user_calendar_event(self.john['kolabcalendarfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+
+        # store a copy in mark's calendar, too
+        self.create_calendar_event(start, attendees=[self.jane, self.mark], recurring=True, folder=self.mark['kolabcalendarfolder'], uid=uid)
+
+        # send a reply for a single occurrence from jane
+        exdate = start + datetime.timedelta(days=7)
+        self.send_itip_reply(uid, self.jane['mail'], self.john['mail'], start=exdate, instance=exdate)
+
+        # check for the updated event in john's calendar
+        time.sleep(10)
+        event = self.check_user_calendar_event(self.john['kolabcalendarfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(len(event.get_exceptions()), 1)
+
+        exception = event.get_instance(exdate)
+        self.assertEqual(exception.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted)
+
+        # check mark's copy for partstat update being stored in an exception, too
+        marks = self.check_user_calendar_event(self.mark['kolabcalendarfolder'], uid)
+        self.assertIsInstance(marks, pykolab.xml.Event)
+        self.assertEqual(len(marks.get_exceptions()), 1)
+
+        exception = marks.get_instance(exdate)
+        self.assertEqual(exception.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted)
+
+        # send a reply for a the entire series from mark
+        self.send_itip_reply(uid, self.mark['mail'], self.john['mail'], start=start)
+
+        # check for the updated event in john's calendar
+        time.sleep(10)
+        event = self.check_user_calendar_event(self.john['kolabcalendarfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(len(event.get_exceptions()), 1)
+
+        exception = event.get_instance(exdate)
+        self.assertEqual(exception.get_attendee(self.mark['mail']).get_participant_status(), kolabformat.PartAccepted)
+
+    def test_017_cancel_single_occurrence(self):
+        self.purge_mailbox(self.john['mailbox'])
+
+        start = datetime.datetime(2015,3,20, 19,0,0, tzinfo=pytz.timezone("Europe/Zurich"))
+        uid = self.send_itip_invitation(self.jane['mail'], summary="recurring", start=start, template=itip_recurring)
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'recurring', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+
+        exdate = start + datetime.timedelta(days=14)
+        self.send_itip_cancel(self.jane['mail'], uid, summary="recurring cancelled", instance=exdate)
+
+        time.sleep(10)
+        event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], 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())
+
+        # send a new invitation for the cancelled slot
+        uid = self.send_itip_invitation(self.jane['mail'], summary="new booking", start=exdate)
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'new booking', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+
+    def test_018_invite_individual_occurrences(self):
+        self.purge_mailbox(self.john['mailbox'])
+
+        start = datetime.datetime(2015,1,30, 17,0,0, tzinfo=pytz.timezone("Europe/Zurich"))
+        uid = self.send_itip_invitation(self.jane['mail'], summary="single", start=start, instance=start)
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'single', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+        self.assertIsInstance(response, email.message.Message)
+        self.assertIn("RECURRENCE-ID", str(response))
+
+        event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertIsInstance(event.get_recurrence_id(), datetime.datetime)
+
+
     def test_020_task_assignment_accept(self):
         start = datetime.datetime(2014,9,10, 19,0,0)
         uid = self.send_itip_invitation(self.jane['mail'], start, summary='work', template=itip_todo)
diff --git a/wallace/module_invitationpolicy.py b/wallace/module_invitationpolicy.py
index f8c0b8e..4ac7eef 100644
--- a/wallace/module_invitationpolicy.py
+++ b/wallace/module_invitationpolicy.py
@@ -25,6 +25,7 @@ from urlparse import urlparse
 import urllib
 import hashlib
 import traceback
+import re
 
 from email import message_from_string
 from email.parser import Parser
@@ -398,11 +399,34 @@ def process_itip_request(itip_event, policy, recipient_email, sender_email, rece
     condition_fulfilled = True
 
     # find existing event in user's calendar
-    existing = find_existing_object(itip_event['uid'], itip_event['type'], receiving_user, True)
+    existing = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True)
+    master = None
 
     # compare sequence number to determine a (re-)scheduling request
     if existing is not None:
         log.debug(_("Existing %s: %r") % (existing.type, existing), level=9)
+
+        # reply for a single instance only
+        if itip_event['recurrence-id'] is not None:
+            log.debug(_("REQUEST refers to a single occurrence at %s") % (str(itip_event['recurrence-id'])), level=8)
+            # find instance in a recurring series
+            if existing.is_recurring():
+                master = existing
+                existing = master.get_instance(itip_event['recurrence-id'])
+            # compare recurrence-id with the found event
+            elif not xmlutils.dates_equal(itip_event['recurrence-id'], existing.get_recurrence_id()):
+                existing = None
+
+            if not existing:
+                log.info(_("The iTip REQUEST refers to an unknown occurrence '%s' of object '%s'. Forwarding to Inbox.") % (
+                    str(itip_event['recurrence-id']), itip_event['uid']
+                ))
+                return MESSAGE_FORWARD
+
+            if master:
+                setattr(existing, '_imap_folder', master._imap_folder)
+                setattr(existing, '_msguid', master._msguid)
+
         scheduling_required = itip_event['sequence'] > 0 and itip_event['sequence'] > existing.get_sequence()
         save_object = True
 
@@ -478,7 +502,7 @@ def process_itip_request(itip_event, policy, recipient_email, sender_email, rece
 
         if not nonpart or existing:
             # save new copy from iTip
-            if store_object(itip_event['xml'], receiving_user, targetfolder):
+            if store_object(itip_event['xml'], receiving_user, targetfolder, master):
                 if policy & COND_FORWARD:
                     log.debug(_("Forward invitation for notification"), level=5)
                     return MESSAGE_FORWARD
@@ -509,9 +533,27 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
 
         # find existing event in user's calendar
         # sets/checks lock to avoid concurrent wallace processes trying to update the same event simultaneously
-        existing = find_existing_object(itip_event['uid'], itip_event['type'], receiving_user, True)
+        existing = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True)
+        master = None
 
         if existing:
+            # reply for a single instance only
+            if itip_event['recurrence-id'] is not None:
+                log.debug(_("REPLY refers to a single occurrence at %s") % (str(itip_event['recurrence-id'])), level=8)
+                # find instance in a recurring series
+                if existing.is_recurring():
+                    master = existing
+                    existing = master.get_instance(itip_event['recurrence-id'])
+                # compare recurrence-id with the found event
+                elif not xmlutils.dates_equal(itip_event['recurrence-id'], existing.get_recurrence_id()):
+                    existing = None
+
+                if not existing:
+                    log.info(_("The iTip REPLY refers to an unknown occurrence '%s' of object '%s'. Forwarding to Inbox.") % (
+                        str(itip_event['recurrence-id']), itip_event['uid']
+                    ))
+                    return MESSAGE_FORWARD
+
             # compare sequence number to avoid outdated replies?
             if not itip_event['sequence'] == existing.get_sequence():
                 log.info(_("The iTip reply sequence (%r) doesn't match the referred object version (%r). Forwarding to Inbox.") % (
@@ -521,16 +563,18 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
                 return MESSAGE_FORWARD
 
             log.debug(_("Auto-updating %s %r on iTip REPLY") % (existing.type, existing.uid), level=8)
+            updated_attendees = []
             try:
                 existing_attendee = existing.get_attendee(sender_email)
                 existing.set_attendee_participant_status(sender_email, sender_attendee.get_participant_status(), rsvp=False)
+                updated_attendees.append(existing_attendee)
             except Exception, e:
                 log.error("Could not find corresponding attende in organizer's copy: %r" % (e))
 
                 # append delegated-from attendee ?
                 if len(sender_attendee.get_delegated_from()) > 0:
-                    existing._attendees.append(sender_attendee)
-                    existing.event.setAttendees(existing._attendees)
+                    existing.add_attendee(sender_attendee)
+                    updated_attendees.append(sender_attendee)
                 else:
                     # TODO: accept new participant if ACT_ACCEPT ?
                     remove_write_lock(existing._lock_key)
@@ -544,28 +588,30 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
                     existing_delegatee = existing.find_attendee(delegatee_email)
 
                     if not existing_delegatee:
-                        existing._attendees.append(sender_delegatee)
+                        existing.add_attendee(sender_delegatee)
                         log.debug(_("Add delegatee: %r") % (sender_delegatee.to_dict()), level=9)
                     else:
                         existing_delegatee.copy_from(sender_delegatee)
                         log.debug(_("Update existing delegatee: %r") % (existing_delegatee.to_dict()), level=9)
 
+                    updated_attendees.append(sender_delegatee)
+
                     # copy all parameters from replying attendee (e.g. delegated-to, role, etc.)
                     existing_attendee.copy_from(sender_attendee)
-                    existing.event.setAttendees(existing._attendees)
+                    existing.update_attendees([existing_attendee])
                     log.debug(_("Update delegator: %r") % (existing_attendee.to_dict()), level=9)
 
                 except Exception, e:
                     log.error("Could not find delegated-to attendee: %r" % (e))
 
             # update the organizer's copy of the object
-            if update_object(existing, receiving_user):
+            if update_object(existing, receiving_user, master):
                 if policy & COND_NOTIFY:
                     send_update_notification(existing, receiving_user, existing, True)
 
                 # update all other attendee's copies
                 if conf.get('wallace','invitationpolicy_autoupdate_other_attendees_on_reply'):
-                    propagate_changes_to_attendees_accounts(existing)
+                    propagate_changes_to_attendees_accounts(existing, updated_attendees)
 
                 return MESSAGE_PROCESSED
 
@@ -589,12 +635,30 @@ def process_itip_cancel(itip_event, policy, recipient_email, sender_email, recei
     # auto-update the local copy with STATUS=CANCELLED
     if policy & ACT_UPDATE:
         # find existing object in user's folders
-        existing = find_existing_object(itip_event['uid'], itip_event['type'], receiving_user, True)
+        existing = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True)
+        master = None
 
         if existing:
+            # reply for a single instance only
+            if itip_event['recurrence-id'] is not None:
+                log.debug(_("CANCEL refers to a single occurrence at %s") % (str(itip_event['recurrence-id'])), level=8)
+                # find instance in a recurring series
+                if existing.is_recurring():
+                    master = existing
+                    existing = master.get_instance(itip_event['recurrence-id'])
+                # compare recurrence-id with the found event
+                elif not xmlutils.dates_equal(itip_event['recurrence-id'], existing.get_recurrence_id()):
+                    existing = None
+
+                if not existing:
+                    log.info(_("The iTip CANCEL refers to an unknown occurrence '%s' of object '%s'. Forwarding to Inbox.") % (
+                        str(itip_event['recurrence-id']), itip_event['uid']
+                    ))
+                    return MESSAGE_FORWARD
+
             existing.set_status('CANCELLED')
             existing.set_transparency(True)
-            if update_object(existing, receiving_user):
+            if update_object(existing, receiving_user, master):
                 # send cancellation notification
                 if policy & ACT_UPDATE_AND_NOTIFY:
                     send_cancel_notification(existing, receiving_user)
@@ -602,7 +666,7 @@ def process_itip_cancel(itip_event, policy, recipient_email, sender_email, recei
                 return MESSAGE_PROCESSED
 
         else:
-            log.error(_("The object referred by this reply was not found in the user's folders. Forwarding to Inbox."))
+            log.error(_("The object referred by this cancel request was not found in the user's folders. Forwarding to Inbox."))
             return MESSAGE_FORWARD
 
     return None
@@ -735,7 +799,7 @@ def list_user_folders(user_rec, type):
 
     for folder in folders:
         # exclude shared and other user's namespace
-        if not ns_other is None and folder.startswith(ns_other):
+        if not ns_other is None and folder.startswith(ns_other) and user_rec.has_key('_delegated_mailboxes'):
             # allow shared folders from delegators
             if len([_mailbox for _mailbox in user_rec['_delegated_mailboxes'] if folder.startswith(ns_other + _mailbox + '/')]) == 0:
                 continue;
@@ -765,7 +829,7 @@ def list_user_folders(user_rec, type):
     return result
 
 
-def find_existing_object(uid, type, user_rec, lock=False):
+def find_existing_object(uid, type, recurrence_id, user_rec, lock=False):
     """
         Search user's private folders for the given object (by UID+type)
     """
@@ -782,9 +846,14 @@ def find_existing_object(uid, type, user_rec, lock=False):
         log.debug(_("Searching folder %r for %s %r") % (folder, type, uid), level=8)
         imap.imap.m.select(imap.folder_utf7(folder))
 
-        typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid))
+        res, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid))
         for num in reversed(data[0].split()):
-            typ, data = imap.imap.m.fetch(num, '(RFC822)')
+            res, data = imap.imap.m.fetch(num, '(UID RFC822)')
+            msguid = None
+
+            grep = re.search(r" UID (\d+)", data[0][0])
+            if grep:
+                msguid = grep.group(1)
 
             try:
                 if type == 'task':
@@ -792,8 +861,16 @@ def find_existing_object(uid, type, user_rec, lock=False):
                 else:
                     event = event_from_message(message_from_string(data[0][1]))
 
+                # compare recurrence-id and skip to next message if not matching
+                if 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, '_imap_folder', folder)
                 setattr(event, '_lock_key', lock_key)
+                setattr(event, '_msguid', msguid)
             except Exception, e:
                 log.error(_("Failed to parse %s from message %s/%s: %s") % (type, folder, num, traceback.format_exc()))
                 continue
@@ -824,12 +901,12 @@ def check_availability(itip_event, receiving_user):
         log.debug(_("Listing events from folder %r") % (folder), level=8)
         imap.imap.m.select(imap.folder_utf7(folder))
 
-        typ, data = imap.imap.m.search(None, '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")')
+        res, data = imap.imap.m.search(None, '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")')
         num_messages += len(data[0].split())
 
         for num in reversed(data[0].split()):
             event = None
-            typ, data = imap.imap.m.fetch(num, '(RFC822)')
+            res, data = imap.imap.m.fetch(num, '(RFC822)')
 
             try:
                 event = event_from_message(message_from_string(data[0][1]))
@@ -907,25 +984,30 @@ def get_lock_key(user, uid):
     return hashlib.md5("%s/%s" % (user['mail'], uid)).hexdigest()
 
 
-def update_object(object, user_rec):
+def update_object(object, user_rec, master=None):
     """
         Update the given object in IMAP (i.e. delete + append)
     """
     success = False
+    saveobj = object
 
-    if hasattr(object, '_imap_folder'):
-        delete_object(object)
-        object.set_lastmodified()  # update last-modified timestamp
-        success = store_object(object, user_rec, object._imap_folder)
+    # updating a single instance only: use master event
+    if object.get_recurrence_id() and master:
+        saveobj = master
+
+    if hasattr(saveobj, '_imap_folder'):
+        if delete_object(saveobj):
+            saveobj.set_lastmodified()  # update last-modified timestamp
+            success = store_object(object, user_rec, saveobj._imap_folder, master)
 
         # remove write lock for this event
-        if hasattr(object, '_lock_key') and object._lock_key is not None:
-            remove_write_lock(object._lock_key)
+        if hasattr(saveobj, '_lock_key') and saveobj._lock_key is not None:
+            remove_write_lock(saveobj._lock_key)
 
     return success
 
 
-def store_object(object, user_rec, targetfolder=None):
+def store_object(object, user_rec, targetfolder=None, master=None):
     """
         Append the given object to the user's default calendar/tasklist
     """
@@ -943,7 +1025,15 @@ def store_object(object, user_rec, targetfolder=None):
         log.error(_("Failed to save %s: no target folder found for user %r") % (object.type, user_rec['mail']))
         return Fasle
 
-    log.debug(_("Save %s %r to user folder %r") % (object.type, object.uid, targetfolder), level=8)
+    saveobj = object
+
+    # updating a single instance only: add exception to master event
+    if object.get_recurrence_id() and master:
+        object.set_lastmodified()  # update last-modified timestamp
+        master.add_exception(object)
+        saveobj = master
+
+    log.debug(_("Save %s %r to user folder %r") % (saveobj.type, saveobj.uid, targetfolder), level=8)
 
     try:
         imap.imap.m.select(imap.folder_utf7(targetfolder))
@@ -951,13 +1041,13 @@ def store_object(object, user_rec, targetfolder=None):
             imap.folder_utf7(targetfolder),
             None,
             None,
-            object.to_message(creator="Kolab Server <wallace at localhost>").as_string()
+            saveobj.to_message(creator="Kolab Server <wallace at localhost>").as_string()
         )
         return result
 
     except Exception, e:
         log.error(_("Failed to save %s to user folder at %r: %r") % (
-            object.type, targetfolder, e
+            saveobj.type, targetfolder, e
         ))
 
     return False
@@ -968,18 +1058,37 @@ def delete_object(existing):
         Removes the IMAP object with the given UID from a user's folder
     """
     targetfolder = existing._imap_folder
-    imap.imap.m.select(imap.folder_utf7(targetfolder))
+    msguid = existing._msguid if hasattr(existing, '_msguid') else None
 
-    typ, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % existing.uid)
+    try:
+        imap.imap.m.select(imap.folder_utf7(targetfolder))
 
-    log.debug(_("Delete %s %r in %r: %r") % (
-        existing.type, existing.uid, targetfolder, data
-    ), level=8)
+        # delete by IMAP UID
+        if msguid is not None:
+            log.debug(_("Delete %s %r in %r by UID: %r") % (
+                existing.type, existing.uid, targetfolder, msguid
+            ), level=8)
+
+            imap.imap.m.uid('store', msguid, '+FLAGS', '(\\Deleted)')
+        else:
+            res, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % existing.uid)
+
+            log.debug(_("Delete %s %r in %r: %r") % (
+                existing.type, existing.uid, targetfolder, data
+            ), level=8)
+
+            for num in data[0].split():
+                imap.imap.m.store(num, '+FLAGS', '(\\Deleted)')
 
-    for num in data[0].split():
-        imap.imap.m.store(num, '+FLAGS', '\\Deleted')
+        imap.imap.m.expunge()
+        return True
 
-    imap.imap.m.expunge()
+    except Exception, e:
+        log.error(_("Failed to delete %s from folder %r: %r") % (
+            existing.type, targetfolder, e
+        ))
+
+    return False
 
 
 def send_update_notification(object, receiving_user, old=None, reply=True):
@@ -1076,6 +1185,9 @@ def send_update_notification(object, receiving_user, old=None, reply=True):
             'roundup': roundup
         }
 
+    if object.get_recurrence_id():
+        message_text += "\n" + _("NOTE: This update only refers to this single occurrence!")
+
     message_text += "\n" + _("*** This is an automated message. Please do not reply. ***")
 
     # compose mime message
@@ -1092,7 +1204,8 @@ def send_update_notification(object, receiving_user, old=None, reply=True):
         smtp.set_debuglevel(True)
 
     try:
-        smtp.sendmail(orgemail, receiving_user['mail'], msg.as_string())
+        success = smtp.sendmail(orgemail, receiving_user['mail'], msg.as_string())
+        log.debug(_("Sent update notification to %r: %r") % (receiving_user['mail'], success), level=8)
     except Exception, e:
         log.error(_("SMTP sendmail error: %r") % (e))
 
@@ -1191,23 +1304,42 @@ def check_policy_condition(policy, available):
     return condition_fulfilled
 
 
-def propagate_changes_to_attendees_accounts(object):
+def propagate_changes_to_attendees_accounts(object, updated_attendees=None):
     """
         Find and update copies of this object in all attendee's personal folders
     """
+    recurrence_id = object.get_recurrence_id()
+
     for attendee in object.get_attendees():
         attendee_user_dn = user_dn_from_email_address(attendee.get_email())
         if attendee_user_dn:
             attendee_user = auth.get_entry_attributes(None, attendee_user_dn, ['*'])
-            attendee_object = find_existing_object(object.uid, object.type, attendee_user, True)  # does IMAP authenticate
+            attendee_object = find_existing_object(object.uid, object.type, recurrence_id, attendee_user, True)  # does IMAP authenticate
             if attendee_object:
-                try:
-                    attendee_entry = attendee_object.get_attendee_by_email(attendee_user['mail'])
-                except:
-                    attendee_entry = None
+                master_object = None
+
+                # updating a single instance only: add exception to master event
+                if recurrence_id:
+                    master_object = attendee_object
+                    attendee_object = master_object.get_instance(recurrence_id)
+                    if attendee_object is None:
+                        log.debug(_("Unable to find occurrence '%s' in %s's copy of object %r") % (
+                            str(recurrence_id), attendee_user['mail'], object.uid
+                        ), level=5)
+                        break
+
+                # find attendee's entry by one of its email addresses
+                attendee_emails = auth.extract_recipient_addresses(attendee_user)
+                for attendee_email in attendee_emails:
+                    try:
+                        attendee_entry = attendee_object.get_attendee_by_email(attendee_email)
+                    except:
+                        attendee_entry = None
+                    if attendee_entry:
+                        break
 
                 # copy all attendees from master object (covers additions and removals)
-                new_attendees = kolabformat.vectorattendee();
+                new_attendees = [];
                 for a in object.get_attendees():
                     # keep my own entry intact
                     if attendee_entry is not None and attendee_entry.get_email() == a.get_email():
@@ -1215,9 +1347,13 @@ def propagate_changes_to_attendees_accounts(object):
                     else:
                         new_attendees.append(a)
 
-                attendee_object.event.setAttendees(new_attendees)
+                attendee_object.set_attendees(new_attendees)
+
+                if updated_attendees and not recurrence_id:
+                    log.debug("Update Attendees %r for %s" % ([a.get_email()+':'+a.get_participant_status(True) for a in updated_attendees], attendee_user['mail']), level=8)
+                    attendee_object.update_attendees(updated_attendees, False)
 
-                success = update_object(attendee_object, attendee_user)
+                success = update_object(attendee_object, attendee_user, master_object)
                 log.debug(_("Updated %s's copy of %r: %r") % (attendee_user['mail'], object.uid, success), level=8)
 
             else:


commit 94cd4fab8e9e80111fc3c07636ab0fae4af4c49d
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Tue Feb 17 20:50:22 2015 +0100

    Consider transparency and 'cancelled' status for recurrence exceptions

diff --git a/pykolab/itip/__init__.py b/pykolab/itip/__init__.py
index 848b2d7..961c3dc 100644
--- a/pykolab/itip/__init__.py
+++ b/pykolab/itip/__init__.py
@@ -1,6 +1,7 @@
 import icalendar
 import pykolab
 import traceback
+import kolabformat
 
 from pykolab.xml import to_dt
 from pykolab.xml import event_from_ical
@@ -86,6 +87,7 @@ def objects_from_message(message, objnames, methods=None):
                     itip['uid'] = str(c['uid'])
                     itip['method'] = str(cal['method']).upper()
                     itip['sequence'] = int(c['sequence']) if c.has_key('sequence') else 0
+                    itip['recurrence-id'] = c['recurrence-id'].dt if c.has_key('recurrence-id') and hasattr(c['recurrence-id'], 'dt') else None
 
                     if c.has_key('dtstart'):
                         itip['start'] = c['dtstart'].dt
@@ -151,21 +153,23 @@ def check_event_conflict(kolab_event, itip_event):
         return conflict
 
     # don't consider conflict if event has TRANSP:TRANSPARENT
-    if kolab_event.get_transparency():
+    if _is_transparent(kolab_event):
         return conflict
 
     _es = to_dt(kolab_event.get_start())
     _ee = to_dt(kolab_event.get_ical_dtend())  # use iCal style end date: next day for all-day events
+    _ev = kolab_event
 
     # naive loops to check for collisions in (recurring) events
     # TODO: compare recurrence rules directly (e.g. matching time slot or weekday or monthday)
     while not conflict and _es is not None:
         _is = to_dt(itip_event['start'])
         _ie = to_dt(itip_event['end'])
+        _iv = itip_event['xml']
 
         while not conflict and _is is not None:
             # log.debug("* Comparing event dates at %s/%s with %s/%s" % (_es, _ee, _is, _ie), level=9)
-            conflict = check_date_conflict(_es, _ee, _is, _ie)
+            conflict = not _is_transparent(_ev) and not _is_transparent(_iv) and check_date_conflict(_es, _ee, _is, _ie)
             _is = to_dt(itip_event['xml'].get_next_occurence(_is)) if itip_event['xml'].is_recurring() else None
             _ie = to_dt(itip_event['xml'].get_occurence_end_date(_is))
 
@@ -175,6 +179,7 @@ def check_event_conflict(kolab_event, itip_event):
                 if _ix is not None:
                     _is = to_dt(_ix.get_start())
                     _ie = to_dt(_ix.get_end())
+                    _iv = _ix
 
         _es = to_dt(kolab_event.get_next_occurence(_es)) if kolab_event.is_recurring() else None
         _ee = to_dt(kolab_event.get_occurence_end_date(_es))
@@ -185,10 +190,15 @@ def check_event_conflict(kolab_event, itip_event):
             if _ex is not None:
                 _es = to_dt(_ex.get_start())
                 _ee = to_dt(_ex.get_end())
+                _ev = _ex
 
     return conflict
 
 
+def _is_transparent(event):
+    return event.get_transparency() or event.get_status() == kolabformat.StatusCancelled
+
+
 def check_date_conflict(_es, _ee, _is, _ie):
     """
         Check the given event start/end dates for conflicts
diff --git a/tests/unit/test-011-itip.py b/tests/unit/test-011-itip.py
index dafa645..80b9df6 100644
--- a/tests/unit/test-011-itip.py
+++ b/tests/unit/test-011-itip.py
@@ -461,13 +461,21 @@ class TestITip(unittest.TestCase):
         event5.set_start(datetime.datetime(2012,7,9, 10,0,0, tzinfo=pytz.timezone("Europe/London")))
         event5.set_end(datetime.datetime(2012,7,9, 11,0,0, tzinfo=pytz.timezone("Europe/London")))
 
-        exception = Event(from_string=str(event5))
+        event_xml = str(event5)
+        exception = Event(from_string=event_xml)
         exception.set_start(datetime.datetime(2012,7,13, 14,0,0, tzinfo=pytz.timezone("Europe/London")))
         exception.set_end(datetime.datetime(2012,7,13, 16,0,0, tzinfo=pytz.timezone("Europe/London")))
         exception.set_recurrence_id(datetime.datetime(2012,7,13, 10,0,0, tzinfo=pytz.timezone("Europe/London")), False)
         event5.add_exception(exception)
         self.assertFalse(itip.check_event_conflict(event5, itip_event), "No conflict with exception date")
 
+        exception = Event(from_string=event_xml)
+        exception.set_start(datetime.datetime(2012,7,13, 10,0,0, tzinfo=pytz.timezone("Europe/London")))
+        exception.set_end(datetime.datetime(2012,7,13, 11,0,0, tzinfo=pytz.timezone("Europe/London")))
+        exception.set_status('CANCELLED')
+        exception.set_recurrence_id(datetime.datetime(2012,7,13, 10,0,0, tzinfo=pytz.timezone("Europe/London")), False)
+        event5.add_exception(exception)
+        self.assertFalse(itip.check_event_conflict(event5, itip_event), "No conflict with cancelled exception")
 
     def test_003_send_reply(self):
         itip_events = itip.events_from_message(message_from_string(itip_non_multipart))





More information about the commits mailing list