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

Thomas Brüderli bruederli at kolabsys.com
Mon Aug 4 19:45:05 CEST 2014


 pykolab/itip/__init__.py                                      |   51 ++-
 pykolab/xml/event.py                                          |   12 
 tests/functional/test_wallace/test_005_resource_invitation.py |   84 ++++
 tests/unit/test-003-event.py                                  |    2 
 wallace/module_resources.py                                   |  169 +++++++++-
 5 files changed, 308 insertions(+), 10 deletions(-)

New commits:
commit d9f5e3568f9d9298ea44194c8c10cf547652a5e1
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Aug 4 13:44:56 2014 -0400

    First attempt for resource owner confirmation workflow as described in #3168

diff --git a/pykolab/itip/__init__.py b/pykolab/itip/__init__.py
index 40cf007..c30421a 100644
--- a/pykolab/itip/__init__.py
+++ b/pykolab/itip/__init__.py
@@ -241,7 +241,7 @@ def send_reply(from_address, itip_events, response_text, subject=None):
         smtp.quit()
 
 
-def send_request(to_address, itip_events, request_text, subject=None):
+def send_request(to_address, itip_events, request_text, subject=None, direct=False):
     """
         Send an iTip REQUEST message from the given iCal events
     """
@@ -270,7 +270,8 @@ def send_request(to_address, itip_events, request_text, subject=None):
             log.error(_("Failed to compose iTip request message: %r") % (e))
             return
 
-        smtp = smtplib.SMTP("localhost", 10026)  # requests go through wallace
+        port = 10027 if direct else 10026
+        smtp = smtplib.SMTP("localhost", port)
 
         if conf.debuglevel > 8:
             smtp.set_debuglevel(True)
diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py
index 534134f..076eb39 100644
--- a/pykolab/xml/event.py
+++ b/pykolab/xml/event.py
@@ -644,7 +644,7 @@ class Event(object):
             raise ValueError, _("Invalid custom property name %r") % (name)
 
         props = self.event.customProperties()
-        props.append(kolabformat.CustomProperty(name, value))
+        props.append(kolabformat.CustomProperty(name.upper(), value))
         self.event.setCustomProperties(props)
 
     def set_from_ical(self, attr, value):
diff --git a/tests/functional/test_wallace/test_005_resource_invitation.py b/tests/functional/test_wallace/test_005_resource_invitation.py
index 60b6587..096fba8 100644
--- a/tests/functional/test_wallace/test_005_resource_invitation.py
+++ b/tests/functional/test_wallace/test_005_resource_invitation.py
@@ -11,6 +11,7 @@ from wallace import module_resources
 from pykolab.translate import _
 from pykolab.xml import event_from_message
 from pykolab.xml import participant_status_label
+from pykolab.itip import events_from_message
 from email import message_from_string
 from twisted.trial import unittest
 
@@ -220,6 +221,7 @@ class TestResourceInvitation(unittest.TestCase):
 
         self.room1 = funcs.resource_add("confroom", "Room 101", owner=self.jane['dn'], kolabinvitationpolicy='ACT_ACCEPT_AND_NOTIFY')
         self.room2 = funcs.resource_add("confroom", "Conference Room B-222")
+        self.room3 = funcs.resource_add("confroom", "CEOs Office 303", owner=self.jane['dn'], kolabinvitationpolicy='ACT_MANUAL')
         self.rooms = funcs.resource_add("collection", "Rooms", [ self.room1['dn'], self.room2['dn'] ], self.jane['dn'], kolabinvitationpolicy='ACT_ACCEPT_AND_NOTIFY')
 
         time.sleep(1)
@@ -232,6 +234,7 @@ class TestResourceInvitation(unittest.TestCase):
 
         smtp = smtplib.SMTP('localhost', 10026)
         smtp.sendmail(from_addr, to_addr, mime_message % (to_addr, itip_payload))
+        smtp.quit()
 
     def send_itip_invitation(self, resource_email, start=None, allday=False, template=None):
         if start is None:
