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

Thomas Brüderli bruederli at kolabsys.com
Wed Feb 26 15:48:20 CET 2014


 pykolab/xml/event.py                                          |   14 
 tests/functional/test_wallace/test_005_resource_invitation.py |  175 ++++++++--
 tests/unit/test-003-event.py                                  |    4 
 tests/unit/test-011-wallace_resources.py                      |    1 
 wallace/module_resources.py                                   |  137 ++++++-
 5 files changed, 285 insertions(+), 46 deletions(-)

New commits:
commit 5b59c3723ae76401bde695c56938ae0b2f8b81f5
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Feb 20 03:19:01 2014 -0500

    Handle iTip updates and cancellations for resources

diff --git a/tests/functional/test_wallace/test_005_resource_invitation.py b/tests/functional/test_wallace/test_005_resource_invitation.py
index 5bf9767..780ca0f 100644
--- a/tests/functional/test_wallace/test_005_resource_invitation.py
+++ b/tests/functional/test_wallace/test_005_resource_invitation.py
@@ -46,7 +46,7 @@ CALSCALE:GREGORIAN
 METHOD:REQUEST
 BEGIN:VEVENT
 UID:%s
-DTSTAMP:20120713T1254140
+DTSTAMP:20140213T1254140
 DTSTART;TZID=Europe/London:%s
 DTEND;TZID=Europe/London:%s
 SUMMARY:test
