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

Thomas Brüderli bruederli at kolabsys.com
Fri Jul 18 18:20:11 CEST 2014


 pykolab/itip/__init__.py                                   |   31 ++--
 pykolab/xml/event.py                                       |   41 +++--
 tests/functional/test_wallace/test_007_invitationpolicy.py |   94 ++++++++++++-
 wallace/module_invitationpolicy.py                         |   71 +++++++--
 4 files changed, 186 insertions(+), 51 deletions(-)

New commits:
commit ebcc6748bf105fda1183bce3242260b867271809
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 7 21:36:59 2014 -0400

    Simplify code, get rid of exec() calls; allow to set RSVP flag wen updtading a participant's status

diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py
index f9b6487..65eb818 100644
--- a/pykolab/xml/event.py
+++ b/pykolab/xml/event.py
@@ -126,29 +126,31 @@ class Event(object):
 
         # NOTE: Make sure to list(set()) or duplicates may arise
         for attr in list(set(event.singletons)):
-            if hasattr(self, 'get_ical_%s' % (attr.lower())):
-                exec("retval = self.get_ical_%s()" % (attr.lower()))
+            ical_getter = 'get_ical_%s' % (attr.lower())
+            default_getter = 'get_%s' % (attr.lower())
+            retval = None
+            if hasattr(self, ical_getter):
+                retval = getattr(self, ical_getter)()
                 if not retval == None and not retval == "":
                     event.add(attr.lower(), retval)
-
-            elif hasattr(self, 'get_%s' % (attr.lower())):
-                exec("retval = self.get_%s()" % (attr.lower()))
+            elif hasattr(self, default_getter):
+                retval = getattr(self, default_getter)()
                 if not retval == None and not retval == "":
                     event.add(attr.lower(), retval, encode=0)
 
         # NOTE: Make sure to list(set()) or duplicates may arise
         for attr in list(set(event.multiple)):
-            if hasattr(self, 'get_ical_%s' % (attr.lower())):
-                exec("retval = self.get_ical_%s()" % (attr.lower()))
-                if isinstance(retval, list) and not len(retval) == 0:
-                    for _retval in retval:
-                        event.add(attr.lower(), _retval, encode=0)
-
-            elif hasattr(self, 'get_%s' % (attr.lower())):
-                exec("retval = self.get_%s()" % (attr.lower()))
-                if isinstance(retval, list) and not len(retval) == 0:
-                    for _retval in retval:
-                        event.add(attr.lower(), _retval, encode=0)
+            ical_getter = 'get_ical_%s' % (attr.lower())
+            default_getter = 'get_%s' % (attr.lower())
+            retval = None
+            if hasattr(self, ical_getter):
+                retval = getattr(self, ical_getter)()
+            elif hasattr(self, default_getter):
+                retval = getattr(self, default_getter)()
+
+            if isinstance(retval, list) and not len(retval) == 0:
+                for _retval in retval:
+                    event.add(attr.lower(), _retval, encode=0)
 
         cal.add_component(event)
 
@@ -491,7 +493,7 @@ class Event(object):
     def get_transparency(self):
         return self.event.transparency()
 
-    def set_attendee_participant_status(self, attendee, status):
+    def set_attendee_participant_status(self, attendee, status, rsvp=None):
         """
             Set the participant status of an attendee to status.
 
@@ -500,8 +502,11 @@ class Event(object):
             attendees for this event.
         """
         attendee = self.get_attendee(attendee)
-
         attendee.set_participant_status(status)
+
+        if rsvp is not None:
+            attendee.set_rsvp(rsvp)
+
         self.event.setAttendees(self._attendees)
 
     def set_status(self, status):
diff --git a/wallace/module_invitationpolicy.py b/wallace/module_invitationpolicy.py
index 9488dd1..ec3ad44 100644
--- a/wallace/module_invitationpolicy.py
+++ b/wallace/module_invitationpolicy.py
@@ -419,7 +419,7 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
 
             log.debug(_("Auto-updating event %r on iTip REPLY") % (existing.uid), level=8)
             try:
-                existing.set_attendee_participant_status(sender_email, sender_attendee.get_participant_status(), False)
+                existing.set_attendee_participant_status(sender_email, sender_attendee.get_participant_status(), rsvp=False)
             except Exception, e:
                 log.error("Could not find corresponding attende in organizer's event: %r" % (e))
 