@@ -339,6 +342,8 @@ class TestResourceInvitation(unittest.TestCase):
 
             time.sleep(1)
 
+        imap.disconnect()
+
         return found
 
     def purge_mailbox(self, mailbox):
@@ -621,3 +626,82 @@ class TestResourceInvitation(unittest.TestCase):
         notify = self.check_message_received(_('Booking for %s has been %s') % (delegatee['cn'], participant_status_label('ACCEPTED')), delegatee['mail'], self.jane['mailbox'])
         self.assertIsInstance(notify, email.message.Message)
         self.assertIn(self.john['mail'], notification_text)
+
+
+    def test_013_owner_confirmation_accept(self):
+        self.purge_mailbox(self.john['mailbox'])
+        self.purge_mailbox(self.jane['mailbox'])
+
+        uid = self.send_itip_invitation(self.room3['mail'], datetime.datetime(2014,9,12, 14,0,0))
+
+        # requester (john) gets a TENTATIVE confirmation
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('TENTATIVE') }, self.room3['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        event = self.check_resource_calendar_event(self.room3['kolabtargetfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(event.get_summary(), "test")
+        self.assertEqual(event.get_attendee_by_email(self.room3['mail']).get_participant_status(True), 'TENTATIVE')
+
+        # check confirmation message sent to resource owner (jane)
+        notify = self.check_message_received(_('Booking request for %s requires confirmation') % (self.room3['cn']), mailbox=self.jane['mailbox'])
+        self.assertIsInstance(notify, email.message.Message)
+
+        itip_event = events_from_message(notify)[0]
+
+        # resource owner confirms reservation request
+        itip_reply = itip_event['xml'].to_message_itip(self.jane['mail'],
+            method="REPLY",
+            participant_status='ACCEPTED',
+            message_text="Request accepted",
+            subject=_('Booking for %s has been %s') % (self.room3['cn'], participant_status_label('ACCEPTED'))
+        )
+
+        smtp = smtplib.SMTP('localhost', 10026)
+        smtp.sendmail(self.jane['mail'], str(itip_event['organizer']), str(itip_reply))
+        smtp.quit()
+
+        # requester (john) now gets the ACCEPTED response
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') }, self.room3['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        event = self.check_resource_calendar_event(self.room3['kolabtargetfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(event.get_attendee_by_email(self.room3['mail']).get_participant_status(True), 'ACCEPTED')
+
+
+    def test_014_owner_confirmation_decline(self):
+        self.purge_mailbox(self.john['mailbox'])
+        self.purge_mailbox(self.jane['mailbox'])
+
+        uid = self.send_itip_invitation(self.room3['mail'], datetime.datetime(2014,9,14, 9,0,0))
+
+        # requester (john) gets a TENTATIVE confirmation
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('TENTATIVE') }, self.room3['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        # check confirmation message sent to resource owner (jane)
+        notify = self.check_message_received(_('Booking request for %s requires confirmation') % (self.room3['cn']), mailbox=self.jane['mailbox'])
+        self.assertIsInstance(notify, email.message.Message)
+
+        itip_event = events_from_message(notify)[0]
+
+        # resource owner declines reservation request
+        itip_reply = itip_event['xml'].to_message_itip(self.jane['mail'],
+            method="REPLY",
+            participant_status='DECLINED',
+            message_text="Request declined",
+            subject=_('Booking for %s has been %s') % (self.room3['cn'], participant_status_label('DECLINED'))
+        )
+
+        smtp = smtplib.SMTP('localhost', 10026)
+        smtp.sendmail(self.jane['mail'], str(itip_event['organizer']), str(itip_reply))
+        smtp.quit()
+
+        # requester (john) now gets the DECLINED response
+        response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('DECLINED') }, self.room3['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        # tentative reservation was removed from resource calendar
+        event = self.check_resource_calendar_event(self.room3['kolabtargetfolder'], uid)
+        self.assertEqual(event, None)
diff --git a/tests/unit/test-003-event.py b/tests/unit/test-003-event.py
index 45a817c..1f54419 100644
--- a/tests/unit/test-003-event.py
+++ b/tests/unit/test-003-event.py
@@ -417,7 +417,7 @@ END:VEVENT
         self.event.set_start(datetime.datetime(2014, 05, 23, 11, 00, 00, tzinfo=pytz.timezone("Europe/London")))
         self.event.set_end(datetime.datetime(2014, 05, 23, 12, 30, 00, tzinfo=pytz.timezone("Europe/London")))
         self.event.set_sequence(3)
-        self.event.add_custom_property('X-CUSTOM', 'check')
+        self.event.add_custom_property('X-Custom', 'check')
 
         ical = icalendar.Calendar.from_ical(self.event.as_string_itip())
         event = ical.walk('VEVENT')[0]
diff --git a/wallace/module_resources.py b/wallace/module_resources.py
index b31a8d0..2f93c6f 100644
--- a/wallace/module_resources.py
+++ b/wallace/module_resources.py
@@ -26,6 +26,8 @@ import tempfile
 import time
 from urlparse import urlparse
 import urllib
+import uuid
+import re
 
 from email import message_from_string
 from email.parser import Parser
@@ -159,15 +161,17 @@ def execute(*args, **kw):
     message = Parser().parse(open(filepath, 'r'))
 
     recipients = [address for displayname,address in getaddresses(message.get_all('X-Kolab-To'))]
+    sender_email = [address for displayname,address in getaddresses(message.get_all('X-Kolab-From'))][0]
 
     any_itips = False
     any_resources = False
     possibly_any_resources = True
+    reference_uid = None
 
     # An iTip message may contain multiple events. Later on, test if the message
     # is an iTip message by checking the length of this list.
     try:
-        itip_events = events_from_message(message, ['REQUEST', 'CANCEL'])
+        itip_events = events_from_message(message, ['REQUEST', 'REPLY', 'CANCEL'])
     except Exception, e:
         log.error(_("Failed to parse iTip events from message: %r" % (e)))
         itip_events = []
@@ -199,6 +203,12 @@ def execute(*args, **kw):
         auth.connect()
 
         for recipient in recipients:
+            # extract reference UID from recipients like resource+UID at domain.org
+            if re.match('.+\+[A-Za-z0-9%/_-]+@', recipient):
+                (prefix, host) = recipient.split('@')
+                (local, reference_uid) = prefix.split('+')
+                recipient = local + '@' + host
+
             if not len(resource_record_from_email_address(recipient)) == 0:
                 resource_recipient = recipient
                 any_resources = True
@@ -226,6 +236,7 @@ def execute(*args, **kw):
     # check if resource attendees match the envelope recipient
     if len(resource_dns) == 0:
         log.info(_("No resource attendees matching envelope recipient %s, Reject message") % (resource_recipient))
+        log.debug("%r" % (itip_events), level=8)
         reject(filepath)
         return False
 
@@ -242,6 +253,41 @@ def execute(*args, **kw):
     receiving_resource = resources[resource_dns[0]]
 
     for itip_event in itip_events:
+        if itip_event['method'] == 'REPLY':
+            done = True
+
+            # find initial reservation referenced by the reply
+            if reference_uid:
+                event = find_existing_event(reference_uid, receiving_resource)
+                if event:
+                    try:
+                        sender_attendee = itip_event['xml'].get_attendee_by_email(sender_email)
+                        owner_reply = sender_attendee.get_participant_status()
+                        log.debug(_("Sender Attendee: %r => %r") % (sender_attendee, owner_reply), level=9)
+                    except Exception, e:
+                        log.error("Could not find envelope sender attendee: %r" % (e))
+                        continue
+
+                    itip_event_ = dict(xml=event, uid=event.get_uid())
+
+                    if owner_reply == kolabformat.PartAccepted:
+                        accept_reservation_request(itip_event_, receiving_resource, confirmed=True)
+                    elif owner_reply == kolabformat.PartDeclined:
+                        decline_reservation_request(itip_event_, receiving_resource)
+                        # TODO: set partstat=DECLINED and status=CANCELLED instead of deleting?
+                        delete_resource_event(reference_uid, receiving_resource)
+                    else:
+                        log.info("Invalid response (%r) recieved from resource owner for event %r" % (
+                            sender_attendee.get_participant_status(True), reference_uid
+                        ))
+                else:
+                    log.info(_("Event referenced by this REPLY (%r) not found in resource calendar") % (reference_uid))
+
+            # exit for-loop
+            break
+
+        # else:
+
         try:
             receiving_attendee = itip_event['xml'].get_attendee_by_email(receiving_resource['mail'])
             log.debug(_("Receiving Resource: %r; %r") % (receiving_resource, receiving_attendee), level=9)
@@ -510,18 +556,65 @@ def read_resource_calendar(resource_rec, itip_events):
     return num_messages
 
 
-def accept_reservation_request(itip_event, resource, delegator=None):
+def find_existing_event(uid, resource_rec):
+    """
+        Search the resources's calendar folder for the given event (by UID)
+    """
+    global imap
+
+    event = None
+    mailbox = resource_rec['kolabtargetfolder']
+
+    log.debug(_("Searching %r for event %r") % (mailbox, uid), level=9)
+
+    try:
+        imap.imap.m.select(imap.folder_quote(mailbox))
+        typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid))
+    except Exception, e:
+        log.error(_("Failed to access resource calendar:: %r") % (e))
+        return event
+
+    for num in reversed(data[0].split()):
+        typ, data = imap.imap.m.fetch(num, '(RFC822)')
+
+        try:
+            event = event_from_message(message_from_string(data[0][1]))
+        except Exception, e:
+            log.error(_("Failed to parse event from message %s/%s: %r") % (mailbox, num, e))
+            continue
+
+        if event and event.uid == uid:
+            return event
+
+    return event
+
+
+def accept_reservation_request(itip_event, resource, delegator=None, confirmed=False):
     """
         Accepts the given iTip event by booking it into the resource's
         calendar. Then set the attendee status of the given resource to
         ACCEPTED and sends an iTip reply message to the organizer.
     """
+    owner = get_resource_owner(resource)
+    confirmation_required = False
+
+    if not confirmed and resource.has_key('kolabinvitationpolicy'):
+        for policy in resource['kolabinvitationpolicy']:
+            if policy & ACT_MANUAL and owner['mail']:
+                confirmation_required = True
+                break
+
+    partstat = 'TENTATIVE' if confirmation_required else 'ACCEPTED'
 
     itip_event['xml'].set_attendee_participant_status(
         itip_event['xml'].get_attendee_by_email(resource['mail']),
-        "ACCEPTED"
+        partstat
     )
 
+    # remove old copy of the reservation
+    if confirmed:
+        delete_resource_event(itip_event['uid'], resource)
+
     saved = save_resource_event(itip_event, resource)
 
     log.debug(
@@ -529,12 +622,12 @@ def accept_reservation_request(itip_event, resource, delegator=None):
         level=8
     )
 
-    owner = get_resource_owner(resource)
-
     if saved:
         send_response(delegator['mail'] if delegator else resource['mail'], itip_event, owner)
 
-    if owner:
+    if owner and confirmation_required:
+        send_owner_confirmation(resource, owner, itip_event)
+    elif owner:
         send_owner_notification(resource, owner, itip_event, saved)
 
 
@@ -685,6 +778,12 @@ def resource_records_from_itip_events(itip_events, recipient_email=None):
 
     log.debug(_("Raw set of resources: %r") % (resources_raw), level=9)
 
+    # consider organizer (in REPLY messages), too
+    organizers_raw = [re.sub('\+[A-Za-z0-9%/_-]+@', '@', str(y['organizer'])) for y in itip_events if y.has_key('organizer')]
+
+    log.debug(_("Raw set of organizers: %r") % (organizers_raw), level=8)
+
+
     # TODO: We expect the format of an attendee line to literally be:
     #
     #   ATTENDEE:RSVP=TRUE;ROLE=REQ-PARTICIPANT;MAILTO:lydia.bossers at kolabsys.com
@@ -693,7 +792,7 @@ def resource_records_from_itip_events(itip_events, recipient_email=None):
     #
     #   RSVP=TRUE;ROLE=REQ-PARTICIPANT;MAILTO:lydia.bossers at kolabsys.com
     #
-    attendees = [x.split(':')[-1] for x in attendees_raw]
+    attendees = [x.split(':')[-1] for x in attendees_raw + organizers_raw]
 
     # Limit the attendee resources to the one that is actually invited
     # with the current message. Considering all invited resources would result in
@@ -1000,3 +1099,59 @@ def owner_notification_text(resource, owner, event, success):
         'orgname': organizer.name(),
         'orgemail': organizer.email()
     }
