6 commits - po/de_DE.po po/de.po po/en.po pykolab/itip pykolab/translate.py pykolab/xml tests/functional tests/unit wallace/module_invitationpolicy.py

Thomas Brüderli bruederli at kolabsys.com
Thu Jul 17 17:16:18 CEST 2014


 po/de.po                                                   |    2 
 po/de_DE.po                                                |    2 
 po/en.po                                                   |   15 
 pykolab/itip/__init__.py                                   |    2 
 pykolab/translate.py                                       |   16 
 pykolab/xml/attendee.py                                    |   13 
 pykolab/xml/event.py                                       |    1 
 tests/functional/test_wallace/test_007_invitationpolicy.py |  130 +++-
 tests/unit/test-011-itip.py                                |  400 +++++++++++++
 tests/unit/test-011-wallace_resources.py                   |  208 ------
 tests/unit/test-015-translate.py                           |   25 
 wallace/module_invitationpolicy.py                         |  110 ++-
 12 files changed, 674 insertions(+), 250 deletions(-)

New commits:
commit cfc64210ee11c8e48e83a534059159e649d92906
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 7 08:14:53 2014 -0400

    Implement (basic) notification to organizer when processing iTip REPLY messages from attendees

diff --git a/pykolab/xml/attendee.py b/pykolab/xml/attendee.py
index 56699ce..579158e 100644
--- a/pykolab/xml/attendee.py
+++ b/pykolab/xml/attendee.py
@@ -132,8 +132,17 @@ class Attendee(kolabformat.Attendee):
     def get_name(self):
         return self.contactreference.get_name()
 
-    def get_participant_status(self):
-        return self.partStat()
+    def get_displayname(self):
+        name = self.contactreference.get_name()
+        email = self.contactreference.get_email()
+        return "%s <%s>" % (name, email) if name is not None else email
+
+    def get_participant_status(self, translated=False):
+        partstat = self.partStat()
+        if translated:
+            partstat_name_map = dict([(v, k) for (k, v) in self.participant_status_map.iteritems()])
+            return partstat_name_map[partstat] if partstat_name_map.has_key(partstat) else 'UNKNOWN'
+        return partstat
 
     def get_role(self):
         return self.role()
diff --git a/tests/functional/test_wallace/test_007_invitationpolicy.py b/tests/functional/test_wallace/test_007_invitationpolicy.py
index 10a377f..4fd61a7 100644
--- a/tests/functional/test_wallace/test_007_invitationpolicy.py
+++ b/tests/functional/test_wallace/test_007_invitationpolicy.py
@@ -10,6 +10,7 @@ import kolabformat
 from pykolab.imap import IMAP
 from wallace import module_resources
 
+from pykolab.translate import _
 from email import message_from_string
 from twisted.trial import unittest
 
@@ -129,6 +130,7 @@ Content-Transfer-Encoding: 8bit
 class TestWallaceInvitationpolicy(unittest.TestCase):
 
     john = None
+    itip_reply_subject = None
 
     @classmethod
     def setUp(self):
@@ -139,6 +141,8 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
     @classmethod
     def setup_class(self, *args, **kw):
+        self.itip_reply_subject = _('"%(summary)s" has been %(status)s')
+
         from tests.functional.purge_users import purge_users
         purge_users()
 
@@ -147,9 +151,10 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
             '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',
             'kolabtargetfolder': 'user/john.doe/Calendar at example.org',
-            'kolabinvitationpolicy': ['ACT_UPDATE', 'ACT_MANUAL']
+            'kolabinvitationpolicy': ['ACT_UPDATE_AND_NOTIFY','ACT_MANUAL']
         }
 
         self.jane = {
@@ -157,14 +162,27 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
             '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',
             'kolabtargetfolder': 'user/jane.manager/Calendar at example.org',
-            'kolabinvitationpolicy': ['ACT_ACCEPT_IF_NO_CONFLICT','ACT_REJECT_IF_CONFLICT', 'ACT_UPDATE']
+            'kolabinvitationpolicy': ['ACT_ACCEPT_IF_NO_CONFLICT','ACT_REJECT_IF_CONFLICT','ACT_UPDATE']
+        }
+
+        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',
+            'kolabtargetfolder': 'user/jack.tentative/Calendar at example.org',
+            'kolabinvitationpolicy': ['ACT_TENTATIVE_IF_NO_CONFLICT','ACT_SAVE_TO_CALENDAR','ACT_UPDATE']
         }
 
         from tests.functional.user_add import user_add
-        user_add("John", "Doe", kolabinvitationpolicy=self.john['kolabinvitationpolicy'])
-        user_add("Jane", "Manager", kolabinvitationpolicy=self.jane['kolabinvitationpolicy'])
+        user_add("John", "Doe", kolabinvitationpolicy=self.john['kolabinvitationpolicy'], preferredlanguage=self.john['preferredlanguage'])
+        user_add("Jane", "Manager", kolabinvitationpolicy=self.jane['kolabinvitationpolicy'], preferredlanguage=self.jane['preferredlanguage'])
+        user_add("Jack", "Tentative", kolabinvitationpolicy=self.jack['kolabinvitationpolicy'], preferredlanguage=self.jack['preferredlanguage'])
 
         time.sleep(1)
         from tests.functional.synchronize import synchronize_once
@@ -223,7 +241,7 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
         return uid
 
-    def send_itip_reply(self, uid, mailto, attendee_email, 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=1, partstat='ACCEPTED'):
         if start is None:
             start = datetime.datetime.now()
 
@@ -256,13 +274,13 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
         return uid
 
-    def create_calendar_event(self, start=None, summary="test", sequence=0, user=None, attendee=None):
+    def create_calendar_event(self, start=None, summary="test", sequence=0, user=None, attendees=None):
         if start is None:
             start = datetime.datetime.now(pytz.timezone("Europe/Berlin"))
         if user is None:
             user = self.john
-        if attendee is None:
-            attendee = self.jane
+        if attendees is None:
+            attendees = [self.jane]
 
         end = start + datetime.timedelta(hours=4)
 
@@ -270,7 +288,10 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         event.set_start(start)
         event.set_end(end)
         event.set_organizer(user['mail'], user['displayname'])
-        event.add_attendee(attendee['mail'], attendee['displayname'], role="REQ-PARTICIPANT", participant_status="NEEDS-ACTION", rsvp=True)
+
+        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)
 
@@ -372,11 +393,11 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         imap.disconnect()
 
 
-    def test_001_invite_user(self):
+    def test_001_invite_accept_udate(self):
         start = datetime.datetime(2014,8,13, 10,0,0)
         uid = self.send_itip_invitation(self.jane['mail'], start)
 
-        response = self.check_message_received('"test" has been ACCEPTED', self.jane['mail'])
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':_('ACCEPTED') }, self.jane['mail'])
         self.assertIsInstance(response, email.message.Message)
 
         event = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid)
@@ -394,10 +415,10 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
 
 
     # @depends on test_001_invite_user
-    def test_002_invite_conflict(self):
+    def test_002_invite_conflict_reject(self):
         uid = self.send_itip_invitation(self.jane['mail'], datetime.datetime(2014,8,13, 11,0,0), summary="test2")
 
-        response = self.check_message_received('"test2" has been DECLINED', self.jane['mail'])
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test2', 'status':_('DECLINED') }, self.jane['mail'])
         self.assertIsInstance(response, email.message.Message)
 
         event = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid)