commit e6ee15781cf3a3a8d0606351683438a5281e6530
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 7 21:34:38 2014 -0400

    Implement participant status updates propagated to all attendee's calendars

diff --git a/tests/functional/test_wallace/test_007_invitationpolicy.py b/tests/functional/test_wallace/test_007_invitationpolicy.py
index 0d2875c..2b669ff 100644
--- a/tests/functional/test_wallace/test_007_invitationpolicy.py
+++ b/tests/functional/test_wallace/test_007_invitationpolicy.py
@@ -177,6 +177,11 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
             'kolabinvitationpolicy': ['ACT_TENTATIVE_IF_NO_CONFLICT','ACT_SAVE_TO_CALENDAR','ACT_UPDATE']
         }
 
+        self.external = {
+            'displayname': 'Bob External',
+            'mail': 'bob.external at gmail.com'
+        }
+
         from tests.functional.user_add import user_add
         user_add("John", "Doe", kolabinvitationpolicy=self.john['kolabinvitationpolicy'], preferredlanguage=self.john['preferredlanguage'])
         user_add("Jane", "Manager", kolabinvitationpolicy=self.jane['kolabinvitationpolicy'], preferredlanguage=self.jane['preferredlanguage'])
@@ -239,7 +244,7 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
         return uid
 
-    def send_itip_reply(self, uid, attendee_email, mailto, start=None, template=None, summary="test", sequence=1, partstat='ACCEPTED'):
+    def send_itip_reply(self, uid, attendee_email, mailto, start=None, template=None, summary="test", sequence=0, partstat='ACCEPTED'):
         if start is None:
             start = datetime.datetime.now()
 
@@ -586,3 +591,59 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.assertIn(self.jack['mail'], notification_text)
         self.assertNotIn(_("PENDING"), notification_text)
 