+
+
+def send_owner_confirmation(resource, owner, itip_event):
+    """
+        Send a reservation request to the resource owner for manual confirmation (ACCEPT or DECLINE)
+
+        This clones the given invtation with a new UID and setting the resource as organizer in order to
+        receive the reply from the owner.
+    """
+
+    event = itip_event['xml']
+    uid = itip_event['uid']
+    organizer = event.get_organizer()
+
+    # generate new UID and set the resource as organizer
+    (mail, domain) = resource['mail'].split('@')
+    event.set_uid(str(uuid.uuid4()))
+    event.set_organizer(mail + '+' + urllib.quote(uid) + '@' + domain, resource['cn'])
+    itip_event['uid'] = event.get_uid()
+
+    # add resource owner as attendee
+    event.add_attendee(owner['mail'], owner['cn'], rsvp=True, role=kolabformat.Required, participant_status=kolabformat.PartNeedsAction)
+
+    # flag this iTip message as confirmation type
+    event.add_custom_property('X-Wallace-MessageType', 'CONFIRMATION')
+
+    log.debug(
+        _("Clone invitation for owner confirmation: %r from %r") % (
+            itip_event['uid'], event.get_organizer().email()
+        ),
+        level=8
+    )
+
+    message_text = _("""
+        A reservation request for %(resource)s requires your approval!
+        Please either accept or decline this inivitation without saving it to your calendar.
+
+        The reservation request was sent from %(orgname)s <%(orgemail)s>.
+
+        Subject: %(summary)s.
+        Date: %(date)s
+
+        *** This is an automated message, please don't reply by email. ***
+    """)% {
+        'resource': resource['cn'],
+        'orgname': organizer.name(),
+        'orgemail': organizer.email(),
+        'summary': event.get_summary(),
+        'date': event.get_date_text()
+    }
+
+    pykolab.itip.send_request(owner['mail'], itip_event, message_text,
+        subject=_('Booking request for %s requires confirmation') % (resource['cn']),
+        direct=True)
+
+