@@ -59,6 +59,78 @@ END:VCALENDAR
 --=_c8894dbdb8baeedacae836230e3436fd--
 """
 
+itip_update = """MIME-Version: 1.0
+Content-Type: multipart/mixed;
+ boundary="=_c8894dbdb8baeedacae836230e3436fd"
+From: "Doe, John" <john.doe at example.org>
+Date: Tue, 25 Feb 2014 13:54:14 +0100
+Message-ID: <240fe7ae7e139129e9eb95213c1016d7 at example.org>
+User-Agent: Roundcube Webmail/0.9-0.3.el6.kolab_3.0
+To: %s
+Subject: "test" has been updated
+
+--=_c8894dbdb8baeedacae836230e3436fd
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: quoted-printable
+
+*test* updated
+
+--=_c8894dbdb8baeedacae836230e3436fd
+Content-Type: text/calendar; charset=UTF-8; method=REQUEST; name=event.ics
+Content-Disposition: attachment; filename=event.ics
+Content-Transfer-Encoding: 8bit
+
+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:%s
+DTSTAMP:20140215T1254140
+DTSTART;TZID=Europe/London:%s
+DTEND;TZID=Europe/London:%s
+SEQUENCE:2
+SUMMARY:test
+DESCRIPTION:test
+ORGANIZER;CN="Doe, John":mailto:john.doe at example.org
+ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:%s
+TRANSP:OPAQUE
+END:VEVENT
+END:VCALENDAR
+--=_c8894dbdb8baeedacae836230e3436fd--
+"""
+
+itip_cancellation = """Return-Path: <john.doe at example.org>
+Content-Type: text/calendar; method=CANCEL; charset=UTF-8
+Content-Transfer-Encoding: quoted-printable
+To: %s
+From: john.doe at example.org
+Date: Mon, 24 Feb 2014 11:27:28 +0100
+Message-ID: <1a3aa8995e83dd24cf9247e538ac91ff at example.org>
+Subject: "test" cancelled
+
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
+CALSCALE:GREGORIAN
+METHOD:CANCEL
+BEGIN:VEVENT
+UID:%s
+DTSTAMP:20140218T1254140
+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:%s
+TRANSP:OPAQUE
+SEQUENCE:3
+END:VEVENT
+END:VCALENDAR
+"""
+
 class TestResourceInvitation(unittest.TestCase):
 
     john = None
@@ -119,6 +191,33 @@ class TestResourceInvitation(unittest.TestCase):
 
         return uid
 
+    def send_itip_update(self, resource_email, uid, start=None):
+        if start is None:
+            start = datetime.datetime.now()
+
+        end = start + datetime.timedelta(hours=4)
+        self.send_message(itip_update % (
+                resource_email,
+                uid,
+                start.strftime('%Y%m%dT%H%M%S'),
+                end.strftime('%Y%m%dT%H%M%S'),
+                resource_email
+            ),
+            resource_email)
+
+        return uid
+
+    def send_itip_cancel(self, resource_email, uid):
+        self.send_message(itip_cancellation % (
+                resource_email,
+                uid,
+                resource_email
+            ),
+            resource_email)
+
+        return uid
+
+
     def check_message_received(self, subject, from_addr=None):
         imap = IMAP()
         imap.connect()
@@ -126,10 +225,10 @@ class TestResourceInvitation(unittest.TestCase):
         imap.imap.m.select(self.john['mailbox'])
 
         found = None
-        max_tries = 20
+        retries = 10
 
-        while not found and max_tries > 0:
-            max_tries -= 1
+        while not found and retries > 0:
+            retries -= 1
 
             typ, data = imap.imap.m.search(None, '(UNDELETED HEADER FROM "%s")' % (from_addr) if from_addr else 'UNDELETED')
             for num in data[0].split():
@@ -150,28 +249,32 @@ class TestResourceInvitation(unittest.TestCase):
         imap.connect()
 
         imap.imap.m.select(u'"'+mailbox+'"')
-        typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid) if uid else '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")')
 
         found = None
+        retries = 10
 
-        for num in data[0].split():
-            typ, data = imap.imap.m.fetch(num, '(RFC822)')
-            event_message = message_from_string(data[0][1])
+        while not found and retries > 0:
+            retries -= 1
 
-            # return matching UID or first event found
-            if uid and event_message['subject'] != uid:
-                continue
+            typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid) if uid else '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")')
+            for num in data[0].split():
+                typ, data = imap.imap.m.fetch(num, '(RFC822)')
+                event_message = message_from_string(data[0][1])
 
-            for part in event_message.walk():
-                if part.get_content_type() == "application/calendar+xml":
-                    payload = part.get_payload(decode=True)
-                    found = pykolab.xml.event_from_string(payload)
-                    break
+                # return matching UID or first event found
+                if uid and event_message['subject'] != uid:
+                    continue
 
-            if found:
-                break
+                for part in event_message.walk():
+                    if part.get_content_type() == "application/calendar+xml":
+                        payload = part.get_payload(decode=True)
+                        found = pykolab.xml.event_from_string(payload)
+                        break
 
-        imap.disconnect()
+                if found:
+                    break
+
+            time.sleep(1)
 
         return found
 
@@ -236,3 +339,37 @@ class TestResourceInvitation(unittest.TestCase):
         # resource collection respons with a DELEGATED message
         response = self.check_message_received("Meeting Request DELEGATED", self.cars['mail'])
         self.assertIsInstance(response, email.message.Message)
+
+
+    def test_005_rescheduling_reservation(self):
+        uid = self.send_itip_invitation(self.audi['mail'], datetime.datetime(2014,5,1, 10,0,0))
+
+        response = self.check_message_received("Meeting Request ACCEPTED", self.audi['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        self.purge_mailbox(self.john['mailbox'])
+        self.send_itip_update(self.audi['mail'], uid, datetime.datetime(2014,5,1, 12,0,0)) # conflict with myself
+
+        response = self.check_message_received("Meeting Request ACCEPTED", self.audi['mail'])
+        self.assertIsInstance(response, email.message.Message)
+
+        event = self.check_resource_calendar_event(self.audi['kolabtargetfolder'], uid)
+        self.assertIsInstance(event, pykolab.xml.Event)
+        self.assertEqual(event.get_start().hour, 12)
+        self.assertEqual(event.get_sequence(), 2)
+
+
+    def test_006_cancelling_revervation(self):
+        uid = self.send_itip_invitation(self.boxter['mail'], datetime.datetime(2014,5,1, 10,0,0))
+        self.assertIsInstance(self.check_resource_calendar_event(self.boxter['kolabtargetfolder'], uid), pykolab.xml.Event)
+
+        self.send_itip_cancel(self.boxter['mail'], uid)
+
+        time.sleep(2)  # wait for IMAP to update
+        self.assertEqual(self.check_resource_calendar_event(self.boxter['kolabtargetfolder'], uid), None)
+
+        # make new reservation to the now free'd slot
+        self.send_itip_invitation(self.boxter['mail'], datetime.datetime(2014,5,1, 9,0,0))
+
+        response = self.check_message_received("Meeting Request ACCEPTED", self.boxter['mail'])
+        self.assertIsInstance(response, email.message.Message)
diff --git a/tests/unit/test-011-wallace_resources.py b/tests/unit/test-011-wallace_resources.py
index f6b2d40..204df06 100644
--- a/tests/unit/test-011-wallace_resources.py
+++ b/tests/unit/test-011-wallace_resources.py
@@ -291,6 +291,7 @@ class TestWallaceResources(unittest.TestCase):
     def test_001_itip_events_from_message(self):
         itips1 = module_resources.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 = module_resources.itip_events_from_message(message_from_string(itip_non_multipart))
         self.assertEqual(len(itips2), 1, "Detect non-multipart iTip messages")
diff --git a/wallace/module_resources.py b/wallace/module_resources.py
index eaa547b..84447c2 100644
--- a/wallace/module_resources.py
+++ b/wallace/module_resources.py
@@ -62,6 +62,7 @@ def accept(filepath):
             os.path.basename(filepath)
         )
 
+    cleanup()
     os.rename(filepath, new_filepath)
     filepath = new_filepath
     exec('modules.cb_action_ACCEPT(%r, %r)' % ('resources',filepath))
@@ -69,7 +70,21 @@ def accept(filepath):
 def description():
     return """Resource management module."""
 
+def cleanup():
+    global auth, imap
+
+    log.debug("cleanup(): %r, %r" % (auth, imap), level=9)
+
+    auth.disconnect()
+    del auth
+
+    # Disconnect IMAP or we lock the mailbox almost constantly
+    imap.disconnect()
+    del imap
+
 def execute(*args, **kw):
+    global auth, imap
+
     if not os.path.isdir(mybasepath):
         os.makedirs(mybasepath)
 
@@ -216,6 +231,25 @@ def execute(*args, **kw):
 
     log.debug(_("Resources: %r, %r") % (resource_dns, resources), level=8)
 
+
+    # process CANCEL messages
+    done = False
+    for itip_event in itip_events:
+        if itip_event['method'] == "CANCEL":
+            for resource in resource_dns:
+                if resources[resource]['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()]:
+                    delete_resource_event(itip_event['uid'], resources[resource])
+
+                # TODO: handle cancellations sent to resource collections. Really?
+
+            done = True
+
+    if done:
+        os.unlink(filepath)
+        cleanup()
+        return
+
+
     # For each resource, determine if any of the events in question is in
     # conflict.
     #
@@ -229,7 +263,7 @@ def execute(*args, **kw):
 
         # sets the 'conflicting' flag and adds a list of conflicting events found
         try:
-            read_resource_calendar(resources[resource], itip_events, imap)
+            read_resource_calendar(resources[resource], itip_events)
         except Exception, e:
             log.error(_("Failed to read resource calendar for %r: %r") % (resource, e))
             continue
@@ -238,8 +272,9 @@ def execute(*args, **kw):
 
     log.debug(_("start: %r, end: %r, total: %r") % (start, end, (end-start)), level=1)
 
-    done = False
 
+    # For each resource (collections are first!)
+    # check conflicts and either accept or decline the reservation request
     for resource in resource_dns:
         log.debug(_("Polling for resource %r") % (resource), level=9)
 
@@ -295,8 +330,13 @@ def execute(*args, **kw):
             # No conflicts, go accept
             for itip_event in itip_events:
                 if resources[resource]['mail'] in [a.get_email() for a in itip_event['xml'].get_attendees()]:
+                    # replace existing copy of this event
+                    if len(resources[resource]['existing_events']) > 0:
+                        for uid in resources[resource]['existing_events']:
+                            delete_resource_event(uid, resources[resource])
+
                     log.debug(_("Accept invitation for individual resource %r / %r") % (resource, resources[resource]['mail']), level=9)
-                    accept_reservation_request(itip_event, resources[resource], imap)
+                    accept_reservation_request(itip_event, resources[resource])
                     done = True
 
                 else:
@@ -320,32 +360,29 @@ def execute(*args, **kw):
                         #
                         itip_event['xml'].delegate(original_resource['mail'], _target_resource['mail'])
 
-                        accept_reservation_request(itip_event, _target_resource, imap, delegator=original_resource)
+                        accept_reservation_request(itip_event, _target_resource, original_resource)
                         done = True
 
         if done:
             break
 
-        # for resource in resource_dns:
+    # end for resource in resource_dns:
 
-    auth.disconnect()
-    del auth
-
-    # Disconnect IMAP or we lock the mailbox almost constantly
-    imap.disconnect()
-    del imap
+    cleanup()
 
     os.unlink(filepath)
 
 
-def read_resource_calendar(resource_rec, itip_events, imap):
+def read_resource_calendar(resource_rec, itip_events):
     """
         Read all booked events from the given resource's calendar
         and check for conflicts with the given list if itip events
     """
+    global imap
 
     resource_rec['conflict'] = False
     resource_rec['conflicting_events'] = []
+    resource_rec['existing_events'] = []
 
     mailbox = resource_rec['kolabtargetfolder']
 
@@ -401,6 +438,13 @@ def read_resource_calendar(resource_rec, itip_events, imap):
                             else:
                                 conflict = False
 
+                        if event.get_uid() == itip['uid']:
+                            resource_rec['existing_events'].append(itip['uid'])
+
+                            # don't register conflict for updates
+                            if itip['sequence'] > event.get_sequence():
+                                conflict = False
+
                         if conflict:
                             log.info(
                                 _("Event %r conflicts with event %r") % (
@@ -409,13 +453,13 @@ def read_resource_calendar(resource_rec, itip_events, imap):
                                 )
                             )
 
-                            resource_rec['conflicting_events'].append(event)
+                            resource_rec['conflicting_events'].append(event.get_uid())
                             resource_rec['conflict'] = True
 
     return resource_rec['conflict']
 
 
-def accept_reservation_request(itip_event, resource, imap, delegator=None):
+def accept_reservation_request(itip_event, resource, delegator=None):
     """
         Accepts the given iTip event by booking it into the resource's
         calendar. Then set the attendee status of the given resource to
