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