commit 5a171ddd85f7f8e57685a469d3de3fc3bb6a99ab
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Aug 4 12:49:51 2014 -0400

    Allow to set custom event properties and add them in iCal export

diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py
index 213e43e..534134f 100644
--- a/pykolab/xml/event.py
+++ b/pykolab/xml/event.py
@@ -197,6 +197,10 @@ class Event(object):
                 for _retval in retval:
                     event.add(attr.lower(), _retval, encode=0)
 
+        # copy custom properties to iCal
+        for cs in self.event.customProperties():
+            event.add(cs.identifier, cs.value)
+
         cal.add_component(event)
 
         if hasattr(cal, 'to_ical'):
@@ -635,6 +639,14 @@ class Event(object):
         for _datetime in _datetimes:
             self.add_exception_date(_datetime)
 
+    def add_custom_property(self, name, value):
+        if not name.upper().startswith('X-'):
+            raise ValueError, _("Invalid custom property name %r") % (name)
+
+        props = self.event.customProperties()
+        props.append(kolabformat.CustomProperty(name, value))
+        self.event.setCustomProperties(props)
+
     def set_from_ical(self, attr, value):
         ical_setter = 'set_ical_' + attr
         default_setter = 'set_' + attr
diff --git a/tests/unit/test-003-event.py b/tests/unit/test-003-event.py
index f069be3..45a817c 100644
--- a/tests/unit/test-003-event.py
+++ b/tests/unit/test-003-event.py
@@ -417,6 +417,7 @@ END:VEVENT
         self.event.set_start(datetime.datetime(2014, 05, 23, 11, 00, 00, tzinfo=pytz.timezone("Europe/London")))
         self.event.set_end(datetime.datetime(2014, 05, 23, 12, 30, 00, tzinfo=pytz.timezone("Europe/London")))
         self.event.set_sequence(3)