@@ -427,21 +471,13 @@ def accept_reservation_request(itip_event, resource, imap, delegator=None):
         "ACCEPTED"
     )
 
+    saved = save_resource_event(itip_event, resource)
+
     log.debug(
-        _("Adding event to %r") % (resource['kolabtargetfolder']),
+        _("Adding event to %r: %r") % (resource['kolabtargetfolder'], saved),
         level=9
     )
 
-    # TODO: The Cyrus IMAP (or Dovecot) Administrator login
-    # name comes from configuration.
-    imap.imap.m.setacl(resource['kolabtargetfolder'], "cyrus-admin", "lrswipkxtecda")
-    imap.imap.m.append(
-            resource['kolabtargetfolder'],
-            None,
-            None,
-            itip_event['xml'].to_message().as_string()
-        )
-
     send_response(delegator['mail'] if delegator else resource['mail'], itip_event)
 
 
@@ -459,6 +495,49 @@ def decline_reservation_request(itip_event, resource):
     send_response(resource['mail'], itip_event)
 
 
+def save_resource_event(itip_event, resource):
+    """
+        Append the given event object to the resource's calendar
+    """
+    try:
+        # TODO: The Cyrus IMAP (or Dovecot) Administrator login
+        # name comes from configuration.
+        imap.imap.m.setacl(resource['kolabtargetfolder'], "cyrus-admin", "lrswipkxtecda")
+        result = imap.imap.m.append(
+            resource['kolabtargetfolder'],
+            None,
+            None,
+            itip_event['xml'].to_message().as_string()
+        )
+        return result
+
+    except Exception, e:
+        log.error(_("Failed to save event to resource calendar at %r: %r") % (
+            resource['kolabtargetfolder'], e
+        ))
+
+    return False
+
+
+def delete_resource_event(uid, resource):
+    """
+        Removes the IMAP object with the given UID from a resource's calendar folder
+    """
+    imap.imap.m.setacl(resource['kolabtargetfolder'], "cyrus-admin", "lrswipkxtecda")
+    imap.imap.m.select(resource['kolabtargetfolder'])
+
+    typ, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % uid)
+
+    log.debug(_("Delete resource calendar object %r in %r: %r") % (
+        uid, resource['kolabtargetfolder'], data
+    ), level=9)
+
+    for num in data[0].split():
+        imap.imap.m.store(num, '+FLAGS', '\\Deleted')
+
+    imap.imap.m.expunge()
+
+
 def itip_events_from_message(message):
     """
         Obtain the iTip payload from email.message <message>
@@ -481,7 +560,7 @@ def itip_events_from_message(message):
         # The iTip part MUST be Content-Type: text/calendar (RFC 6047, section 2.4)
         # But in real word, other mime-types are used as well
         if part.get_content_type() in [ "text/calendar", "text/x-vcalendar", "application/ics" ]:
-            if not part.get_param('method') in itip_methods:
+            if not part.get_param('method').upper() in itip_methods:
                 log.error(
                         _("Method %r not really interesting for us.") % (
                                 part.get_param('method')
@@ -514,6 +593,8 @@ def itip_events_from_message(message):
 
                     # From the event, take the following properties:
                     #
+                    # - method
+                    # - uid
                     # - start
                     # - end (if any)
                     # - duration (if any)
@@ -524,6 +605,9 @@ def itip_events_from_message(message):
                     #   Where are these stored actually?
                     #
 
+                    itip['uid'] = str(c['uid'])
+                    itip['method'] = str(cal['method']).upper()
+
                     if c.has_key('dtstart'):
                         itip['start'] = c['dtstart']
                     else:
@@ -535,6 +619,7 @@ def itip_events_from_message(message):
 
                     if c.has_key('duration'):
                         itip['duration'] = c['duration']
+                        # TODO: translate start + duration into end
 
                     itip['organizer'] = c['organizer']
 


commit 312d3875fc04848e6086b61cc4165829c20a5c24
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Thu Feb 20 02:13:34 2014 -0500

    Copy sequence property from/to ical

diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py
index 508dabb..482b1bc 100644
--- a/pykolab/xml/event.py
+++ b/pykolab/xml/event.py
@@ -434,6 +434,9 @@ class Event(object):
         if status in self.status_map.values():
             return [k for k, v in self.status_map.iteritems() if v == status][0]
 
+    def get_ical_sequence(self):
+        return str(self.event.sequence()) if self.event.sequence() else None
+
     def get_lastmodified(self):
         try:
             _datetime = self.event.lastModified()
@@ -524,6 +527,9 @@ class Event(object):
             self.set_uid(uuid.uuid4())
             return self.get_uid()
 
+    def get_sequence(self):
+        return self.event.sequence()
+
     def set_attendee_participant_status(self, attendee, status):
         """
             Set the participant status of an attendee to status.
