summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThomas Bruederli <bruederli@kolabsys.com>2014-07-07 08:14:53 -0400
committerThomas Bruederli <bruederli@kolabsys.com>2014-07-07 08:14:53 -0400
commitcfc64210ee11c8e48e83a534059159e649d92906 (patch)
tree606806a7a553114f5bac2896e2a8b6c63568ffc0
parentcf500d4b24cf865d77bf09e2bf149da2cf09421a (diff)
downloadpykolab-cfc64210ee11c8e48e83a534059159e649d92906.tar.gz
Implement (basic) notification to organizer when processing iTip REPLY messages from attendees
-rw-r--r--pykolab/xml/attendee.py13
-rw-r--r--tests/functional/test_wallace/test_007_invitationpolicy.py130
-rw-r--r--wallace/module_invitationpolicy.py110
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.