+        self.event.add_custom_property('X-CUSTOM', 'check')
 
         ical = icalendar.Calendar.from_ical(self.event.as_string_itip())
         event = ical.walk('VEVENT')[0]
@@ -424,6 +425,7 @@ END:VEVENT
         self.assertEqual(event['uid'], self.event.get_uid())
         self.assertEqual(event['summary'], "test")
         self.assertEqual(event['sequence'], 3)
+        self.assertEqual(event['X-CUSTOM'], "check")
         self.assertIsInstance(event['dtstamp'].dt, datetime.datetime)
 
     def test_020_calendaring_recurrence(self):


commit 0813c350ed72c595bdee53e9c78490d8eccf7e98
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Mon Aug 4 12:32:24 2014 -0400

    Add function to send iTip REQUESTs

diff --git a/pykolab/itip/__init__.py b/pykolab/itip/__init__.py
index 816ee1d..40cf007 100644
--- a/pykolab/itip/__init__.py
+++ b/pykolab/itip/__init__.py
@@ -113,7 +113,7 @@ def objects_from_message(message, objname, methods=None):
                         # TODO: distinguish event and todo here
                         itip['xml'] = event_from_ical(c.to_ical())
                     except Exception, e:
-                        log.error("event_from_ical() exception: %r" % (e))
+                        log.error("event_from_ical() exception: %r; iCal: %s" % (e, itip_payload))
                         continue
 
                     itip_objects.append(itip)