+
+    def test_009_outdated_reply(self):
+        self.purge_mailbox(self.john['mailbox'])
+
+        start = datetime.datetime(2014,9,2, 11,0,0, tzinfo=pytz.timezone("Europe/Berlin"))
+        uid = self.create_calendar_event(start, user=self.john, sequence=2)
+
+        # send a reply from jane to john
+        self.send_itip_reply(uid, self.jane['mail'], self.john['mail'], start=start, sequence=1)
+
+        # verify jane's attendee status was not updated
+        time.sleep(10)
+        event = self.check_user_calendar_event(self.john['kolabtargetfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(event.get_sequence(), 2)
+        self.assertEqual(event.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartNeedsAction)
+
+
+    def test_010_partstat_update_propagation(self):
+        # ATTENTION: this test requires wallace.invitationpolicy_autoupdate_other_attendees_on_reply to be enabled in config
+
+        start = datetime.datetime(2014,8,21, 13,0,0, tzinfo=pytz.timezone("Europe/Berlin"))
+        uid = self.create_calendar_event(start, user=self.john, attendees=[self.jane, self.jack, self.external])
+
+        event = self.check_user_calendar_event(self.john['kolabtargetfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+
+        # send invitations to jack and jane
+        event_itip = event.as_string_itip()
+        self.send_itip_invitation(self.jane['mail'], start, template=event_itip)
+        self.send_itip_invitation(self.jack['mail'], start, template=event_itip)
+
+        # send replies from jack and jane
+        # FIXME: replies should not be necessary if auto-replies get through wallace as well
+        self.send_itip_reply(uid, self.jane['mail'], self.john['mail'], start=start, partstat='ACCEPTED')
+        time.sleep(10)  # FIXME: implement locking in wallace
+        self.send_itip_reply(uid, self.jack['mail'], self.john['mail'], start=start, partstat='TENTATIVE')
+
+        # wait for replies to be processed and propagated
+        time.sleep(10)
+        event = self.check_user_calendar_event(self.john['kolabtargetfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+
+        # check updated event in organizer's calendar
+        self.assertEqual(event.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted)
+        self.assertEqual(event.get_attendee(self.jack['mail']).get_participant_status(), kolabformat.PartTentative)
+
+        # check updated partstats in jane's calendar
+        janes = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid)
+        self.assertEqual(janes.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted)
+        self.assertEqual(janes.get_attendee(self.jack['mail']).get_participant_status(), kolabformat.PartTentative)
+
+        # check updated partstats in jack's calendar
+        jacks = self.check_user_calendar_event(self.jack['kolabtargetfolder'], uid)
+        self.assertEqual(jacks.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted)
+        self.assertEqual(jacks.get_attendee(self.jack['mail']).get_participant_status(), kolabformat.PartTentative)
diff --git a/wallace/module_invitationpolicy.py b/wallace/module_invitationpolicy.py
index 41917da..9488dd1 100644
--- a/wallace/module_invitationpolicy.py
+++ b/wallace/module_invitationpolicy.py
@@ -316,7 +316,7 @@ 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_event(itip_event, receiving_user)
+    existing = find_existing_event(itip_event['uid'], receiving_user)
 
     # compare sequence number to determine a (re-)scheduling request
     if existing is not None:
@@ -406,14 +406,20 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
             return MESSAGE_FORWARD
 
         # find existing event in user's calendar
-        existing = find_existing_event(itip_event, receiving_user)
+        # TODO: set/check lock to avoid concurrent wallace processes trying to update the same event simultaneously
+        existing = find_existing_event(itip_event['uid'], receiving_user)
 
         if existing:
-            log.debug(_("Auto-updating event %r on iTip REPLY") % (existing.uid), level=8)
+            # 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 event version (%r). Forwarding to Inbox.") % (
+                    itip_event['sequence'], existing.get_sequence()
+                ))
+                return MESSAGE_FORWARD
 
-            # TODO: compare sequence number to avoid outdated replies?
+            log.debug(_("Auto-updating event %r on iTip REPLY") % (existing.uid), level=8)
             try:
-                existing.set_attendee_participant_status(sender_email, sender_attendee.get_participant_status())
+                existing.set_attendee_participant_status(sender_email, sender_attendee.get_participant_status(), False)
             except Exception, e:
                 log.error("Could not find corresponding attende in organizer's event: %r" % (e))
 
@@ -425,7 +431,10 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
                 if policy & COND_NOTIFY:
                     send_reply_notification(existing, receiving_user)
 
-                # TODO: update all other attendee's copies if conf.get('wallace','invitationpolicy_autoupdate_other_attendees_on_reply'):
+                # update all other attendee's copies
+                if conf.get('wallace','invitationpolicy_autoupdate_other_attendees_on_reply'):
+                    propagate_changes_to_attendees_calendars(existing)
+
                 return MESSAGE_PROCESSED
 
         else:
@@ -448,7 +457,7 @@ 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 event in user's calendar
-        existing = find_existing_event(itip_event, receiving_user)
+        existing = find_existing_event(itip_event['uid'], receiving_user)
 
         if existing:
             existing.set_status('CANCELLED')
@@ -606,7 +615,7 @@ def list_user_calendars(user_rec):
     return calendars
 
 
-def find_existing_event(itip_event, user_rec):
+def find_existing_event(uid, user_rec):
     """
         Search user's calendar folders for the given event (by UID)
     """
@@ -614,10 +623,10 @@ def find_existing_event(itip_event, user_rec):
 
     event = None
     for folder in list_user_calendars(user_rec):
-        log.debug(_("Searching folder %r for event %r") % (folder, itip_event['uid']), level=8)
+        log.debug(_("Searching folder %r for event %r") % (folder, uid), level=8)
         imap.imap.m.select(imap.folder_utf7(folder))
 
-        typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (itip_event['uid']))
+        typ, 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)')
 
@@ -628,7 +637,7 @@ def find_existing_event(itip_event, user_rec):
                 log.error(_("Failed to parse event from message %s/%s: %r") % (folder, num, e))
                 continue
 
-            if event and event.uid == itip_event['uid']:
+            if event and event.uid == uid:
                 return event
 
     return event
@@ -660,7 +669,6 @@ def check_availability(itip_event, receiving_user):
 
             try:
                 event = event_from_message(message_from_string(data[0][1]))
-                setattr(event, '_imap_folder', folder)
             except Exception, e:
                 log.error(_("Failed to parse event from message %s/%s: %r") % (folder, num, e))
                 continue
@@ -811,6 +819,29 @@ def send_reply_notification(event, receiving_user):
     smtp.quit()
 
 
+def propagate_changes_to_attendees_calendars(event):
+    """
+        Find and update copies of this event in all attendee's calendars
+    """
+    for attendee in event.get_attendees():
+        attendee_user_dn = user_dn_from_email_address(attendee.get_email())
+        if attendee_user_dn is not None:
+            log.debug(_("Update attendee copy of %r") % (attendee_user_dn), level=9)
+
+            attendee_user = auth.get_entry_attributes(None, attendee_user_dn, ['*'])
+            attendee_event = find_existing_event(event.uid, attendee_user)  # does IMAP authenticate
+            if attendee_event:
+                attendee_event.event.setAttendees(event.get_attendees())
+                success = update_event(attendee_event, attendee_user)
+                log.debug(_("Updated %s's copy of %r: %r") % (attendee_user['mail'], event.uid, success), level=8)
+
+            else:
+                log.debug(_("Attendee %s's copy of %r not found") % (attendee_user['mail'], event.uid), level=8)
+
+        else:
+            log.debug(_("Attendee %r not found in LDAP") % (attendee.get_email()), level=8)
+
+
 def invitation_response_text():
     return _("""
         %(name)s has %(status)s your invitation for %(summary)s.


commit 317c074fc70c972ed068c01e658925512abbc28a
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 7 19:34:07 2014 -0400

    Catch potential exceptions while sending iTip replies; set recipient parstat=needs-action when saving new/re-scheduled invitations directly to calendar

diff --git a/pykolab/itip/__init__.py b/pykolab/itip/__init__.py
index f32cad4..43646df 100644
--- a/pykolab/itip/__init__.py
+++ b/pykolab/itip/__init__.py
@@ -197,13 +197,9 @@ def send_reply(from_address, itip_events, response_text, subject=None):
     """
 
     import smtplib
-    smtp = smtplib.SMTP("localhost", 10027)
 
     conf = pykolab.getConf()
 
-    if conf.debuglevel > 8:
-        smtp.set_debuglevel(True)
-
     if isinstance(itip_events, dict):
         itip_events = [ itip_events ]
 
@@ -217,12 +213,25 @@ def send_reply(from_address, itip_events, response_text, subject=None):
         if subject is not None:
             subject = subject % { 'summary':event_summary, 'status':_(participant_status), 'name':attendee.get_name() }
 
-        message = itip_event['xml'].to_message_itip(from_address,
-            method="REPLY",
-            participant_status=participant_status,
-            message_text=message_text,
-            subject=subject
-        )
-        smtp.sendmail(message['From'], message['To'], message.as_string())
+        try:
+            message = itip_event['xml'].to_message_itip(from_address,
+                method="REPLY",
+                participant_status=participant_status,
+                message_text=message_text,
+                subject=subject
+            )
+        except Exception, e:
+            log.error(_("Failed to compose iTip reply message: %r") % (e))
+            return
+
+        smtp = smtplib.SMTP("localhost", 10027)
+
+        if conf.debuglevel > 8:
+            smtp.set_debuglevel(True)
+
+        try:
+            smtp.sendmail(message['From'], message['To'], message.as_string())
+        except Exception, e:
+            log.error(_("SMTP sendmail error: %r") % (e))
 
     smtp.quit()
diff --git a/tests/functional/test_wallace/test_007_invitationpolicy.py b/tests/functional/test_wallace/test_007_invitationpolicy.py
index 3b68aef..0d2875c 100644
--- a/tests/functional/test_wallace/test_007_invitationpolicy.py
+++ b/tests/functional/test_wallace/test_007_invitationpolicy.py
@@ -150,7 +150,6 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.john = {
             'displayname': 'John Doe',
             'mail': 'john.doe at example.org',
-            'sender': 'John Doe <john.doe at example.org>',
             'dn': 'uid=doe,ou=People,dc=example,dc=org',
             'preferredlanguage': 'en_US',
             'mailbox': 'user/john.doe at example.org',
@@ -161,7 +160,6 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.jane = {
             'displayname': 'Jane Manager',
             'mail': 'jane.manager at example.org',
-            'sender': 'Jane Manager <jane.manager at example.org>',
             'dn': 'uid=manager,ou=People,dc=example,dc=org',
             'preferredlanguage': 'en_US',
             'mailbox': 'user/jane.manager at example.org',
@@ -172,7 +170,6 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.jack = {
             'displayname': 'Jack Tentative',
             'mail': 'jack.tentative at example.org',
-            'sender': 'Jack Tentative <jack.tentative at example.org>',
             'dn': 'uid=tentative,ou=People,dc=example,dc=org',
             'preferredlanguage': 'en_US',
             'mailbox': 'user/jack.tentative at example.org',
@@ -486,7 +483,32 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
 
     def test_005_invite_rescheduling_reject(self):
-        pass
+        self.purge_mailbox(self.john['mailbox'])
+        self.purge_mailbox(self.jack['kolabtargetfolder'])
+
+        start = datetime.datetime(2014,8,9, 17,0,0, tzinfo=pytz.timezone("Europe/Berlin"))
+        uid = self.send_itip_invitation(self.jack['mail'], start)
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':_('TENTATIVE') }, self.jack['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        # send update with new but conflicting date and incremented sequence
+        self.create_calendar_event(datetime.datetime(2014,8,10, 10,30,0, tzinfo=pytz.timezone("Europe/Berlin")), user=self.jack)
+        new_start = datetime.datetime(2014,8,10, 9,30,0, tzinfo=pytz.timezone("Europe/Berlin"))
+        self.send_itip_update(self.jack['mail'], uid, new_start, summary="test (updated)", sequence=1)
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':_('DECLINED') }, self.jack['mail'])
+        self.assertEqual(response, None)
+
+        # verify re-scheduled copy in jack's calendar with NEEDS-ACTION
+        event = self.check_user_calendar_event(self.jack['kolabtargetfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(event.get_start(), new_start)
+        self.assertEqual(event.get_sequence(), 1)
+
+        attendee = event.get_attendee(self.jack['mail'])
+        self.assertTrue(attendee.get_rsvp())
+        self.assertEqual(attendee.get_participant_status(), kolabformat.PartNeedsAction)
 
 
     def test_006_invitation_reply(self):
@@ -563,3 +585,4 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         notification_text = str(notification.get_payload());
         self.assertIn(self.jack['mail'], notification_text)
         self.assertNotIn(_("PENDING"), notification_text)
+
diff --git a/wallace/module_invitationpolicy.py b/wallace/module_invitationpolicy.py
index 7375d2d..41917da 100644
--- a/wallace/module_invitationpolicy.py
+++ b/wallace/module_invitationpolicy.py
@@ -283,10 +283,11 @@ def execute(*args, **kw):
     if done == MESSAGE_PROCESSED:
         log.debug(_("iTip message %r consumed by the invitationpolicy module") % (message.get('Message-ID')), level=5)
         os.unlink(filepath)
-        filepath = None
+        cleanup()
+        return None
 
-    cleanup()
-    return filepath
+    # accept message into the destination inbox
+    accept(filepath)
 
 
 def process_itip_request(itip_event, policy, recipient_email, sender_email, receiving_user):
@@ -357,7 +358,8 @@ def process_itip_request(itip_event, policy, recipient_email, sender_email, rece
                 subject=_('"%(summary)s" has been %(status)s'))
 
         elif policy & ACT_SAVE_TO_CALENDAR:
-            # copy the invitation into the user's calendar with unchanged PARTSTAT
+            # copy the invitation into the user's calendar with PARTSTAT=NEEDS-ACTION
+            itip_event['xml'].set_attendee_participant_status(receiving_attendee, 'NEEDS-ACTION')
             save_event = True
 
         else:
@@ -801,7 +803,11 @@ def send_reply_notification(event, receiving_user):
     if conf.debuglevel > 8:
         smtp.set_debuglevel(True)
 
-    smtp.sendmail(orgemail, receiving_user['mail'], msg.as_string())
+    try:
+        smtp.sendmail(orgemail, receiving_user['mail'], msg.as_string())
+    except Exception, e:
+        log.error(_("SMTP sendmail error: %r") % (e))
+
     smtp.quit()
 
 




More information about the commits mailing list