@@ -405,11 +426,40 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.assertEqual(event.get_summary(), "test2")
 
 
-    def test_003_invite_rescheduling(self):
+    def test_003_invite_accept_tentative(self):
+        self.purge_mailbox(self.john['mailbox'])
+
+        uid = self.send_itip_invitation(self.jack['mail'], datetime.datetime(2014,7,24, 8,0,0))
+
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':_('TENTATIVE') }, self.jack['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+
+    def test_004_copy_to_calendar(self):
+        self.purge_mailbox(self.john['mailbox'])
+
+        self.send_itip_invitation(self.jack['mail'], datetime.datetime(2014,7,29, 8,0,0))
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':_('TENTATIVE') }, self.jack['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        # send conflicting request to jack
+        uid = self.send_itip_invitation(self.jack['mail'], datetime.datetime(2014,7,29, 10,0,0), summary="test2")
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test2', 'status':_('DECLINED') }, self.jack['mail'])
+        self.assertEqual(response, None, "No reply expected")
+
+        event = self.check_user_calendar_event(self.jack['kolabtargetfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(event.get_summary(), "test2")
+        self.assertEqual(event.get_attendee(self.jack['mail']).get_participant_status(), kolabformat.PartNeedsAction)
+
+
+    def test_005_invite_rescheduling_accept(self):
+        self.purge_mailbox(self.john['mailbox'])
+
         start = datetime.datetime(2014,8,14, 9,0,0, tzinfo=pytz.timezone("Europe/Berlin"))
         uid = self.send_itip_invitation(self.jane['mail'], start)
 
-        response = self.check_message_received('"test" has been ACCEPTED', self.jane['mail'])
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':_('ACCEPTED') }, self.jane['mail'])
         self.assertIsInstance(response, email.message.Message)
 
         event = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid)
@@ -422,7 +472,7 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         new_start = datetime.datetime(2014,8,15, 15,0,0, tzinfo=pytz.timezone("Europe/Berlin"))
         self.send_itip_update(self.jane['mail'], uid, new_start, summary="test", sequence=1)
 
-        response = self.check_message_received('"test" has been ACCEPTED', self.jane['mail'])
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':_('ACCEPTED') }, self.jane['mail'])
         self.assertIsInstance(response, email.message.Message)
 
         event = self.check_user_calendar_event(self.jane['kolabtargetfolder'], uid)
@@ -431,7 +481,13 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.assertEqual(event.get_sequence(), 1)
 
 
-    def test_004_invitation_reply(self):
+    def test_005_invite_rescheduling_reject(self):
+        pass
+
+
+    def test_006_invitation_reply(self):
+        self.purge_mailbox(self.john['mailbox'])
+
         start = datetime.datetime(2014,8,18, 14,30,0, tzinfo=pytz.timezone("Europe/Berlin"))
         uid = self.create_calendar_event(start, user=self.john)
 
@@ -439,7 +495,7 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.assertIsInstance(event, pykolab.xml.Event)
 
         # send a reply from jane to john
-        self.send_itip_reply(uid, self.john['mail'], self.jane['mail'], start=start)
+        self.send_itip_reply(uid, self.jane['mail'], self.john['mail'], start=start)
 
         # check for the updated event in john's calendar
         time.sleep(10)
@@ -450,10 +506,13 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.assertIsInstance(attendee, pykolab.xml.Attendee)
         self.assertEqual(attendee.get_participant_status(), kolabformat.PartAccepted)
 
-    def test_005_invitation_cancel(self):
+
+    def test_007_invitation_cancel(self):
+        self.purge_mailbox(self.john['mailbox'])
+
         uid = self.send_itip_invitation(self.jane['mail'], summary="cancelled")
 
-        response = self.check_message_received('"cancelled" has been ACCEPTED', self.jane['mail'])
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'cancelled', 'status':_('ACCEPTED') }, self.jane['mail'])
         self.assertIsInstance(response, email.message.Message)
 
         self.send_itip_cancel(self.jane['mail'], uid, summary="cancelled")
@@ -465,4 +524,33 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.assertEqual(event.get_status(), 'CANCELLED')
         self.assertTrue(event.get_transparency())
 
-        
\ No newline at end of file
+
+    def test_008_inivtation_reply_notify(self):
+        self.purge_mailbox(self.john['mailbox'])
+
+        start = datetime.datetime(2014,8,12, 16,0,0, tzinfo=pytz.timezone("Europe/Berlin"))
+        uid = self.create_calendar_event(start, user=self.john, attendees=[self.jane, self.jack])
+
+        # send a reply from jane to john
+        self.send_itip_reply(uid, self.jane['mail'], self.john['mail'], start=start)
+
+        # check for notification message
+        # TODO: this notification should be suppressed until jack has replied, too
+        notification = self.check_message_received(_('"%s" has been updated') % ('test'), self.john['mail'])
+        self.assertIsInstance(notification, email.message.Message)
+
+        notification_text = str(notification.get_payload());
+        self.assertIn(self.jane['mail'], notification_text)
+        self.assertIn(_("PENDING"), notification_text)
+
+        self.purge_mailbox(self.john['mailbox'])
+
+        # send a reply from jack to john
+        self.send_itip_reply(uid, self.jack['mail'], self.john['mail'], start=start, partstat='TENTATIVE')
+
+        notification = self.check_message_received(_('"%s" has been updated') % ('test'), self.john['mail'])
+        self.assertIsInstance(notification, email.message.Message)
+
+        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 d4ed7d5..a141251 100644
--- a/wallace/module_invitationpolicy.py
+++ b/wallace/module_invitationpolicy.py
@@ -34,6 +34,7 @@ import modules
 import pykolab
 import kolabformat
 
+from pykolab import utils
 from pykolab.auth import Auth
 from pykolab.conf import Conf
 from pykolab.imap import IMAP
@@ -45,21 +46,22 @@ from pykolab.itip import send_reply
 from pykolab.translate import _
 
 # define some contstants used in the code below
-MOD_IF_AVAILABLE   = 32
-MOD_IF_CONFLICT    = 64
-MOD_TENTATIVE      = 128
-MOD_NOTIFY         = 256
+COND_IF_AVAILABLE  = 32
+COND_IF_CONFLICT   = 64
+COND_TENTATIVE     = 128
+COND_NOTIFY        = 256
 ACT_MANUAL         = 1
 ACT_ACCEPT         = 2
 ACT_DELEGATE       = 4
 ACT_REJECT         = 8
 ACT_UPDATE         = 16
-ACT_TENTATIVE                = ACT_ACCEPT + MOD_TENTATIVE
-ACT_ACCEPT_IF_NO_CONFLICT    = ACT_ACCEPT + MOD_IF_AVAILABLE
-ACT_TENTATIVE_IF_NO_CONFLICT = ACT_ACCEPT + MOD_TENTATIVE + MOD_IF_AVAILABLE
-ACT_DELEGATE_IF_CONFLICT     = ACT_DELEGATE + MOD_IF_CONFLICT
-ACT_REJECT_IF_CONFLICT       = ACT_REJECT + MOD_IF_CONFLICT
-ACT_UPDATE_AND_NOTIFY        = ACT_UPDATE + MOD_NOTIFY
+ACT_TENTATIVE                = ACT_ACCEPT + COND_TENTATIVE
+ACT_ACCEPT_IF_NO_CONFLICT    = ACT_ACCEPT + COND_IF_AVAILABLE
+ACT_TENTATIVE_IF_NO_CONFLICT = ACT_ACCEPT + COND_TENTATIVE + COND_IF_AVAILABLE
+ACT_DELEGATE_IF_CONFLICT     = ACT_DELEGATE + COND_IF_CONFLICT
+ACT_REJECT_IF_CONFLICT       = ACT_REJECT + COND_IF_CONFLICT
+ACT_UPDATE_AND_NOTIFY        = ACT_UPDATE + COND_NOTIFY
+ACT_SAVE_TO_CALENDAR         = 512
 
 FOLDER_TYPE_ANNOTATION = '/vendor/kolab/folder-type'
 
@@ -77,7 +79,8 @@ policy_name_map = {
     'ACT_REJECT':                   ACT_REJECT,
     'ACT_REJECT_IF_CONFLICT':       ACT_REJECT_IF_CONFLICT,
     'ACT_UPDATE':                   ACT_UPDATE,
-    'ACT_UPDATE_AND_NOTIFY':        ACT_UPDATE_AND_NOTIFY
+    'ACT_UPDATE_AND_NOTIFY':        ACT_UPDATE_AND_NOTIFY,
+    'ACT_SAVE_TO_CALENDAR':         ACT_SAVE_TO_CALENDAR
 }
 
 policy_value_map = dict([(v, k) for (k, v) in policy_name_map.iteritems()])
@@ -241,7 +244,11 @@ def execute(*args, **kw):
         return filepath
 
     receiving_user = auth.get_entry_attributes(None, recipient_user_dn, ['*'])
-    log.debug(_("Receiving user: %r") % (receiving_user), level=9)
+    log.debug(_("Receiving user: %r") % (receiving_user), level=8)
+
+    # change gettext language to the preferredlanguage setting of the receiving user
+    if receiving_user.has_key('preferredlanguage'):
+        pykolab.translate.setUserLanguage(receiving_user['preferredlanguage'])
 
     # find user's kolabInvitationPolicy settings and the matching policy values
     sender_domain = str(sender_email).split('@')[-1]