@@ -198,10 +198,10 @@ def send_reply(from_address, itip_events, response_text, subject=None):
     """
         Send the given iCal events as a valid iTip REPLY to the organizer.
     """
-
     import smtplib
 
     conf = pykolab.getConf()
+    smtp = None
 
     if isinstance(itip_events, dict):
         itip_events = [ itip_events ]
@@ -237,4 +237,48 @@ def send_reply(from_address, itip_events, response_text, subject=None):
         except Exception, e:
             log.error(_("SMTP sendmail error: %r") % (e))
 
-    smtp.quit()
+    if smtp:
+        smtp.quit()
+
+
+def send_request(to_address, itip_events, request_text, subject=None):
+    """
+        Send an iTip REQUEST message from the given iCal events
+    """
+    import smtplib
+
+    conf = pykolab.getConf()
+    smtp = None
+
+    if isinstance(itip_events, dict):
+        itip_events = [ itip_events ]
+
+    for itip_event in itip_events:
+        event_summary = itip_event['xml'].get_summary()
+        message_text = request_text % { 'summary':event_summary }
+
+        if subject is not None:
+            subject = subject % { 'summary':event_summary }
+
+        try:
+            message = itip_event['xml'].to_message_itip(None,
+                method="REQUEST",
+                message_text=message_text,
+                subject=subject
+            )
+        except Exception, e:
+            log.error(_("Failed to compose iTip request message: %r") % (e))
+            return
+
+        smtp = smtplib.SMTP("localhost", 10026)  # requests go through wallace
+
+        if conf.debuglevel > 8:
+            smtp.set_debuglevel(True)
+
+        try:
+            smtp.sendmail(message['From'], to_address, message.as_string())
+        except Exception, e:
+            log.error(_("SMTP sendmail error: %r") % (e))
+
+    if smtp:
+        smtp.quit()




More information about the commits mailing list