2 commits - pykolab/itip pykolab/xml tests/functional tests/unit wallace/module_invitationpolicy.py
Thomas Brüderli
bruederli at kolabsys.com
Thu Feb 19 13:08:35 CET 2015
pykolab/itip/__init__.py | 14
pykolab/xml/event.py | 5
pykolab/xml/utils.py | 5
tests/functional/test_wallace/test_005_resource_invitation.py | 2
tests/functional/test_wallace/test_007_invitationpolicy.py | 226 +++++++++
tests/unit/test-011-itip.py | 10
wallace/module_invitationpolicy.py | 230 +++++++---
7 files changed, 425 insertions(+), 67 deletions(-)
New commits:
commit c8abb4150f7e9c1f80a7098a332561ae92e135a3
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date: Tue Feb 17 20:51:29 2015 +0100
Add support for invitations of recurring events and single occurrences (#4552)
diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py
index 398814f..2d9a424 100644
--- a/pykolab/xml/event.py
+++ b/pykolab/xml/event.py
@@ -1347,6 +1347,9 @@ class Event(object):
instance._exceptions = []
instance._isexception = False
+ # unset attachments list (only stored in main event)
+ instance.event.setAttachments(kolabformat.vectorattachment())
+
# copy data from matching exception
# (give precedence to single occurrence exceptions over thisandfuture)
for exception in self._exceptions:
@@ -1374,7 +1377,7 @@ class Event(object):
while instance:
recurrence_id = instance.get_recurrence_id()
if type(recurrence_id) == type(_datetime) and recurrence_id <= _datetime:
- if recurrence_id == _datetime:
+ if xmlutils.dates_equal(recurrence_id, _datetime):
return instance
instance = self.get_next_instance(instance.get_start())
else:
diff --git a/pykolab/xml/utils.py b/pykolab/xml/utils.py
index 9ff29b6..c92c52f 100644
--- a/pykolab/xml/utils.py
+++ b/pykolab/xml/utils.py
@@ -122,6 +122,11 @@ def to_cdatetime(_datetime, with_timezone=True, as_utc=False):
return _cdatetime
+def dates_equal(a, b):
+ date_format = '%Y%m%d' if isinstance(a, datetime.date) and isinstance(b, datetime.date) else '%Y%m%dT%H%M%S'
+ return type(a) == type(b) and a.strftime(date_format) == b.strftime(date_format)
+
+
property_labels = {
"name": N_("Name"),
"summary": N_("Summary"),
diff --git a/tests/functional/test_wallace/test_005_resource_invitation.py b/tests/functional/test_wallace/test_005_resource_invitation.py
index 885f4ba..98a6523 100644
--- a/tests/functional/test_wallace/test_005_resource_invitation.py
+++ b/tests/functional/test_wallace/test_005_resource_invitation.py
@@ -303,7 +303,7 @@ class TestResourceInvitation(unittest.TestCase):
imap.imap.m.select(mailbox)
found = None
- retries = 10
+ retries = 15
while not found and retries > 0:
retries -= 1
diff --git a/tests/functional/test_wallace/test_007_invitationpolicy.py b/tests/functional/test_wallace/test_007_invitationpolicy.py
index cca5b96..4507dd1 100644
--- a/tests/functional/test_wallace/test_007_invitationpolicy.py
+++ b/tests/functional/test_wallace/test_007_invitationpolicy.py
@@ -29,7 +29,7 @@ PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
CALSCALE:GREGORIAN
METHOD:REQUEST
BEGIN:VEVENT
-UID:%(uid)s
+UID:%(uid)s%(recurrenceid)s
DTSTAMP:20140213T125414Z
DTSTART;TZID=Europe/Berlin:%(start)s
DTEND;TZID=Europe/Berlin:%(end)s
@@ -51,7 +51,7 @@ PRODID:-//Roundcube Webmail 0.9-0.3.el6.kolab_3.0//NONSGML Calendar//EN
CALSCALE:GREGORIAN
METHOD:CANCEL
BEGIN:VEVENT
-UID:%(uid)s
+UID:%(uid)s%(recurrenceid)s
DTSTAMP:20140218T125414Z
DTSTART;TZID=Europe/Berlin:20120713T100000
DTEND;TZID=Europe/Berlin:20120713T110000
@@ -74,8 +74,8 @@ METHOD:REQUEST
BEGIN:VEVENT
UID:%(uid)s
DTSTAMP:20140213T125414Z
-DTSTART;TZID=Europe/Zurich:%(start)s
-DTEND;TZID=Europe/Zurich:%(end)s
+DTSTART;TZID=Europe/Berlin:%(start)s
+DTEND;TZID=Europe/Berlin:%(end)s
RRULE:FREQ=WEEKLY;INTERVAL=1;COUNT=10
SUMMARY:%(summary)s
DESCRIPTION:test
@@ -95,7 +95,7 @@ CALSCALE:GREGORIAN
METHOD:REPLY
BEGIN:VEVENT
SUMMARY:%(summary)s
-UID:%(uid)s
+UID:%(uid)s%(recurrenceid)s
DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:%(start)s
DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:%(end)s
DTSTAMP;VALUE=DATE-TIME:20140706T171038Z
@@ -115,7 +115,7 @@ CALSCALE:GREGORIAN
METHOD:REPLY
BEGIN:VEVENT
SUMMARY:%(summary)s
-UID:%(uid)s
+UID:%(uid)s%(recurrenceid)s
DTSTART;TZID=Europe/Berlin;VALUE=DATE-TIME:%(start)s
DTEND;TZID=Europe/Berlin;VALUE=DATE-TIME:%(end)s
DTSTAMP;VALUE=DATE-TIME:20140706T171038Z
@@ -351,11 +351,12 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
smtp = smtplib.SMTP('localhost', 10026)
smtp.sendmail(from_addr, to_addr, mime_message % (to_addr, method, itip_payload))
- def send_itip_invitation(self, attendee_email, start=None, allday=False, template=None, summary="test", sequence=0, partstat='NEEDS-ACTION', from_addr=None):
+ def send_itip_invitation(self, attendee_email, start=None, allday=False, template=None, summary="test", sequence=0, partstat='NEEDS-ACTION', from_addr=None, instance=None):
if start is None:
start = datetime.datetime.now()
uid = str(uuid.uuid4())
+ recurrence_id = ''
if allday:
default_template = itip_allday
@@ -372,8 +373,12 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
else:
default_template = default_template.replace("john.doe at example.org", from_addr)
+ if instance is not None:
+ recurrence_id = "\nRECURRENCE-ID;TZID=Europe/Berlin:" + instance.strftime(date_format)
+
self.send_message((template if template is not None else default_template) % {
'uid': uid,
+ 'recurrenceid': recurrence_id,
'start': start.strftime(date_format),
'end': end.strftime(date_format),
'mailto': attendee_email,
@@ -385,15 +390,23 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
return uid
- def send_itip_update(self, attendee_email, uid, start=None, template=None, summary="test", sequence=1, partstat='ACCEPTED'):
+ def send_itip_update(self, attendee_email, uid, start=None, template=None, summary="test", sequence=1, partstat='ACCEPTED', instance=None):
if start is None:
start = datetime.datetime.now()
end = start + datetime.timedelta(hours=4)
+
+ date_format = '%Y%m%dT%H%M%S'
+ recurrence_id = ''
+
+ if instance is not None:
+ recurrence_id = "\nRECURRENCE-ID;TZID=Europe/Berlin:" + instance.strftime(date_format)
+
self.send_message((template if template is not None else itip_invitation) % {
'uid': uid,
- 'start': start.strftime('%Y%m%dT%H%M%S'),
- 'end': end.strftime('%Y%m%dT%H%M%S'),
+ 'recurrenceid': recurrence_id,
+ 'start': start.strftime(date_format),
+ 'end': end.strftime(date_format),
'mailto': attendee_email,
'summary': summary,
'sequence': sequence,
@@ -403,15 +416,23 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
return uid
- def send_itip_reply(self, uid, attendee_email, mailto, start=None, template=None, summary="test", sequence=0, partstat='ACCEPTED'):
+ def send_itip_reply(self, uid, attendee_email, mailto, start=None, template=None, summary="test", sequence=0, partstat='ACCEPTED', instance=None):
if start is None:
start = datetime.datetime.now()
end = start + datetime.timedelta(hours=4)
+
+ date_format = '%Y%m%dT%H%M%S'
+ recurrence_id = ''
+
+ if instance is not None:
+ recurrence_id = "\nRECURRENCE-ID;TZID=Europe/Berlin:" + instance.strftime(date_format)
+
self.send_message((template if template is not None else itip_reply) % {
'uid': uid,
- 'start': start.strftime('%Y%m%dT%H%M%S'),
- 'end': end.strftime('%Y%m%dT%H%M%S'),
+ 'recurrenceid': recurrence_id,
+ 'start': start.strftime(date_format),
+ 'end': end.strftime(date_format),
'mailto': attendee_email,
'organizer': mailto,
'summary': summary,
@@ -424,9 +445,15 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
return uid
- def send_itip_cancel(self, attendee_email, uid, template=None, summary="test", sequence=1):
+ def send_itip_cancel(self, attendee_email, uid, template=None, summary="test", sequence=1, instance=None):
+ recurrence_id = ''
+
+ if instance is not None:
+ recurrence_id = "\nRECURRENCE-ID;TZID=Europe/Berlin:" + instance.strftime('%Y%m%dT%H%M%S')
+
self.send_message((template if template is not None else itip_cancellation) % {
'uid': uid,
+ 'recurrenceid': recurrence_id,
'mailto': attendee_email,
'summary': summary,
'sequence': sequence,
@@ -436,7 +463,7 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
return uid
- def create_calendar_event(self, start=None, summary="test", sequence=0, user=None, attendees=None, folder=None):
+ def create_calendar_event(self, start=None, summary="test", sequence=0, user=None, attendees=None, folder=None, recurring=False, uid=None):
if start is None:
start = datetime.datetime.now(pytz.timezone("Europe/Berlin"))
if user is None:
@@ -453,12 +480,23 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
event.set_end(end)
event.set_organizer(user['mail'], user['displayname'])
+ if uid:
+ event.set_uid(uid)
+
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)
+ if recurring and isinstance(recurring, kolabformat.RecurrenceRule):
+ event.set_recurrence(rrule)
+ else:
+ rrule = kolabformat.RecurrenceRule()
+ rrule.setFrequency(kolabformat.RecurrenceRule.Daily)
+ rrule.setCount(10)
+ event.set_recurrence(rrule)
+
# create event with attachment
vattach = event.get_attachments()
attachment = kolabformat.Attachment()
@@ -1046,6 +1084,164 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
self.assertIsInstance(event, pykolab.xml.Event)
+ def test_015_update_single_occurrence(self):
+ self.purge_mailbox(self.john['mailbox'])
+
+ start = datetime.datetime(2015,4,2, 14,0,0)
+ uid = self.send_itip_invitation(self.jane['mail'], start, template=itip_recurring)
+
+ response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+ self.assertIsInstance(response, email.message.Message)
+
+ event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertTrue(event.is_recurring())
+
+ # send update to a single instance with the same sequence: no re-scheduling
+ exdate = start + datetime.timedelta(days=14)
+ self.send_itip_update(self.jane['mail'], uid, exdate, summary="test exception", sequence=0, partstat='ACCEPTED', instance=exdate)
+
+ time.sleep(10)
+ event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertEqual(len(event.get_exceptions()), 1)
+
+ exception = event.get_instance(exdate)
+ self.assertEqual(exception.get_summary(), "test exception")
+ self.assertEqual(exception.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted)
+
+
+ def test_015_reschedule_single_occurrence(self):
+ self.purge_mailbox(self.john['mailbox'])
+
+ start = datetime.datetime(2015,4,10, 9,0,0)
+ uid = self.send_itip_invitation(self.jane['mail'], start, template=itip_recurring)
+
+ response = self.check_message_received(self.itip_reply_subject % { 'summary':'test', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+ self.assertIsInstance(response, email.message.Message)
+
+ # send update to a single instance with the same sequence: no re-scheduling
+ exdate = start + datetime.timedelta(days=14)
+ exstart = exdate + datetime.timedelta(hours=5)
+ self.send_itip_update(self.jane['mail'], uid, exstart, summary="test resceduled", sequence=1, partstat='NEEDS-ACTION', instance=exdate)
+
+ response = self.check_message_received(self.itip_reply_subject % { 'summary':'test resceduled', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+ self.assertIsInstance(response, email.message.Message)
+
+ time.sleep(10)
+ event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertEqual(len(event.get_exceptions()), 1)
+
+ # re-schedule again, conflicts with itself
+ exstart = exdate + datetime.timedelta(hours=6)
+ self.send_itip_update(self.jane['mail'], uid, exstart, summary="test new", sequence=2, partstat='NEEDS-ACTION', instance=exdate)
+
+ response = self.check_message_received(self.itip_reply_subject % { 'summary':'test new', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+ self.assertIsInstance(response, email.message.Message)
+
+ # check for updated excaption
+ time.sleep(10)
+ event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertEqual(len(event.get_exceptions()), 1)
+
+ exception = event.get_instance(exdate)
+ self.assertIsInstance(exception, pykolab.xml.Event)
+ self.assertEqual(exception.get_start().strftime('%Y%m%dT%H%M%S'), exstart.strftime('%Y%m%dT%H%M%S'))
+
+
+ def test_016_reply_single_occurrence(self):
+ self.purge_mailbox(self.john['mailbox'])
+
+ start = datetime.datetime(2015,3,7, 10,0,0, tzinfo=pytz.timezone("Europe/Zurich"))
+ uid = self.create_calendar_event(start, attendees=[self.jane, self.mark], recurring=True)
+
+ event = self.check_user_calendar_event(self.john['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+
+ # store a copy in mark's calendar, too
+ self.create_calendar_event(start, attendees=[self.jane, self.mark], recurring=True, folder=self.mark['kolabcalendarfolder'], uid=uid)
+
+ # send a reply for a single occurrence from jane
+ exdate = start + datetime.timedelta(days=7)
+ self.send_itip_reply(uid, self.jane['mail'], self.john['mail'], start=exdate, instance=exdate)
+
+ # check for the updated event in john's calendar
+ time.sleep(10)
+ event = self.check_user_calendar_event(self.john['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertEqual(len(event.get_exceptions()), 1)
+
+ exception = event.get_instance(exdate)
+ self.assertEqual(exception.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted)
+
+ # check mark's copy for partstat update being stored in an exception, too
+ marks = self.check_user_calendar_event(self.mark['kolabcalendarfolder'], uid)
+ self.assertIsInstance(marks, pykolab.xml.Event)
+ self.assertEqual(len(marks.get_exceptions()), 1)
+
+ exception = marks.get_instance(exdate)
+ self.assertEqual(exception.get_attendee(self.jane['mail']).get_participant_status(), kolabformat.PartAccepted)
+
+ # send a reply for a the entire series from mark
+ self.send_itip_reply(uid, self.mark['mail'], self.john['mail'], start=start)
+
+ # check for the updated event in john's calendar
+ time.sleep(10)
+ event = self.check_user_calendar_event(self.john['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertEqual(len(event.get_exceptions()), 1)
+
+ exception = event.get_instance(exdate)
+ self.assertEqual(exception.get_attendee(self.mark['mail']).get_participant_status(), kolabformat.PartAccepted)
+
+ def test_017_cancel_single_occurrence(self):
+ self.purge_mailbox(self.john['mailbox'])
+
+ start = datetime.datetime(2015,3,20, 19,0,0, tzinfo=pytz.timezone("Europe/Zurich"))
+ uid = self.send_itip_invitation(self.jane['mail'], summary="recurring", start=start, template=itip_recurring)
+
+ response = self.check_message_received(self.itip_reply_subject % { 'summary':'recurring', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+ self.assertIsInstance(response, email.message.Message)
+
+ event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+
+ exdate = start + datetime.timedelta(days=14)
+ self.send_itip_cancel(self.jane['mail'], uid, summary="recurring cancelled", instance=exdate)
+
+ time.sleep(10)
+ event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertEqual(len(event.get_exceptions()), 1)
+
+ exception = event.get_instance(exdate)
+ self.assertEqual(exception.get_status(True), 'CANCELLED')
+ self.assertTrue(exception.get_transparency())
+
+ # send a new invitation for the cancelled slot
+ uid = self.send_itip_invitation(self.jane['mail'], summary="new booking", start=exdate)
+
+ response = self.check_message_received(self.itip_reply_subject % { 'summary':'new booking', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+ self.assertIsInstance(response, email.message.Message)
+
+
+ def test_018_invite_individual_occurrences(self):
+ self.purge_mailbox(self.john['mailbox'])
+
+ start = datetime.datetime(2015,1,30, 17,0,0, tzinfo=pytz.timezone("Europe/Zurich"))
+ uid = self.send_itip_invitation(self.jane['mail'], summary="single", start=start, instance=start)
+
+ response = self.check_message_received(self.itip_reply_subject % { 'summary':'single', 'status':participant_status_label('ACCEPTED') }, self.jane['mail'])
+ self.assertIsInstance(response, email.message.Message)
+ self.assertIn("RECURRENCE-ID", str(response))
+
+ event = self.check_user_calendar_event(self.jane['kolabcalendarfolder'], uid)
+ self.assertIsInstance(event, pykolab.xml.Event)
+ self.assertIsInstance(event.get_recurrence_id(), datetime.datetime)
+
+
def test_020_task_assignment_accept(self):
start = datetime.datetime(2014,9,10, 19,0,0)
uid = self.send_itip_invitation(self.jane['mail'], start, summary='work', template=itip_todo)
diff --git a/wallace/module_invitationpolicy.py b/wallace/module_invitationpolicy.py
index f8c0b8e..4ac7eef 100644
--- a/wallace/module_invitationpolicy.py
+++ b/wallace/module_invitationpolicy.py
@@ -25,6 +25,7 @@ from urlparse import urlparse
import urllib
import hashlib
import traceback
+import re
from email import message_from_string
from email.parser import Parser
@@ -398,11 +399,34 @@ def process_itip_request(itip_event, policy, recipient_email, sender_email, rece
condition_fulfilled = True
# find existing event in user's calendar
- existing = find_existing_object(itip_event['uid'], itip_event['type'], receiving_user, True)
+ existing = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True)
+ master = None
# compare sequence number to determine a (re-)scheduling request
if existing is not None:
log.debug(_("Existing %s: %r") % (existing.type, existing), level=9)
+
+ # reply for a single instance only
+ if itip_event['recurrence-id'] is not None:
+ log.debug(_("REQUEST refers to a single occurrence at %s") % (str(itip_event['recurrence-id'])), level=8)
+ # find instance in a recurring series
+ if existing.is_recurring():
+ master = existing
+ existing = master.get_instance(itip_event['recurrence-id'])
+ # compare recurrence-id with the found event
+ elif not xmlutils.dates_equal(itip_event['recurrence-id'], existing.get_recurrence_id()):
+ existing = None
+
+ if not existing:
+ log.info(_("The iTip REQUEST refers to an unknown occurrence '%s' of object '%s'. Forwarding to Inbox.") % (
+ str(itip_event['recurrence-id']), itip_event['uid']
+ ))
+ return MESSAGE_FORWARD
+
+ if master:
+ setattr(existing, '_imap_folder', master._imap_folder)
+ setattr(existing, '_msguid', master._msguid)
+
scheduling_required = itip_event['sequence'] > 0 and itip_event['sequence'] > existing.get_sequence()
save_object = True
@@ -478,7 +502,7 @@ def process_itip_request(itip_event, policy, recipient_email, sender_email, rece
if not nonpart or existing:
# save new copy from iTip
- if store_object(itip_event['xml'], receiving_user, targetfolder):
+ if store_object(itip_event['xml'], receiving_user, targetfolder, master):
if policy & COND_FORWARD:
log.debug(_("Forward invitation for notification"), level=5)
return MESSAGE_FORWARD
@@ -509,9 +533,27 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
# find existing event in user's calendar
# sets/checks lock to avoid concurrent wallace processes trying to update the same event simultaneously
- existing = find_existing_object(itip_event['uid'], itip_event['type'], receiving_user, True)
+ existing = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True)
+ master = None
if existing:
+ # reply for a single instance only
+ if itip_event['recurrence-id'] is not None:
+ log.debug(_("REPLY refers to a single occurrence at %s") % (str(itip_event['recurrence-id'])), level=8)
+ # find instance in a recurring series
+ if existing.is_recurring():
+ master = existing
+ existing = master.get_instance(itip_event['recurrence-id'])
+ # compare recurrence-id with the found event
+ elif not xmlutils.dates_equal(itip_event['recurrence-id'], existing.get_recurrence_id()):
+ existing = None
+
+ if not existing:
+ log.info(_("The iTip REPLY refers to an unknown occurrence '%s' of object '%s'. Forwarding to Inbox.") % (
+ str(itip_event['recurrence-id']), itip_event['uid']
+ ))
+ return MESSAGE_FORWARD
+
# compare sequence number to avoid outdated replies?
if not itip_event['sequence'] == existing.get_sequence():
log.info(_("The iTip reply sequence (%r) doesn't match the referred object version (%r). Forwarding to Inbox.") % (
@@ -521,16 +563,18 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
return MESSAGE_FORWARD
log.debug(_("Auto-updating %s %r on iTip REPLY") % (existing.type, existing.uid), level=8)
+ updated_attendees = []
try:
existing_attendee = existing.get_attendee(sender_email)
existing.set_attendee_participant_status(sender_email, sender_attendee.get_participant_status(), rsvp=False)
+ updated_attendees.append(existing_attendee)
except Exception, e:
log.error("Could not find corresponding attende in organizer's copy: %r" % (e))
# append delegated-from attendee ?
if len(sender_attendee.get_delegated_from()) > 0:
- existing._attendees.append(sender_attendee)
- existing.event.setAttendees(existing._attendees)
+ existing.add_attendee(sender_attendee)
+ updated_attendees.append(sender_attendee)
else:
# TODO: accept new participant if ACT_ACCEPT ?
remove_write_lock(existing._lock_key)
@@ -544,28 +588,30 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
existing_delegatee = existing.find_attendee(delegatee_email)
if not existing_delegatee:
- existing._attendees.append(sender_delegatee)
+ existing.add_attendee(sender_delegatee)
log.debug(_("Add delegatee: %r") % (sender_delegatee.to_dict()), level=9)
else:
existing_delegatee.copy_from(sender_delegatee)
log.debug(_("Update existing delegatee: %r") % (existing_delegatee.to_dict()), level=9)
+ updated_attendees.append(sender_delegatee)
+
# copy all parameters from replying attendee (e.g. delegated-to, role, etc.)
existing_attendee.copy_from(sender_attendee)
- existing.event.setAttendees(existing._attendees)
+ existing.update_attendees([existing_attendee])
log.debug(_("Update delegator: %r") % (existing_attendee.to_dict()), level=9)
except Exception, e:
log.error("Could not find delegated-to attendee: %r" % (e))
# update the organizer's copy of the object
- if update_object(existing, receiving_user):
+ if update_object(existing, receiving_user, master):
if policy & COND_NOTIFY:
send_update_notification(existing, receiving_user, existing, True)
# update all other attendee's copies
if conf.get('wallace','invitationpolicy_autoupdate_other_attendees_on_reply'):
- propagate_changes_to_attendees_accounts(existing)
+ propagate_changes_to_attendees_accounts(existing, updated_attendees)
return MESSAGE_PROCESSED
@@ -589,12 +635,30 @@ def process_itip_cancel(itip_event, policy, recipient_email, sender_email, recei
# auto-update the local copy with STATUS=CANCELLED
if policy & ACT_UPDATE:
# find existing object in user's folders
- existing = find_existing_object(itip_event['uid'], itip_event['type'], receiving_user, True)
+ existing = find_existing_object(itip_event['uid'], itip_event['type'], itip_event['recurrence-id'], receiving_user, True)
+ master = None
if existing:
+ # reply for a single instance only
+ if itip_event['recurrence-id'] is not None:
+ log.debug(_("CANCEL refers to a single occurrence at %s") % (str(itip_event['recurrence-id'])), level=8)
+ # find instance in a recurring series
+ if existing.is_recurring():
+ master = existing
+ existing = master.get_instance(itip_event['recurrence-id'])
+ # compare recurrence-id with the found event
+ elif not xmlutils.dates_equal(itip_event['recurrence-id'], existing.get_recurrence_id()):
+ existing = None
+
+ if not existing:
+ log.info(_("The iTip CANCEL refers to an unknown occurrence '%s' of object '%s'. Forwarding to Inbox.") % (
+ str(itip_event['recurrence-id']), itip_event['uid']
+ ))
+ return MESSAGE_FORWARD
+
existing.set_status('CANCELLED')
existing.set_transparency(True)
- if update_object(existing, receiving_user):
+ if update_object(existing, receiving_user, master):
# send cancellation notification
if policy & ACT_UPDATE_AND_NOTIFY:
send_cancel_notification(existing, receiving_user)
@@ -602,7 +666,7 @@ def process_itip_cancel(itip_event, policy, recipient_email, sender_email, recei
return MESSAGE_PROCESSED
else:
- log.error(_("The object referred by this reply was not found in the user's folders. Forwarding to Inbox."))
+ log.error(_("The object referred by this cancel request was not found in the user's folders. Forwarding to Inbox."))
return MESSAGE_FORWARD
return None
@@ -735,7 +799,7 @@ def list_user_folders(user_rec, type):
for folder in folders:
# exclude shared and other user's namespace
- if not ns_other is None and folder.startswith(ns_other):
+ if not ns_other is None and folder.startswith(ns_other) and user_rec.has_key('_delegated_mailboxes'):
# allow shared folders from delegators
if len([_mailbox for _mailbox in user_rec['_delegated_mailboxes'] if folder.startswith(ns_other + _mailbox + '/')]) == 0:
continue;
@@ -765,7 +829,7 @@ def list_user_folders(user_rec, type):
return result
-def find_existing_object(uid, type, user_rec, lock=False):
+def find_existing_object(uid, type, recurrence_id, user_rec, lock=False):
"""
Search user's private folders for the given object (by UID+type)
"""
@@ -782,9 +846,14 @@ def find_existing_object(uid, type, user_rec, lock=False):
log.debug(_("Searching folder %r for %s %r") % (folder, type, uid), level=8)
imap.imap.m.select(imap.folder_utf7(folder))
- typ, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid))
+ res, data = imap.imap.m.search(None, '(UNDELETED HEADER SUBJECT "%s")' % (uid))
for num in reversed(data[0].split()):
- typ, data = imap.imap.m.fetch(num, '(RFC822)')
+ res, data = imap.imap.m.fetch(num, '(UID RFC822)')
+ msguid = None
+
+ grep = re.search(r" UID (\d+)", data[0][0])
+ if grep:
+ msguid = grep.group(1)
try:
if type == 'task':
@@ -792,8 +861,16 @@ def find_existing_object(uid, type, user_rec, lock=False):
else:
event = event_from_message(message_from_string(data[0][1]))
+ # compare recurrence-id and skip to next message if not matching
+ if recurrence_id and not event.is_recurring() and not xmlutils.dates_equal(recurrence_id, event.get_recurrence_id()):
+ log.debug(_("Recurrence-ID not matching on message %s, skipping: %r != %r") % (
+ msguid, recurrence_id, event.get_recurrence_id()
+ ), level=8)
+ continue
+
setattr(event, '_imap_folder', folder)
setattr(event, '_lock_key', lock_key)
+ setattr(event, '_msguid', msguid)
except Exception, e:
log.error(_("Failed to parse %s from message %s/%s: %s") % (type, folder, num, traceback.format_exc()))
continue
@@ -824,12 +901,12 @@ def check_availability(itip_event, receiving_user):
log.debug(_("Listing events from folder %r") % (folder), level=8)
imap.imap.m.select(imap.folder_utf7(folder))
- typ, data = imap.imap.m.search(None, '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")')
+ res, data = imap.imap.m.search(None, '(UNDELETED HEADER X-Kolab-Type "application/x-vnd.kolab.event")')
num_messages += len(data[0].split())
for num in reversed(data[0].split()):
event = None
- typ, data = imap.imap.m.fetch(num, '(RFC822)')
+ res, data = imap.imap.m.fetch(num, '(RFC822)')
try:
event = event_from_message(message_from_string(data[0][1]))
@@ -907,25 +984,30 @@ def get_lock_key(user, uid):
return hashlib.md5("%s/%s" % (user['mail'], uid)).hexdigest()
-def update_object(object, user_rec):
+def update_object(object, user_rec, master=None):
"""
Update the given object in IMAP (i.e. delete + append)
"""
success = False
+ saveobj = object
- if hasattr(object, '_imap_folder'):
- delete_object(object)
- object.set_lastmodified() # update last-modified timestamp
- success = store_object(object, user_rec, object._imap_folder)
+ # updating a single instance only: use master event
+ if object.get_recurrence_id() and master:
+ saveobj = master
+
+ if hasattr(saveobj, '_imap_folder'):
+ if delete_object(saveobj):
+ saveobj.set_lastmodified() # update last-modified timestamp
+ success = store_object(object, user_rec, saveobj._imap_folder, master)
# remove write lock for this event
- if hasattr(object, '_lock_key') and object._lock_key is not None:
- remove_write_lock(object._lock_key)
+ if hasattr(saveobj, '_lock_key') and saveobj._lock_key is not None:
+ remove_write_lock(saveobj._lock_key)
return success
-def store_object(object, user_rec, targetfolder=None):
+def store_object(object, user_rec, targetfolder=None, master=None):
"""
Append the given object to the user's default calendar/tasklist
"""
@@ -943,7 +1025,15 @@ def store_object(object, user_rec, targetfolder=None):
log.error(_("Failed to save %s: no target folder found for user %r") % (object.type, user_rec['mail']))
return Fasle
- log.debug(_("Save %s %r to user folder %r") % (object.type, object.uid, targetfolder), level=8)
+ saveobj = object
+
+ # updating a single instance only: add exception to master event
+ if object.get_recurrence_id() and master:
+ object.set_lastmodified() # update last-modified timestamp
+ master.add_exception(object)
+ saveobj = master
+
+ log.debug(_("Save %s %r to user folder %r") % (saveobj.type, saveobj.uid, targetfolder), level=8)
try:
imap.imap.m.select(imap.folder_utf7(targetfolder))
@@ -951,13 +1041,13 @@ def store_object(object, user_rec, targetfolder=None):
imap.folder_utf7(targetfolder),
None,
None,
- object.to_message(creator="Kolab Server <wallace at localhost>").as_string()
+ saveobj.to_message(creator="Kolab Server <wallace at localhost>").as_string()
)
return result
except Exception, e:
log.error(_("Failed to save %s to user folder at %r: %r") % (
- object.type, targetfolder, e
+ saveobj.type, targetfolder, e
))
return False
@@ -968,18 +1058,37 @@ def delete_object(existing):
Removes the IMAP object with the given UID from a user's folder
"""
targetfolder = existing._imap_folder
- imap.imap.m.select(imap.folder_utf7(targetfolder))
+ msguid = existing._msguid if hasattr(existing, '_msguid') else None
- typ, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % existing.uid)
+ try:
+ imap.imap.m.select(imap.folder_utf7(targetfolder))
- log.debug(_("Delete %s %r in %r: %r") % (
- existing.type, existing.uid, targetfolder, data
- ), level=8)
+ # delete by IMAP UID
+ if msguid is not None:
+ log.debug(_("Delete %s %r in %r by UID: %r") % (
+ existing.type, existing.uid, targetfolder, msguid
+ ), level=8)
+
+ imap.imap.m.uid('store', msguid, '+FLAGS', '(\\Deleted)')
+ else:
+ res, data = imap.imap.m.search(None, '(HEADER SUBJECT "%s")' % existing.uid)
+
+ log.debug(_("Delete %s %r in %r: %r") % (
+ existing.type, existing.uid, targetfolder, data
+ ), level=8)
+
+ for num in data[0].split():
+ imap.imap.m.store(num, '+FLAGS', '(\\Deleted)')
- for num in data[0].split():
- imap.imap.m.store(num, '+FLAGS', '\\Deleted')
+ imap.imap.m.expunge()
+ return True
- imap.imap.m.expunge()
+ except Exception, e:
+ log.error(_("Failed to delete %s from folder %r: %r") % (
+ existing.type, targetfolder, e
+ ))
+
+ return False
def send_update_notification(object, receiving_user, old=None, reply=True):
@@ -1076,6 +1185,9 @@ def send_update_notification(object, receiving_user, old=None, reply=True):
'roundup': roundup
}
+ if object.get_recurrence_id():
+ message_text += "\n" + _("NOTE: This update only refers to this single occurrence!")
+
message_text += "\n" + _("*** This is an automated message. Please do not reply. ***")
# compose mime message
@@ -1092,7 +1204,8 @@ def send_update_notification(object, receiving_user, old=None, reply=True):
smtp.set_debuglevel(True)
try:
- smtp.sendmail(orgemail, receiving_user['mail'], msg.as_string())
+ success = smtp.sendmail(orgemail, receiving_user['mail'], msg.as_string())
+ log.debug(_("Sent update notification to %r: %r") % (receiving_user['mail'], success), level=8)
except Exception, e:
log.error(_("SMTP sendmail error: %r") % (e))
@@ -1191,23 +1304,42 @@ def check_policy_condition(policy, available):
return condition_fulfilled
-def propagate_changes_to_attendees_accounts(object):
+def propagate_changes_to_attendees_accounts(object, updated_attendees=None):
"""
Find and update copies of this object in all attendee's personal folders
"""
+ recurrence_id = object.get_recurrence_id()
+
for attendee in object.get_attendees():
attendee_user_dn = user_dn_from_email_address(attendee.get_email())
if attendee_user_dn:
attendee_user = auth.get_entry_attributes(None, attendee_user_dn, ['*'])
- attendee_object = find_existing_object(object.uid, object.type, attendee_user, True) # does IMAP authenticate
+ attendee_object = find_existing_object(object.uid, object.type, recurrence_id, attendee_user, True) # does IMAP authenticate
if attendee_object:
- try:
- attendee_entry = attendee_object.get_attendee_by_email(attendee_user['mail'])
- except:
- attendee_entry = None
+ master_object = None
+
+ # updating a single instance only: add exception to master event
+ if recurrence_id:
+ master_object = attendee_object
+ attendee_object = master_object.get_instance(recurrence_id)
+ if attendee_object is None:
+ log.debug(_("Unable to find occurrence '%s' in %s's copy of object %r") % (
+ str(recurrence_id), attendee_user['mail'], object.uid
+ ), level=5)
+ break
+
+ # find attendee's entry by one of its email addresses
+ attendee_emails = auth.extract_recipient_addresses(attendee_user)
+ for attendee_email in attendee_emails:
+ try:
+ attendee_entry = attendee_object.get_attendee_by_email(attendee_email)
+ except:
+ attendee_entry = None
+ if attendee_entry:
+ break
# copy all attendees from master object (covers additions and removals)
- new_attendees = kolabformat.vectorattendee();
+ new_attendees = [];
for a in object.get_attendees():
# keep my own entry intact
if attendee_entry is not None and attendee_entry.get_email() == a.get_email():
@@ -1215,9 +1347,13 @@ def propagate_changes_to_attendees_accounts(object):
else:
new_attendees.append(a)
- attendee_object.event.setAttendees(new_attendees)
+ attendee_object.set_attendees(new_attendees)
+
+ if updated_attendees and not recurrence_id:
+ log.debug("Update Attendees %r for %s" % ([a.get_email()+':'+a.get_participant_status(True) for a in updated_attendees], attendee_user['mail']), level=8)
+ attendee_object.update_attendees(updated_attendees, False)
- success = update_object(attendee_object, attendee_user)
+ success = update_object(attendee_object, attendee_user, master_object)
log.debug(_("Updated %s's copy of %r: %r") % (attendee_user['mail'], object.uid, success), level=8)
else:
commit 94cd4fab8e9e80111fc3c07636ab0fae4af4c49d
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date: Tue Feb 17 20:50:22 2015 +0100
Consider transparency and 'cancelled' status for recurrence exceptions
diff --git a/pykolab/itip/__init__.py b/pykolab/itip/__init__.py
index 848b2d7..961c3dc 100644
--- a/pykolab/itip/__init__.py
+++ b/pykolab/itip/__init__.py
@@ -1,6 +1,7 @@
import icalendar
import pykolab
import traceback
+import kolabformat
from pykolab.xml import to_dt
from pykolab.xml import event_from_ical
@@ -86,6 +87,7 @@ def objects_from_message(message, objnames, methods=None):
itip['uid'] = str(c['uid'])
itip['method'] = str(cal['method']).upper()
itip['sequence'] = int(c['sequence']) if c.has_key('sequence') else 0
+ itip['recurrence-id'] = c['recurrence-id'].dt if c.has_key('recurrence-id') and hasattr(c['recurrence-id'], 'dt') else None
if c.has_key('dtstart'):
itip['start'] = c['dtstart'].dt
@@ -151,21 +153,23 @@ def check_event_conflict(kolab_event, itip_event):
return conflict
# don't consider conflict if event has TRANSP:TRANSPARENT
- if kolab_event.get_transparency():
+ if _is_transparent(kolab_event):
return conflict
_es = to_dt(kolab_event.get_start())
_ee = to_dt(kolab_event.get_ical_dtend()) # use iCal style end date: next day for all-day events
+ _ev = kolab_event
# naive loops to check for collisions in (recurring) events
# TODO: compare recurrence rules directly (e.g. matching time slot or weekday or monthday)
while not conflict and _es is not None:
_is = to_dt(itip_event['start'])
_ie = to_dt(itip_event['end'])
+ _iv = itip_event['xml']
while not conflict and _is is not None:
# log.debug("* Comparing event dates at %s/%s with %s/%s" % (_es, _ee, _is, _ie), level=9)
- conflict = check_date_conflict(_es, _ee, _is, _ie)
+ conflict = not _is_transparent(_ev) and not _is_transparent(_iv) and check_date_conflict(_es, _ee, _is, _ie)
_is = to_dt(itip_event['xml'].get_next_occurence(_is)) if itip_event['xml'].is_recurring() else None
_ie = to_dt(itip_event['xml'].get_occurence_end_date(_is))
@@ -175,6 +179,7 @@ def check_event_conflict(kolab_event, itip_event):
if _ix is not None:
_is = to_dt(_ix.get_start())
_ie = to_dt(_ix.get_end())
+ _iv = _ix
_es = to_dt(kolab_event.get_next_occurence(_es)) if kolab_event.is_recurring() else None
_ee = to_dt(kolab_event.get_occurence_end_date(_es))
@@ -185,10 +190,15 @@ def check_event_conflict(kolab_event, itip_event):
if _ex is not None:
_es = to_dt(_ex.get_start())
_ee = to_dt(_ex.get_end())
+ _ev = _ex
return conflict
+def _is_transparent(event):
+ return event.get_transparency() or event.get_status() == kolabformat.StatusCancelled
+
+
def check_date_conflict(_es, _ee, _is, _ie):
"""
Check the given event start/end dates for conflicts
diff --git a/tests/unit/test-011-itip.py b/tests/unit/test-011-itip.py
index dafa645..80b9df6 100644
--- a/tests/unit/test-011-itip.py
+++ b/tests/unit/test-011-itip.py
@@ -461,13 +461,21 @@ class TestITip(unittest.TestCase):
event5.set_start(datetime.datetime(2012,7,9, 10,0,0, tzinfo=pytz.timezone("Europe/London")))
event5.set_end(datetime.datetime(2012,7,9, 11,0,0, tzinfo=pytz.timezone("Europe/London")))
- exception = Event(from_string=str(event5))
+ event_xml = str(event5)
+ exception = Event(from_string=event_xml)
exception.set_start(datetime.datetime(2012,7,13, 14,0,0, tzinfo=pytz.timezone("Europe/London")))
exception.set_end(datetime.datetime(2012,7,13, 16,0,0, tzinfo=pytz.timezone("Europe/London")))
exception.set_recurrence_id(datetime.datetime(2012,7,13, 10,0,0, tzinfo=pytz.timezone("Europe/London")), False)
event5.add_exception(exception)
self.assertFalse(itip.check_event_conflict(event5, itip_event), "No conflict with exception date")
+ exception = Event(from_string=event_xml)
+ exception.set_start(datetime.datetime(2012,7,13, 10,0,0, tzinfo=pytz.timezone("Europe/London")))
+ exception.set_end(datetime.datetime(2012,7,13, 11,0,0, tzinfo=pytz.timezone("Europe/London")))
+ exception.set_status('CANCELLED')
+ exception.set_recurrence_id(datetime.datetime(2012,7,13, 10,0,0, tzinfo=pytz.timezone("Europe/London")), False)
+ event5.add_exception(exception)
+ self.assertFalse(itip.check_event_conflict(event5, itip_event), "No conflict with cancelled exception")
def test_003_send_reply(self):
itip_events = itip.events_from_message(message_from_string(itip_non_multipart))
More information about the commits
mailing list