@@ -318,9 +325,9 @@ def process_itip_request(itip_event, policy, recipient_email, sender_email, rece
 
     # if scheduling: check availability
     if scheduling_required:
-        if policy & (MOD_IF_AVAILABLE | MOD_IF_CONFLICT):
+        if policy & (COND_IF_AVAILABLE | COND_IF_CONFLICT):
             condition_fulfilled = check_availability(itip_event, receiving_user)
-        if policy & MOD_IF_CONFLICT:
+        if policy & COND_IF_CONFLICT:
             condition_fulfilled = not condition_fulfilled
 
         log.debug(_("Precondition for event %r fulfilled: %r") % (itip_event['uid'], condition_fulfilled), level=5)
@@ -329,15 +336,15 @@ def process_itip_request(itip_event, policy, recipient_email, sender_email, rece
     if rsvp or scheduling_required:
         respond_with = None
         if policy & ACT_ACCEPT and condition_fulfilled:
-            respond_with = 'TENTATIVE' if policy & MOD_TENTATIVE else 'ACCEPTED'
+            respond_with = 'TENTATIVE' if policy & COND_TENTATIVE else 'ACCEPTED'
 
         elif policy & ACT_REJECT and condition_fulfilled:
             respond_with = 'DECLINED'
             # TODO: only save declined invitation when a certain config option is set?
 
         elif policy & ACT_DELEGATE and condition_fulfilled:
-            # TODO: save and delegate (but to whom?)
-            pass
+            # TODO: delegate (but to whom?)
+            return None
 
         # send iTip reply
         if respond_with is not None:
@@ -349,9 +356,9 @@ def process_itip_request(itip_event, policy, recipient_email, sender_email, rece
             send_reply(recipient_email, itip_event, invitation_response_text(),
                 subject=_('"%(summary)s" has been %(status)s'))
 
-        # elif partstat == kolabformat.PartNeedsAction and conf.get('wallace','invitationpolicy_always_copy_to_calendar'):
-            # TODO: copy the invitation into the user's calendar with unchanged PARTSTAT
-            # TODO: or use ACT_POSTPONE for this?
+        elif policy & ACT_SAVE_TO_CALENDAR:
+            # copy the invitation into the user's calendar with unchanged PARTSTAT
+            save_event = True
 
         else:
             # policy doesn't match, pass on to next one
@@ -413,7 +420,9 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
 
             # update the organizer's copy of the event
             if update_event(existing, receiving_user):
-                # TODO: send (consolidated) notification to organizer if policy & ACT_UPDATE_AND_NOTIFY:
+                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'):
                 return MESSAGE_PROCESSED
 
@@ -742,6 +751,65 @@ def delete_event(existing):
     imap.imap.m.expunge()
 
 
+def send_reply_notification(event, receiving_user):
+    """
+        Send a (consolidated) notification about the current participant status to organizer
+    """
+    import smtplib
+    from email.MIMEText import MIMEText
+    from email.Utils import formatdate
+
+    log.debug(_("Compose participation status summary for event %r to user %r") % (
+        event.uid, receiving_user['mail']
+    ), level=8)
+
+    partstats = { 'ACCEPTED':[], 'TENTATIVE':[], 'DECLINED':[], 'DELEGATED':[], 'PENDING':[] }
+    for attendee in event.get_attendees():
+        parstat = attendee.get_participant_status(True)
+        if partstats.has_key(parstat):
+            partstats[parstat].append(attendee.get_displayname())
+        else:
+            partstats['PENDING'].append(attendee.get_displayname())
+
+    # TODO: for every attendee, look-up its kolabinvitationpolicy and skip notification
+    # until we got replies from all automatically responding attendees
+
+    roundup = ''
+    for status,attendees in partstats.iteritems():
+        if len(attendees) > 0:
+            roundup += "\n" + _(status) + ":\n" + "\n".join(attendees) + "\n"
+
+    message_text = """
+        The event '%(summary)s' at %(start)s has been updated in your calendar.
+        %(roundup)s
+    """ % {
+        'summary': event.get_summary(),
+        'start': event.get_start().strftime('%Y-%m-%d %H:%M %Z'),
+        'roundup': roundup
+    }
+
+    # compose mime message
+    msg = MIMEText(utils.stripped_message(message_text))
+
+    msg['To'] = receiving_user['mail']
+    msg['Date'] = formatdate(localtime=True)
+    msg['Subject'] = _('"%s" has been updated') % (event.get_summary())
+
+    organizer = event.get_organizer()
+    orgemail = organizer.email()
+    orgname = organizer.name()
+
+    msg['From'] = '"%s" <%s>' % (orgname, orgemail) if orgname else orgemail
+
+    smtp = smtplib.SMTP("localhost", 10027)
+
+    if conf.debuglevel > 8:
+        smtp.set_debuglevel(True)
+
+    smtp.sendmail(orgemail, receiving_user['mail'], msg.as_string())
+    smtp.quit()
+
+
 def invitation_response_text():
     return _("""
         %(name)s has %(status)s your invitation for %(summary)s.


commit cf500d4b24cf865d77bf09e2bf149da2cf09421a
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 7 07:21:27 2014 -0400

    Add function to change user language; add en.po for English localization

diff --git a/po/de.po b/po/de.po
index ff7abe2..b49222d 100644
--- a/po/de.po
+++ b/po/de.po
@@ -721,7 +721,7 @@ msgstr ""
 #: ../pykolab/cli/cmd_set_mailbox_acl.py:54
 #: ../pykolab/cli/cmd_set_mailbox_metadata.py:54
 msgid "Folder name"
-msgstr ""
+msgstr "Ordnername"
 
 #: ../pykolab/cli/cmd_delete_mailbox_acl.py:60
 #: ../pykolab/cli/cmd_list_mailbox_acls.py:52
diff --git a/po/de_DE.po b/po/de_DE.po
index 03f16f2..e849ee9 100644
--- a/po/de_DE.po
+++ b/po/de_DE.po
@@ -686,7 +686,7 @@ msgstr ""
 #: ../pykolab/cli/cmd_set_mailbox_acl.py:54
 #: ../pykolab/cli/cmd_set_mailbox_metadata.py:65
 msgid "Folder name"
-msgstr ""
+msgstr "Ordnername"
 
 #: ../pykolab/cli/cmd_delete_mailbox_acl.py:60
 #: ../pykolab/cli/cmd_list_mailbox_acls.py:54
diff --git a/po/en.po b/po/en.po
new file mode 100644
index 0000000..da6a905
--- /dev/null
+++ b/po/en.po
@@ -0,0 +1,15 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
+# This file is distributed under the same license as the PACKAGE package.
+# 
+msgid ""
+msgstr ""
+"Project-Id-Version: Kolab Groupware Solution\n"
+"Report-Msgid-Bugs-To: https://isues.kolab.org/\n"
+"POT-Creation-Date: 2014-07-17 10:22+0100\n"
+"PO-Revision-Date: 2014-07-14 11:13+0000\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: en\n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
diff --git a/pykolab/translate.py b/pykolab/translate.py
index bee8fc2..85f4516 100644
--- a/pykolab/translate.py
+++ b/pykolab/translate.py
@@ -28,6 +28,8 @@ import os
 N_ = lambda x: x
 _ = lambda x: gettext.ldgettext(domain, x)
 
+#gettext.bindtextdomain(domain, '/usr/local/share/locale')
+
 def getDefaultLangs():
     languages = []
     for envar in ('LANGUAGE', 'LC_ALL', 'LC_MESSAGES', 'LANG'):
@@ -45,3 +47,17 @@ def getDefaultLangs():
             if nelang not in nelangs:
                 nelangs.append(nelang)
     return nelangs
+
+def setUserLanguage(lang):
+    langs = []
+    for l in gettext._expand_lang(lang):
+        if l not in langs:
+            langs.append(l)
+
+    try:
+        translation = gettext.translation(domain, languages=langs)
+        translation.install()
+    except:
+        return False
+
+    return True
diff --git a/tests/unit/test-015-translate.py b/tests/unit/test-015-translate.py
new file mode 100644
index 0000000..8ca9463
--- /dev/null
+++ b/tests/unit/test-015-translate.py
@@ -0,0 +1,25 @@
+import unittest
+import gettext
+from pykolab import translate
+
+class TestTranslate(unittest.TestCase):
+
+    def setUp(self):
+        translate.setUserLanguage('en')
+
+    def test_001_default_langs(self):
+        self.assertTrue(len(translate.getDefaultLangs()) > 0)
+
+    def test_002_translate(self):
+        from pykolab.translate import _
+        self.assertEqual(_("Folder name"), "Folder name")
+
+    def test_003_set_lang(self):
+        from pykolab.translate import _
+        self.assertFalse(translate.setUserLanguage('foo_bar'))
+        self.assertEqual(_("Folder name"), "Folder name")
+        self.assertTrue(translate.setUserLanguage('de_DE'))
+        self.assertEqual(_("Folder name"), "Ordnername")
+
+if __name__ == '__main__':
+    unittest.main()


commit 2dd455c056baad1acc059273645ddf696027b2c8
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 7 03:18:31 2014 -0400

    Fix typo

diff --git a/pykolab/itip/__init__.py b/pykolab/itip/__init__.py
index 42e08dd..f32cad4 100644
--- a/pykolab/itip/__init__.py
+++ b/pykolab/itip/__init__.py
@@ -100,7 +100,7 @@ def objects_from_message(message, objname, methods=None):
 
                     itip['attendees'] = c['attendee']
 
-                    if itip.has_key('attendee') and not isinstance(itip['attendees'], list):
+                    if itip.has_key('attendees') and not isinstance(itip['attendees'], list):
                         itip['attendees'] = [c['attendee']]
 
                     if c.has_key('resources'):


commit 0703a82c800de826245e4bd6da6061a567c2422c
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 7 01:59:03 2014 -0400

    Correctly return list of tuples from patched auth.search_entry_by_attribute() method

diff --git a/tests/unit/test-011-wallace_resources.py b/tests/unit/test-011-wallace_resources.py
index ccec4b1..9c42317 100644
--- a/tests/unit/test-011-wallace_resources.py
+++ b/tests/unit/test-011-wallace_resources.py
@@ -127,7 +127,7 @@ class TestWallaceResources(unittest.TestCase):
     def _mock_search_entry_by_attribute(self, attr, value, **kw):
         results = []
         if value == "cn=Room 101,ou=Resources,dc=example,dc=org":
-            results.append({ 'dn': 'cn=Rooms,ou=Resources,dc=example,dc=org', attr: value, 'owner': 'uid=doe,ou=People,dc=example,dc=org' })
+            results.append(('cn=Rooms,ou=Resources,dc=example,dc=org', { attr: value, 'owner': 'uid=doe,ou=People,dc=example,dc=org' }))
         return results
 
     def _mock_smtp_init(self, host=None, port=None, local_hostname=None, timeout=0):
diff --git a/wallace/module_resources.py b/wallace/module_resources.py
index 7c23995..f398120 100644
--- a/wallace/module_resources.py
+++ b/wallace/module_resources.py
@@ -809,7 +809,7 @@ def get_resource_owner(resource):
         if not isinstance(collections, list):
             collections = [ collections ]
 
-        for collection in collections:
+        for dn,collection in collections:
             if collection.has_key('owner') and isinstance(collection['owner'], list):
                 owners += collection['owner']
             elif collection.has_key('owner'):


commit d80f5c0fbb69f7b976275563e8b9b9521e9ca55e
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 7 01:49:12 2014 -0400

    Move unit tests for pykolab.itip to a separate file; fix failing wallace module test

diff --git a/tests/unit/test-011-itip.py b/tests/unit/test-011-itip.py
new file mode 100644
index 0000000..abbaa92
--- /dev/null
+++ b/tests/unit/test-011-itip.py
@@ -0,0 +1,400 @@
+import pykolab
+import datetime
+import pytz
+import kolabformat
+
+from pykolab import itip
+from pykolab.xml import Event
+
+from icalendar import Calendar
+from email import message
+from email import message_from_string
+from wallace import module_resources
+from twisted.trial import unittest
+
+# define some iTip MIME messages
+
+itip_multipart = """MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="=_c8894dbdb8baeedacae836230e3436fd"
+From: "Doe, John" <john.doe at example.org>
+Date: Fri, 13 Jul 2012 13:54:14 +0100
+Message-ID: <240fe7ae7e139129e9eb95213c1016d7 at example.org>
+User-Agent: Roundcube Webmail/0.9-0.3.el6.kolab_3.0
+To: resource-collection-car at example.org
+Subject: "test" has been updated
+
+--=_c8894dbdb8baeedacae836230e3436fd
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+
+*test*
+
+--=_c8894dbdb8baeedacae836230e3436fd
+Content-Type: text/calendar; charset=UTF-8; method=REQUEST;
+ name=event.ics
+Content-Disposition: attachment;
+ filename=event.ics
+Content-Transfer-Encoding: quoted-printable
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:626421779C777FBE9C9B85A80D04DDFA-A4BF5BBB9FEAA271
+DTSTAMP:20120713T1254140
+DTSTART;TZID=3DEurope/London:20120713T100000
+DTEND;TZID=3DEurope/London:20120713T110000
+SUMMARY:test
+DESCRIPTION:test
+ORGANIZER;CN=3D"Doe, John":mailto:john.doe at example.org
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailt=
+o:resource-collection-car at example.org
+ATTENDEE;ROLE=3DOPT-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailto:anoth=
+er-resource at example.org
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+
+--=_c8894dbdb8baeedacae836230e3436fd--
+"""
+
+itip_non_multipart = """Return-Path: <john.doe at example.org>
+Sender: john.doe at example.org
+Content-Type: text/calendar; method=REQUEST; charset=UTF-8
+Content-Transfer-Encoding: quoted-printable
+To: resource-collection-car at example.org
+From: john.doe at example.org
+Date: Mon, 24 Feb 2014 11:27:28 +0100
+Message-ID: <1a3aa8995e83dd24cf9247e538ac913a at example.org>
+Subject: test
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:626421779C777FBE9C9B85A80D04DDFA-A4BF5BBB9FEAA271
+DTSTAMP:20120713T1254140
+DTSTART;TZID=3DEurope/London:20120713T100000
+DTEND;TZID=3DEurope/London:20120713T110000
+SUMMARY:test
+DESCRIPTION:test
+ORGANIZER;CN=3D"Doe, John":mailto:john.doe at example.org
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DACCEPTED;RSVP=3DTRUE:mailt=
+o:resource-collection-car at example.org
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+"""
+
+itip_google_multipart = """MIME-Version: 1.0
+Message-ID: <001a11c2ad84243e0604f3246bae at google.com>
+Date: Mon, 24 Feb 2014 10:27:28 +0000
+Subject: =?ISO-8859-1?Q?Invitation=3A_iTip_from_Apple_=40_Mon_Feb_24=2C_2014_12pm_?=
+	=?ISO-8859-1?Q?=2D_1pm_=28Tom_=26_T=E4m=29?=
+From: "john.doe" <john.doe at gmail.com>
+To: <john.sample at example.org>
+Content-Type: multipart/mixed; boundary=001a11c2ad84243df004f3246bad
+
+--001a11c2ad84243df004f3246bad
+Content-Type: multipart/alternative; boundary=001a11c2ad84243dec04f3246bab
+
+--001a11c2ad84243dec04f3246bab
+Content-Type: text/plain; charset=ISO-8859-1; format=flowed; delsp=yes
+
+<some text content here>
+
+--001a11c2ad84243dec04f3246bab
+Content-Type: text/html; charset=ISO-8859-1
+Content-Transfer-Encoding: quoted-printable
+
+<div style=3D""><!-- some HTML message content here --></div>
+--001a11c2ad84243dec04f3246bab
+Content-Type: text/calendar; charset=UTF-8; method=REQUEST
+Content-Transfer-Encoding: 7bit
+
+BEGIN:VCALENDAR
+PRODID:-//Google Inc//Google Calendar 70.9054//EN
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+DTSTART:20140224T110000Z
+DTEND:20140224T120000Z
+DTSTAMP:20140224T102728Z
+ORGANIZER:mailto:kepjllr6mcq7d0959u4cdc7000 at group.calendar.google.com
+UID:0BE2F640-5814-47C9-ABAE-E7E959204E76
+ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE
+ ;X-NUM-GUESTS=0:mailto:kepjllr6mcq7d0959u4cdc7000 at group.calendar.google.com
+ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
+ TRUE;CN=John Sample;X-NUM-GUESTS=0:mailto:john.sample at example.org
+CREATED:20140224T102728Z
+DESCRIPTION:Testing Multipart structure\\nView your event at http://www.goog
+ le.com/calendar/event?action=VIEW&eid=XzYxMTRhY2k2Nm9xMzBiOWw3MG9qOGI5azZ0M
+ WppYmExODkwa2FiYTU2dDJqaWQ5cDY4bzM4aDluNm8gdGhvbWFzQGJyb3RoZXJsaS5jaA&tok=N
+ TIja2VwamxscjZtY3E3ZDA5NTl1NGNkYzcwMDBAZ3JvdXAuY2FsZW5kYXIuZ29vZ2xlLmNvbTkz
+ NTcyYTU2YmUwNWMxNjY0Zjc3OTU0MzhmMDcwY2FhN2NjZjIzYWM&ctz=Europe/Zurich&hl=en
+ .
+LAST-MODIFIED:20140224T102728Z
+LOCATION:
+SEQUENCE:5
+STATUS:CONFIRMED
+SUMMARY:iTip from Apple
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+
+--001a11c2ad84243dec04f3246bab--
+--001a11c2ad84243df004f3246bad
+Content-Type: application/ics; name="invite.ics"
+Content-Disposition: attachment; filename="invite.ics"
+Content-Transfer-Encoding: base64
+
+QkVHSU46VkNBTEVOREFSDQpQUk9ESUQ6LS8vR29vZ2xlIEluYy8vR29vZ2xlIENhbGVuZGFyIDcw
+LjkwNTQvL0VODQpWRVJTSU9OOjIuMA0KQ0FMU0NBTEU6R1JFR09SSUFODQpNRVRIT0Q6UkVRVUVT
+VA0KQkVHSU46VkVWRU5UDQpEVFNUQVJUOjIwMTQwMjI0VDExMDAwMFoNCkRURU5EOjIwMTQwMjI0
+VDEyMDAwMFoNCkRUU1RBTVA6MjAxNDAyMjRUMTAyNzI4Wg0KT1JHQU5JWkVSOm1haWx0bzprZXBq
+bGxyNm1jcTdkMDk1OXU0Y2RjNzAwMEBncm91cC5jYWxlbmRhci5nb29nbGUuY29tDQpVSUQ6MEJF
+MkY2NDAtNTgxNC00N0M5LUFCQUUtRTdFOTU5MjA0RTc2DQpBVFRFTkRFRTtDVVRZUEU9SU5ESVZJ
+RFVBTDtST0xFPVJFUS1QQVJUSUNJUEFOVDtQQVJUU1RBVD1BQ0NFUFRFRDtSU1ZQPVRSVUUNCiA7
+WC1OVU0tR1VFU1RTPTA6bWFpbHRvOmtlcGpsbHI2bWNxN2QwOTU5dTRjZGM3MDAwQGdyb3VwLmNh
+bGVuZGFyLmdvb2dsZS5jb20NCkFUVEVOREVFO0NVVFlQRT1JTkRJVklEVUFMO1JPTEU9UkVRLVBB
+UlRJQ0lQQU5UO1BBUlRTVEFUPU5FRURTLUFDVElPTjtSU1ZQPQ0KIFRSVUU7WC1OVU0tR1VFU1RT
+PTA6bWFpbHRvOnRob21hc0Bicm90aGVybGkuY2gNCkFUVEVOREVFO0NVVFlQRT1JTkRJVklEVUFM
+O1JPTEU9UkVRLVBBUlRJQ0lQQU5UO1BBUlRTVEFUPU5FRURTLUFDVElPTjtSU1ZQPQ0KIFRSVUU7
+Q049VGhvbWFzIEJydWVkZXJsaTtYLU5VTS1HVUVTVFM9MDptYWlsdG86cm91bmRjdWJlQGdtYWls
+LmNvbQ0KQ1JFQVRFRDoyMDE0MDIyNFQxMDI3MjhaDQpERVNDUklQVElPTjpUZXN0aW5nIE11bHRp
+cGFydCBzdHJ1Y3R1cmVcblZpZXcgeW91ciBldmVudCBhdCBodHRwOi8vd3d3Lmdvb2cNCiBsZS5j
+b20vY2FsZW5kYXIvZXZlbnQ/YWN0aW9uPVZJRVcmZWlkPVh6WXhNVFJoWTJrMk5tOXhNekJpT1d3
+M01HOXFPR0k1YXpaME0NCiBXcHBZbUV4T0Rrd2EyRmlZVFUyZERKcWFXUTVjRFk0YnpNNGFEbHVO
+bThnZEdodmJXRnpRR0p5YjNSb1pYSnNhUzVqYUEmdG9rPU4NCiBUSWphMlZ3YW14c2NqWnRZM0Uz
+WkRBNU5UbDFOR05rWXpjd01EQkFaM0p2ZFhBdVkyRnNaVzVrWVhJdVoyOXZaMnhsTG1OdmJUa3oN
+CiBOVGN5WVRVMlltVXdOV014TmpZMFpqYzNPVFUwTXpobU1EY3dZMkZoTjJOalpqSXpZV00mY3R6
+PUV1cm9wZS9adXJpY2gmaGw9ZW4NCiAuDQpMQVNULU1PRElGSUVEOjIwMTQwMjI0VDEwMjcyOFoN
+CkxPQ0FUSU9OOg0KU0VRVUVOQ0U6NQ0KU1RBVFVTOkNPTkZJUk1FRA0KU1VNTUFSWTppVGlwIGZy
+b20gQXBwbGUNClRSQU5TUDpPUEFRVUUNCkVORDpWRVZFTlQNCkVORDpWQ0FMRU5EQVINCg==
+--001a11c2ad84243df004f3246bad--
+"""
+
+itip_application_ics = """MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="=_c8894dbdb8baeedacae836230e3436fd"
+From: "Doe, John" <john.doe at example.org>
+Date: Fri, 13 Jul 2012 13:54:14 +0100
+Message-ID: <240fe7ae7e139129e9eb95213c101622 at example.org>
+User-Agent: Roundcube Webmail/0.9-0.3.el6.kolab_3.0
+To: resource-collection-car at example.org
+Subject: "test" has been updated
+
+--=_c8894dbdb8baeedacae836230e3436fd
+Content-Transfer-Encoding: quoted-printable
+Content-Type: text/plain; charset=UTF-8; format=flowed
+
+<some text here>
+
+--=_c8894dbdb8baeedacae836230e3436fd
+Content-Type: application/ics; charset=UTF-8; method=REQUEST;
+ name=event.ics
+Content-Transfer-Encoding: quoted-printable
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:626421779C777FBE9C9B85A80D04DDFA-A4BF5BBB9FEAA271
+DTSTAMP:20120713T1254140
+DTSTART;TZID=3DEurope/London:20120713T100000
+DTEND;TZID=3DEurope/London:20120713T110000
+SUMMARY:test
+DESCRIPTION:test
+ORGANIZER;CN=3D"Doe, John":mailto:john.doe at example.org
+ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailt=
+o:resource-collection-car at example.org
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+
+--=_c8894dbdb8baeedacae836230e3436fd--
+"""
+
+itip_recurring = """Return-Path: <john.doe at example.org>
+Sender: john.doe at example.org
+Content-Type: text/calendar; method=REQUEST; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+From: john.doe at example.org
+Date: Mon, 24 Feb 2014 11:27:28 +0100
+Message-ID: <1a3aa8995e83dd24cf9247e538ac913a at example.org>
+Subject: Recurring
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Apple Inc.//Mac OS X 10.9.2//EN
+CALSCALE:GREGORIAN
+METHOD:REQUEST
+BEGIN:VEVENT
+UID:dbdb8baeedacae836230e3436fd-5e83dd24cf92
+DTSTAMP:20140213T1254140
+DTSTART;TZID=Europe/London:20120709T100000
+DTEND;TZID=Europe/London:20120709T120000
+RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5
+SUMMARY:Recurring
+ORGANIZER;CN="Doe, John":mailto:john.doe at example.org
+ATTENDEE;ROLE=REQ-PARTICIPANT;CUTYPE=RESOURCE;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:jane at example.com
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+"""
+
+itip_empty = """MIME-Version: 1.0
+Date: Fri, 17 Jan 2014 13:51:50 +0100
+From: <john.doe at example.org>
+User-Agent: Roundcube Webmail/0.9.5
+To: john.sample at example.org
+Subject: "test" has been sent
+Message-ID: <52D92766.5040508 at somedomain.com>
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 7bit
+
+Message plain text goes here...
+"""
+
+conf = pykolab.getConf()
+
+if not hasattr(conf, 'defaults'):
+    conf.finalize_conf()
+
+class TestITip(unittest.TestCase):
+
+    def setUp(self):
+        # intercept calls to smtplib.SMTP.sendmail()
+        import smtplib
+        self.patch(smtplib.SMTP, "__init__", self._mock_smtp_init)
+        self.patch(smtplib.SMTP, "quit", self._mock_nop)
+        self.patch(smtplib.SMTP, "sendmail", self._mock_smtp_sendmail)
+
+        self.smtplog = [];
+
+    def _mock_nop(self, domain=None):
+        pass
+
+    def _mock_smtp_init(self, host=None, port=None, local_hostname=None, timeout=0):
+        pass
+
+    def _mock_smtp_sendmail(self, from_addr, to_addr, message, mail_options=None, rcpt_options=None):
+        self.smtplog.append((from_addr, to_addr, message))
+
+
+    def test_001_itip_events_from_message(self):
+        itips1 = itip.events_from_message(message_from_string(itip_multipart))
+        self.assertEqual(len(itips1), 1, "Multipart iTip message with text/calendar")
+        self.assertEqual(itips1[0]['method'], "REQUEST", "iTip request method property")
+
+        itips2 = itip.events_from_message(message_from_string(itip_non_multipart))
+        self.assertEqual(len(itips2), 1, "Detect non-multipart iTip messages")
+
+        itips3 = itip.events_from_message(message_from_string(itip_application_ics))
+        self.assertEqual(len(itips3), 1, "Multipart iTip message with application/ics attachment")
+
+        itips4 = itip.events_from_message(message_from_string(itip_google_multipart))
+        self.assertEqual(len(itips4), 1, "Multipart iTip message from Google")
+
+        itips5 = itip.events_from_message(message_from_string(itip_empty))
+        self.assertEqual(len(itips5), 0, "Simple plain text message")
+
+        # invalid itip blocks
+        self.assertRaises(Exception, itip.events_from_message, message_from_string(itip_multipart.replace("BEGIN:VEVENT", "")))
+
+        itips6 = itip.events_from_message(message_from_string(itip_multipart.replace("DTSTART;", "X-DTSTART;")))
+        self.assertEqual(len(itips6), 0, "Event with not DTSTART")
+
+        itips7 = itip.events_from_message(message_from_string(itip_non_multipart.replace("METHOD:REQUEST", "METHOD:PUBLISH").replace("method=REQUEST", "method=PUBLISH")))
+        self.assertEqual(len(itips7), 0, "Invalid METHOD")
+
+
+    def test_002_check_date_conflict(self):
+        astart = datetime.datetime(2014,7,13, 10,0,0)
+        aend   = astart + datetime.timedelta(hours=2)
+
+        bstart = datetime.datetime(2014,7,13, 10,0,0)
+        bend   = astart + datetime.timedelta(hours=1)
+        self.assertTrue(itip.check_date_conflict(astart, aend, bstart, bend))
+
+        bstart = datetime.datetime(2014,7,13, 11,0,0)
+        bend   = astart + datetime.timedelta(minutes=30)
+        self.assertTrue(itip.check_date_conflict(astart, aend, bstart, bend))
+
+        bend   = astart + datetime.timedelta(hours=2)
+        self.assertTrue(itip.check_date_conflict(astart, aend, bstart, bend))
+
+        bstart = datetime.datetime(2014,7,13, 12,0,0)
+        bend   = astart + datetime.timedelta(hours=1)
+        self.assertFalse(itip.check_date_conflict(astart, aend, bstart, bend))
+
+        bstart = datetime.datetime(2014,6,13, 10,0,0)
+        bend   = datetime.datetime(2014,6,14, 12,0,0)
+        self.assertFalse(itip.check_date_conflict(astart, aend, bstart, bend))
+
+        bstart = datetime.datetime(2014,7,10, 12,0,0)
+        bend   = datetime.datetime(2014,7,14, 14,0,0)
+        self.assertTrue(itip.check_date_conflict(astart, aend, bstart, bend))
+
+
+    def test_002_check_event_conflict(self):
+        itip_event = itip.events_from_message(message_from_string(itip_non_multipart))[0]
+
+        event = Event()
+        event.set_start(datetime.datetime(2012,7,13, 9,30,0, tzinfo=itip_event['start'].tzinfo))
+        event.set_end(datetime.datetime(2012,7,13, 10,30,0, tzinfo=itip_event['start'].tzinfo))
+
+        self.assertTrue(itip.check_event_conflict(event, itip_event), "Conflicting dates")
+
+        event.set_uid(itip_event['uid'])
+        self.assertFalse(itip.check_event_conflict(event, itip_event), "No conflict for same UID")
+
+        event2 = Event()
+        event2.set_start(datetime.datetime(2012,7,13, 10,0,0, tzinfo=pytz.timezone("US/Central")))
+        event2.set_end(datetime.datetime(2012,7,13, 11,0,0, tzinfo=pytz.timezone("US/Central")))
+
+        self.assertFalse(itip.check_event_conflict(event, itip_event), "No conflict with timezone shift")
+
+        rrule = kolabformat.RecurrenceRule()
+        rrule.setFrequency(kolabformat.RecurrenceRule.Weekly)
+        rrule.setCount(10)
+
+        event3 = Event()
+        event3.set_recurrence(rrule);
+        event3.set_start(datetime.datetime(2012,6,29, 9,30,0, tzinfo=pytz.utc))
+        event3.set_end(datetime.datetime(2012,6,29, 10,30,0, tzinfo=pytz.utc))
+
+        self.assertTrue(itip.check_event_conflict(event3, itip_event), "Conflict in (3rd) recurring event instance")
+
+        itip_event = itip.events_from_message(message_from_string(itip_recurring))[0]
+        self.assertTrue(itip.check_event_conflict(event3, itip_event), "Conflict in two recurring events")
+
+        event4 = Event()
+        event4.set_recurrence(rrule);
+        event4.set_start(datetime.datetime(2012,7,1, 9,30,0, tzinfo=pytz.utc))
+        event4.set_end(datetime.datetime(2012,7,1, 10,30,0, tzinfo=pytz.utc))
+        self.assertFalse(itip.check_event_conflict(event4, itip_event), "No conflict in two recurring events")
+
+
+    def test_003_send_reply(self):
+        itip_events = itip.events_from_message(message_from_string(itip_non_multipart))
+        itip.send_reply("resource-collection-car at example.org", itip_events, "SUMMARY=%(summary)s; STATUS=%(status)s; NAME=%(name)s;")
+
+        self.assertEqual(len(self.smtplog), 1)
+        self.assertEqual(self.smtplog[0][0], 'resource-collection-car at example.org', "From attendee")
+        self.assertEqual(self.smtplog[0][1], 'john.doe at example.org', "To organizer")
+
+        message = message_from_string(self.smtplog[0][2])
+        self.assertEqual(message.get('Subject'), 'Invitation for test was ACCEPTED')
+
+        text = str(message.get_payload(0));
+        self.assertIn('SUMMARY=test', text)
+        self.assertIn('STATUS=ACCEPTED', text)
diff --git a/tests/unit/test-011-wallace_resources.py b/tests/unit/test-011-wallace_resources.py
index bb586f8..ccec4b1 100644
--- a/tests/unit/test-011-wallace_resources.py
+++ b/tests/unit/test-011-wallace_resources.py
@@ -2,6 +2,7 @@ import pykolab
 import logging
 import datetime
 
+from pykolab import itip
 from icalendar import Calendar
 from email import message
 from email import message_from_string
@@ -87,152 +88,6 @@ END:VEVENT
 END:VCALENDAR
 """
 
-itip_google_multipart = """MIME-Version: 1.0
-Message-ID: <001a11c2ad84243e0604f3246bae at google.com>
-Date: Mon, 24 Feb 2014 10:27:28 +0000
-Subject: =?ISO-8859-1?Q?Invitation=3A_iTip_from_Apple_=40_Mon_Feb_24=2C_2014_12pm_?=
-	=?ISO-8859-1?Q?=2D_1pm_=28Tom_=26_T=E4m=29?=
-From: "john.doe" <john.doe at gmail.com>
-To: <john.sample at example.org>
-Content-Type: multipart/mixed; boundary=001a11c2ad84243df004f3246bad
-
---001a11c2ad84243df004f3246bad
-Content-Type: multipart/alternative; boundary=001a11c2ad84243dec04f3246bab
-
---001a11c2ad84243dec04f3246bab
-Content-Type: text/plain; charset=ISO-8859-1; format=flowed; delsp=yes
-
-<some text content here>
-
---001a11c2ad84243dec04f3246bab
-Content-Type: text/html; charset=ISO-8859-1
-Content-Transfer-Encoding: quoted-printable
-
-<div style=3D""><!-- some HTML message content here --></div>
---001a11c2ad84243dec04f3246bab
-Content-Type: text/calendar; charset=UTF-8; method=REQUEST
-Content-Transfer-Encoding: 7bit
-
-BEGIN:VCALENDAR
-PRODID:-//Google Inc//Google Calendar 70.9054//EN
-VERSION:2.0
-CALSCALE:GREGORIAN
-METHOD:REQUEST
-BEGIN:VEVENT
-DTSTART:20140224T110000Z
-DTEND:20140224T120000Z
-DTSTAMP:20140224T102728Z
-ORGANIZER:mailto:kepjllr6mcq7d0959u4cdc7000 at group.calendar.google.com
-UID:0BE2F640-5814-47C9-ABAE-E7E959204E76
-ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;RSVP=TRUE
- ;X-NUM-GUESTS=0:mailto:kepjllr6mcq7d0959u4cdc7000 at group.calendar.google.com
-ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=
- TRUE;CN=John Sample;X-NUM-GUESTS=0:mailto:john.sample at example.org
-CREATED:20140224T102728Z
-DESCRIPTION:Testing Multipart structure\\nView your event at http://www.goog
- le.com/calendar/event?action=VIEW&eid=XzYxMTRhY2k2Nm9xMzBiOWw3MG9qOGI5azZ0M
- WppYmExODkwa2FiYTU2dDJqaWQ5cDY4bzM4aDluNm8gdGhvbWFzQGJyb3RoZXJsaS5jaA&tok=N
- TIja2VwamxscjZtY3E3ZDA5NTl1NGNkYzcwMDBAZ3JvdXAuY2FsZW5kYXIuZ29vZ2xlLmNvbTkz
- NTcyYTU2YmUwNWMxNjY0Zjc3OTU0MzhmMDcwY2FhN2NjZjIzYWM&ctz=Europe/Zurich&hl=en
- .
-LAST-MODIFIED:20140224T102728Z
-LOCATION:
-SEQUENCE:5
-STATUS:CONFIRMED
-SUMMARY:iTip from Apple
-TRANSP:OPAQUE
-END:VEVENT
-END:VCALENDAR
-
---001a11c2ad84243dec04f3246bab--
---001a11c2ad84243df004f3246bad
-Content-Type: application/ics; name="invite.ics"
-Content-Disposition: attachment; filename="invite.ics"
-Content-Transfer-Encoding: base64
-
-QkVHSU46VkNBTEVOREFSDQpQUk9ESUQ6LS8vR29vZ2xlIEluYy8vR29vZ2xlIENhbGVuZGFyIDcw
-LjkwNTQvL0VODQpWRVJTSU9OOjIuMA0KQ0FMU0NBTEU6R1JFR09SSUFODQpNRVRIT0Q6UkVRVUVT
-VA0KQkVHSU46VkVWRU5UDQpEVFNUQVJUOjIwMTQwMjI0VDExMDAwMFoNCkRURU5EOjIwMTQwMjI0
-VDEyMDAwMFoNCkRUU1RBTVA6MjAxNDAyMjRUMTAyNzI4Wg0KT1JHQU5JWkVSOm1haWx0bzprZXBq
-bGxyNm1jcTdkMDk1OXU0Y2RjNzAwMEBncm91cC5jYWxlbmRhci5nb29nbGUuY29tDQpVSUQ6MEJF
-MkY2NDAtNTgxNC00N0M5LUFCQUUtRTdFOTU5MjA0RTc2DQpBVFRFTkRFRTtDVVRZUEU9SU5ESVZJ
-RFVBTDtST0xFPVJFUS1QQVJUSUNJUEFOVDtQQVJUU1RBVD1BQ0NFUFRFRDtSU1ZQPVRSVUUNCiA7
-WC1OVU0tR1VFU1RTPTA6bWFpbHRvOmtlcGpsbHI2bWNxN2QwOTU5dTRjZGM3MDAwQGdyb3VwLmNh
-bGVuZGFyLmdvb2dsZS5jb20NCkFUVEVOREVFO0NVVFlQRT1JTkRJVklEVUFMO1JPTEU9UkVRLVBB
-UlRJQ0lQQU5UO1BBUlRTVEFUPU5FRURTLUFDVElPTjtSU1ZQPQ0KIFRSVUU7WC1OVU0tR1VFU1RT
-PTA6bWFpbHRvOnRob21hc0Bicm90aGVybGkuY2gNCkFUVEVOREVFO0NVVFlQRT1JTkRJVklEVUFM
-O1JPTEU9UkVRLVBBUlRJQ0lQQU5UO1BBUlRTVEFUPU5FRURTLUFDVElPTjtSU1ZQPQ0KIFRSVUU7
-Q049VGhvbWFzIEJydWVkZXJsaTtYLU5VTS1HVUVTVFM9MDptYWlsdG86cm91bmRjdWJlQGdtYWls
-LmNvbQ0KQ1JFQVRFRDoyMDE0MDIyNFQxMDI3MjhaDQpERVNDUklQVElPTjpUZXN0aW5nIE11bHRp
-cGFydCBzdHJ1Y3R1cmVcblZpZXcgeW91ciBldmVudCBhdCBodHRwOi8vd3d3Lmdvb2cNCiBsZS5j
-b20vY2FsZW5kYXIvZXZlbnQ/YWN0aW9uPVZJRVcmZWlkPVh6WXhNVFJoWTJrMk5tOXhNekJpT1d3
-M01HOXFPR0k1YXpaME0NCiBXcHBZbUV4T0Rrd2EyRmlZVFUyZERKcWFXUTVjRFk0YnpNNGFEbHVO
-bThnZEdodmJXRnpRR0p5YjNSb1pYSnNhUzVqYUEmdG9rPU4NCiBUSWphMlZ3YW14c2NqWnRZM0Uz
-WkRBNU5UbDFOR05rWXpjd01EQkFaM0p2ZFhBdVkyRnNaVzVrWVhJdVoyOXZaMnhsTG1OdmJUa3oN
-CiBOVGN5WVRVMlltVXdOV014TmpZMFpqYzNPVFUwTXpobU1EY3dZMkZoTjJOalpqSXpZV00mY3R6
-PUV1cm9wZS9adXJpY2gmaGw9ZW4NCiAuDQpMQVNULU1PRElGSUVEOjIwMTQwMjI0VDEwMjcyOFoN
-CkxPQ0FUSU9OOg0KU0VRVUVOQ0U6NQ0KU1RBVFVTOkNPTkZJUk1FRA0KU1VNTUFSWTppVGlwIGZy
-b20gQXBwbGUNClRSQU5TUDpPUEFRVUUNCkVORDpWRVZFTlQNCkVORDpWQ0FMRU5EQVINCg==
---001a11c2ad84243df004f3246bad--
-"""
-
-itip_application_ics = """MIME-Version: 1.0
-Content-Type: multipart/mixed;
- boundary="=_c8894dbdb8baeedacae836230e3436fd"
-From: "Doe, John" <john.doe at example.org>
-Date: Fri, 13 Jul 2012 13:54:14 +0100
-Message-ID: <240fe7ae7e139129e9eb95213c101622 at example.org>
-User-Agent: Roundcube Webmail/0.9-0.3.el6.kolab_3.0
-To: resource-collection-car at example.org
-Subject: "test" has been updated
-
---=_c8894dbdb8baeedacae836230e3436fd
-Content-Transfer-Encoding: quoted-printable
-Content-Type: text/plain; charset=UTF-8; format=flowed
-
-<some text here>
-
---=_c8894dbdb8baeedacae836230e3436fd
-Content-Type: application/ics; charset=UTF-8; method=REQUEST;
- name=event.ics
-Content-Transfer-Encoding: quoted-printable
-
-BEGIN:VCALENDAR
-VERSION:2.0
-PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
-CALSCALE:GREGORIAN
-METHOD:REQUEST
-BEGIN:VEVENT
-UID:626421779C777FBE9C9B85A80D04DDFA-A4BF5BBB9FEAA271
-DTSTAMP:20120713T1254140
-DTSTART;TZID=3DEurope/London:20120713T100000
-DTEND;TZID=3DEurope/London:20120713T110000
-SUMMARY:test
-DESCRIPTION:test
-ORGANIZER;CN=3D"Doe, John":mailto:john.doe at example.org
-ATTENDEE;ROLE=3DREQ-PARTICIPANT;PARTSTAT=3DNEEDS-ACTION;RSVP=3DTRUE:mailt=
-o:resource-collection-car at example.org
-TRANSP:OPAQUE
-END:VEVENT
-END:VCALENDAR
-
---=_c8894dbdb8baeedacae836230e3436fd--
-"""
-
-itip_empty = """MIME-Version: 1.0
-Date: Fri, 17 Jan 2014 13:51:50 +0100
-From: <john.doe at example.org>
-User-Agent: Roundcube Webmail/0.9.5
-To: john.sample at example.org
-Subject: "test" has been sent
-Message-ID: <52D92766.5040508 at somedomain.com>
-Content-Type: text/plain; charset=UTF-8
-Content-Transfer-Encoding: 7bit
-
-Message plain text goes here...
-"""
-
-
 conf = pykolab.getConf()
 
 if not hasattr(conf, 'defaults'):
@@ -301,32 +156,6 @@ class TestWallaceResources(unittest.TestCase):
 
         return None
 
-    def test_001_itip_events_from_message(self):
-        itips1 = pykolab.itip.events_from_message(message_from_string(itip_multipart))
-        self.assertEqual(len(itips1), 1, "Multipart iTip message with text/calendar")
-        self.assertEqual(itips1[0]['method'], "REQUEST", "iTip request method property")
-
-        itips2 = pykolab.itip.events_from_message(message_from_string(itip_non_multipart))
-        self.assertEqual(len(itips2), 1, "Detect non-multipart iTip messages")
-
-        itips3 = pykolab.itip.events_from_message(message_from_string(itip_application_ics))
-        self.assertEqual(len(itips3), 1, "Multipart iTip message with application/ics attachment")
-
-        itips4 = pykolab.itip.events_from_message(message_from_string(itip_google_multipart))
-        self.assertEqual(len(itips4), 1, "Multipart iTip message from Google")
-
-        itips5 = pykolab.itip.events_from_message(message_from_string(itip_empty))
-        self.assertEqual(len(itips5), 0, "Simple plain text message")
-
-        # invalid itip blocks
-        self.assertRaises(Exception, pykolab.itip.events_from_message, message_from_string(itip_multipart.replace("BEGIN:VEVENT", "")))
-
-        itips6 = pykolab.itip.events_from_message(message_from_string(itip_multipart.replace("DTSTART;", "X-DTSTART;")))
-        self.assertEqual(len(itips6), 0, "Event with not DTSTART")
-
-        itips7 = pykolab.itip.events_from_message(message_from_string(itip_non_multipart.replace("METHOD:REQUEST", "METHOD:PUBLISH").replace("method=REQUEST", "method=PUBLISH")))
-        self.assertEqual(len(itips7), 0, "Invalid METHOD")
-
 
     def test_002_resource_record_from_email_address(self):
         res = module_resources.resource_record_from_email_address("doe at example.org")
@@ -337,7 +166,7 @@ class TestWallaceResources(unittest.TestCase):
 
     def test_003_resource_records_from_itip_events(self):
         message = message_from_string(itip_multipart)
-        itips = pykolab.itip.events_from_message(message)
+        itips = itip.events_from_message(message)
 
         res = module_resources.resource_records_from_itip_events(itips)
         self.assertEqual(len(res), 2, "Return all attendee resources");
@@ -365,7 +194,7 @@ class TestWallaceResources(unittest.TestCase):
 
 
     def test_005_send_response_accept(self):
-        itip_event = pykolab.itip.events_from_message(message_from_string(itip_non_multipart))
+        itip_event = itip.events_from_message(message_from_string(itip_non_multipart))
         module_resources.send_response("resource-collection-car at example.org", itip_event)
 
         self.assertEqual(len(self.smtplog), 1);
@@ -384,7 +213,7 @@ class TestWallaceResources(unittest.TestCase):
 
     def test_006_send_response_delegate(self):
         # delegate resource-collection-car at example.org => resource-car-audi-a4 at example.org
-        itip_event = pykolab.itip.events_from_message(message_from_string(itip_non_multipart))[0]
+        itip_event = itip.events_from_message(message_from_string(itip_non_multipart))[0]
         itip_event['xml'].delegate('resource-collection-car at example.org', 'resource-car-audi-a4 at example.org')
         itip_event['xml'].set_attendee_participant_status(itip_event['xml'].get_attendee('resource-car-audi-a4 at example.org'), "ACCEPTED")
 
@@ -408,30 +237,3 @@ class TestWallaceResources(unittest.TestCase):
         self.assertEqual(ical2['attendee'].params['PARTSTAT'], "DELEGATED")
 
 
-    def test_007_check_date_conflict(self):
-        astart = datetime.datetime(2014,7,13, 10,0,0)
-        aend   = astart + datetime.timedelta(hours=2)
-
-        bstart = datetime.datetime(2014,7,13, 10,0,0)
-        bend   = astart + datetime.timedelta(hours=1)
-        self.assertTrue(module_resources.check_date_conflict(astart, aend, bstart, bend))
-
-        bstart = datetime.datetime(2014,7,13, 11,0,0)
-        bend   = astart + datetime.timedelta(minutes=30)
-        self.assertTrue(module_resources.check_date_conflict(astart, aend, bstart, bend))
-
-        bend   = astart + datetime.timedelta(hours=2)
-        self.assertTrue(module_resources.check_date_conflict(astart, aend, bstart, bend))
-
-        bstart = datetime.datetime(2014,7,13, 12,0,0)
-        bend   = astart + datetime.timedelta(hours=1)
-        self.assertFalse(module_resources.check_date_conflict(astart, aend, bstart, bend))
-
-        bstart = datetime.datetime(2014,6,13, 10,0,0)
-        bend   = datetime.datetime(2014,6,14, 12,0,0)
-        self.assertFalse(module_resources.check_date_conflict(astart, aend, bstart, bend))
-
-        bstart = datetime.datetime(2014,7,10, 12,0,0)
-        bend   = datetime.datetime(2014,7,14, 14,0,0)
-        self.assertTrue(module_resources.check_date_conflict(astart, aend, bstart, bend))
-
diff --git a/wallace/module_resources.py b/wallace/module_resources.py
index f398120..7c23995 100644
--- a/wallace/module_resources.py
+++ b/wallace/module_resources.py
@@ -809,7 +809,7 @@ def get_resource_owner(resource):
         if not isinstance(collections, list):
             collections = [ collections ]
 
-        for dn,collection in collections:
+        for collection in collections:
             if collection.has_key('owner') and isinstance(collection['owner'], list):
                 owners += collection['owner']
             elif collection.has_key('owner'):


commit 951c796336c854474697754110b903c4ce90ccf5
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Jul 7 01:34:10 2014 -0400

    Set uid property, too

diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py
index e438343..7b0c811 100644
--- a/pykolab/xml/event.py
+++ b/pykolab/xml/event.py
@@ -665,6 +665,7 @@ class Event(object):
         self.event.setSummary(summary)
 
     def set_uid(self, uid):
+        self.uid = uid
         self.event.setUid(str(uid))
 
     def set_transparency(self, transp):




More information about the commits mailing list