@@ -646,6 +652,8 @@ class Event(object):
             self.set_ical_summary(value)
         elif attr == "priority":
             self.set_ical_priority(value)
+        elif attr == "sequence":
+            self.set_ical_sequence(value)
         elif attr == "attendee":
             self.set_ical_attendee(value)
         elif attr == "organizer":
@@ -720,6 +728,9 @@ class Event(object):
     def set_ical_priority(self, priority):
         self.set_priority(priority)
 
+    def set_ical_sequence(self, sequence):
+        self.set_sequence(sequence)
+
     def set_ical_status(self, status):
         if status in self.status_map.keys():
             self.event.setStatus(self.status_map[status])
@@ -786,6 +797,9 @@ class Event(object):
     def set_priority(self, priority):
         self.event.setPriority(priority)
 
+    def set_sequence(self, sequence):
+        self.event.setSequence(int(sequence))
+
     def set_recurrence(self, recurrence):
         self.event.setRecurrenceRule(recurrence)
 
diff --git a/tests/unit/test-003-event.py b/tests/unit/test-003-event.py
index 1da272a..59f131d 100644
--- a/tests/unit/test-003-event.py
+++ b/tests/unit/test-003-event.py
@@ -114,7 +114,7 @@ class TestEventXML(unittest.TestCase):
         self.assertEqual(hasattr(_start,'tzinfo'), False)
         self.assertEqual(self.event.get_start().__str__(), "2012-05-23")
 
-    def test_018_from_ical_cutype(self):
+    def test_018_load_from_ical(self):
         ical_str = """BEGIN:VCALENDAR
 VERSION:2.0
 CALSCALE:GREGORIAN
@@ -126,12 +126,14 @@ ATTENDEE;CN="Doe, Jane";CUTYPE=INDIVIDUAL;PARTSTAT=ACCEPTED
  ;ROLE=REQ-PARTICIPANT;RSVP=FALSE:MAILTO:jane at doe.org
 ATTENDEE;CUTYPE=RESOURCE;PARTSTAT=NEEDS-ACTION
  ;ROLE=OPTIONAL;RSVP=FALSE:MAILTO:max at imum.com
+SEQUENCE:2
 END:VEVENT
 END:VCALENDAR
 """
         ical = icalendar.Calendar.from_ical(ical_str)
         event = event_from_ical(ical.walk('VEVENT')[0].to_ical())
         self.assertEqual(event.get_attendee_by_email("max at imum.com").get_cutype(), kolabformat.CutypeResource)
+        self.assertEqual(event.get_sequence(), 2)
 
 if __name__ == '__main__':
     unittest.main()




More information about the commits mailing list