diff options
author | Thomas Bruederli <bruederli@kolabsys.com> | 2014-07-07 08:14:53 -0400 |
---|---|---|
committer | Thomas Bruederli <bruederli@kolabsys.com> | 2014-07-07 08:14:53 -0400 |
commit | cfc64210ee11c8e48e83a534059159e649d92906 (patch) | |
tree | 606806a7a553114f5bac2896e2a8b6c63568ffc0 | |
parent | cf500d4b24cf865d77bf09e2bf149da2cf09421a (diff) | |
download | pykolab-cfc64210ee11c8e48e83a534059159e649d92906.tar.gz |
Implement (basic) notification to organizer when processing iTip REPLY messages from attendees
-rw-r--r-- | pykolab/xml/attendee.py | 13 | ||||
-rw-r--r-- | tests/functional/test_wallace/test_007_invitationpolicy.py | 130 | ||||
-rw-r--r-- | wallace/module_invitationpolicy.py | 110 |
3 files changed, 209 insertions, 44 deletions
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@example.org', 'sender': 'John Doe <john.doe@example.org>', 'dn': 'uid=doe,ou=People,dc=example,dc=org', + 'preferredlanguage': 'en_US', 'mailbox': 'user/john.doe@example.org', 'kolabtargetfolder': 'user/john.doe/Calendar@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@example.org', 'sender': 'Jane Manager <jane.manager@example.org>', 'dn': 'uid=manager,ou=People,dc=example,dc=org', + 'preferredlanguage': 'en_US', 'mailbox': 'user/jane.manager@example.org', 'kolabtargetfolder': 'user/jane.manager/Calendar@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@example.org', + 'sender': 'Jack Tentative <jack.tentative@example.org>', + 'dn': 'uid=tentative,ou=People,dc=example,dc=org', + 'preferredlanguage': 'en_US', + 'mailbox': 'user/jack.tentative@example.org', + 'kolabtargetfolder': 'user/jack.tentative/Calendar@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. |