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

Thomas Brüderli bruederli at kolabsys.com
Mon Aug 25 14:45:54 CEST 2014


 pykolab/xml/__init__.py                                    |    6 
 pykolab/xml/attendee.py                                    |   10 
 pykolab/xml/event.py                                       |    7 
 pykolab/xml/recurrence_rule.py                             |   17 +
 pykolab/xml/utils.py                                       |  193 +++++++++++++
 tests/functional/test_wallace/test_007_invitationpolicy.py |    2 
 tests/unit/test-003-event.py                               |   58 +++
 wallace/module_invitationpolicy.py                         |   29 +
 wallace/module_resources.py                                |    4 
 9 files changed, 310 insertions(+), 16 deletions(-)

New commits:
commit fd68e0f4527f27fb406861036108d44cf500612e
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Aug 22 15:12:45 2014 -0400

    List event/task properties changes in update notification mails (#3447)

diff --git a/pykolab/xml/__init__.py b/pykolab/xml/__init__.py
index 3ca52b2..2c99717 100644
--- a/pykolab/xml/__init__.py
+++ b/pykolab/xml/__init__.py
@@ -19,6 +19,8 @@ from todo import todo_from_ical
 from todo import todo_from_string
 from todo import todo_from_message
 
+from utils import property_label
+from utils import property_to_string
 from utils import compute_diff
 from utils import to_dt
 
@@ -35,6 +37,8 @@ __all__ = [
         "todo_from_ical",
         "todo_from_string",
         "todo_from_message",
+        "property_label",
+        "property_to_string",
         "compute_diff",
         "to_dt",
     ]
diff --git a/pykolab/xml/event.py b/pykolab/xml/event.py
index 24b026e..7b9e13c 100644
--- a/pykolab/xml/event.py
+++ b/pykolab/xml/event.py
@@ -365,7 +365,12 @@ class Event(object):
                 dt = self.get_start() + duration
         return dt
 
-    def get_date_text(self, date_format='%Y-%m-%d', time_format='%H:%M %Z'):
+    def get_date_text(self, date_format=None, time_format=None):
+        if date_format is None:
+            date_format = _("%Y-%m-%d")
+        if time_format is None:
+            time_format = _("%H:%M (%Z)")
+
         start = self.get_start()
         end = self.get_end()
         all_day = not hasattr(start, 'date')
diff --git a/pykolab/xml/recurrence_rule.py b/pykolab/xml/recurrence_rule.py
index 4a0b6c5..37474b1 100644
--- a/pykolab/xml/recurrence_rule.py
+++ b/pykolab/xml/recurrence_rule.py
@@ -1,6 +1,9 @@
 import kolabformat
 from pykolab.xml import utils as xmlutils
 
+from pykolab.translate import _
+from pykolab.translate import N_
+
 """
     def setFrequency(self, *args): return _kolabformat.RecurrenceRule_setFrequency(self, *args)
     def frequency(self): return _kolabformat.RecurrenceRule_frequency(self)
@@ -31,6 +34,20 @@ from pykolab.xml import utils as xmlutils
     def isValid(self): return _kolabformat.RecurrenceRule_isValid(self)
 """
 
+frequency_labels = {
+    "YEARLY":   N_("Every %d year(s)"),
+    "MONTHLY":  N_("Every %d month(s)"),
+    "WEEKLY":   N_("Every %d week(s)"),
+    "DAILY":    N_("Every %d day(s)"),
+    "HOURLY":   N_("Every %d hours"),
+    "MINUTELY": N_("Every %d minutes"),
+    "SECONDLY": N_("Every %d seconds")
+}
+
+def frequency_label(freq):
+    return _(frequency_labels[freq]) if frequency_labels.has_key(freq) else _(freq)
+
+
 class RecurrenceRule(kolabformat.RecurrenceRule):
     frequency_map = {
         None: kolabformat.RecurrenceRule.FreqNone,
diff --git a/pykolab/xml/utils.py b/pykolab/xml/utils.py
index 35d7578..2fe82d2 100644
--- a/pykolab/xml/utils.py
+++ b/pykolab/xml/utils.py
@@ -4,6 +4,9 @@ import kolabformat
 from dateutil.tz import tzlocal
 from collections import OrderedDict
 
+from pykolab.translate import _
+from pykolab.translate import N_
+
 
 def to_dt(dt):
     """
@@ -113,6 +116,113 @@ def to_cdatetime(_datetime, with_timezone=True, as_utc=False):
     return _cdatetime
 
 
+property_labels = {
+    "name":        N_("Name"),
+    "summary":     N_("Summary"),
+    "location":    N_("Location"),
+    "description": N_("Description"),
+    "url":         N_("URL"),
+    "status":      N_("Status"),
+    "priority":    N_("Priority"),
+    "attendee":    N_("Attendee"),
+    "start":       N_("Start"),
+    "end":         N_("End"),
+    "due":         N_("Due"),
+    "rrule":       N_("Repeat"),
+    "exdate":      N_("Repeat Exception"),
+    "organizer":   N_("Organizer"),
+    "attach":      N_("Attachment"),
+    "alarm":       N_("Alarm"),
+    "classification":   N_("Classification"),
+    "percent-complete": N_("Progress")
+}
+
+def property_label(propname):
+    """
+        Return a localized name for the given object property
+    """
+    return _(property_labels[propname]) if property_labels.has_key(propname) else _(propname)
+
+
+def property_to_string(propname, value):
+    """
+        Render a human readable string for the given object property
+    """
+    date_format = _("%Y-%m-%d")
+    time_format = _("%H:%M (%Z)")
+    date_time_format = date_format + " " + time_format
+    maxlen = 50
+
+    if isinstance(value, datetime.datetime):
+        return value.strftime(date_time_format)
+    elif isinstance(value, datetime.date):
+        return value.strftime(date_format)
+    elif isinstance(value, int):
+        return str(value)
+    elif isinstance(value, str):
+        if len(value) > maxlen:
+            return value[:maxlen].rsplit(' ', 1)[0] + '...'
+        return value
+    elif isinstance(value, object) and hasattr(value, 'to_dict'):
+        value = value.to_dict()
+
+    if isinstance(value, dict):
+        if propname == 'attendee':
+            from . import attendee
+            name = value['name'] if value.has_key('name') and not value['name'] == '' else value['email']
+            return "%s, %s" % (name, attendee.participant_status_label(value['partstat']))
+
+        elif propname == 'organizer':
+            return value['name'] if value.has_key('name') and not value['name'] == '' else value['email']
+
+        elif propname == 'rrule':
+            from . import recurrence_rule
+            rrule = recurrence_rule.frequency_label(value['frequency']) % (value['interval'])
+            if value.has_key('count') and value['count'] > 0:
+                rrule += " " + _("for %d times") % (value['count'])
+            elif value.has_key('until') and (isinstance(value['until'], datetime.datetime) or isinstance(value['until'], datetime.date)):
+                rrule += " " + _("until %s") % (value['until'].strftime(date_format))
+            return rrule
+
+        elif propname == 'alarm':
+            alarm_type_labels = {
+                'DISPLAY': _("Display message"),
+                'EMAIL':   _("Send email"),
+                'AUDIO':   _("Play sound")
+            }
+            alarm = alarm_type_labels.get(value['action'], "")
+            if isinstance(value['trigger'], datetime.datetime):
+                alarm += " @ " + property_to_string('trigger', value['trigger'])
+            else:
+                rel = _("%s after") if value['trigger']['related'] == 'END' else _("%s before")
+                offsets = []
+                try:
+                    from icalendar import vDuration
+                    duration = vDuration.from_ical(value['trigger']['value'].strip('-'))
+                except:
+                    return None
+
+                if duration.days:
+                    offsets.append(_("%d day(s)") % (duration.days))
+                if duration.seconds:
+                    hours = duration.seconds // 3600
+                    minutes = duration.seconds % 3600 // 60
+                    seconds = duration.seconds % 60
+                    if hours:
+                        offsets.append(_("%d hour(s)") % (hours))
+                    if minutes or (hours and seconds):
+                        offsets.append(_("%d minute(s)") % (minutes))
+                if len(offsets):
+                    alarm += " " + rel % (", ".join(offsets))
+
+            return alarm
+
+        elif propname == 'attach':
+            return value['label'] if value.has_key('label') else value['fmttype']
+
+    return None
+
+
 def compute_diff(a, b, reduced=False):
     """
         List the differences between two given dicts
@@ -137,7 +247,7 @@ def compute_diff(a, b, reduced=False):
             while index < length:
                 aai = aa[index] if index < len(aa) else None
                 bbi = bb[index] if index < len(bb) else None
-                if not aai == bbi:
+                if not compare_values(aai, bbi):
                     if reduced:
                         (old, new) = reduce_properties(aai, bbi)
                     else:
@@ -146,7 +256,7 @@ def compute_diff(a, b, reduced=False):
                 index += 1
 
         # the two properties differ
-        elif not aa.__class__ == bb.__class__ or not aa == bb:
+        elif not compare_values(aa, bb):
             if reduced:
                 (old, new) = reduce_properties(aa, bb)
             else:
@@ -156,6 +266,22 @@ def compute_diff(a, b, reduced=False):
     return diff
 
 
+def compare_values(aa, bb):
+    ignore_keys = ['rsvp']
+    if not aa.__class__ == bb.__class__:
+        return False
+
+    if isinstance(aa, dict) and isinstance(bb, dict):
+        aa = dict(aa)
+        bb = dict(bb)
+        # ignore some properties for comparison
+        for k in ignore_keys:
+            aa.pop(k, None)
+            bb.pop(k, None)
+
+    return aa == bb
+
+
 def reduce_properties(aa, bb):
     """
         Compares two given structs and removes equal values in bb
diff --git a/tests/functional/test_wallace/test_007_invitationpolicy.py b/tests/functional/test_wallace/test_007_invitationpolicy.py
index 8feeff0..c3be462 100644
--- a/tests/functional/test_wallace/test_007_invitationpolicy.py
+++ b/tests/functional/test_wallace/test_007_invitationpolicy.py
@@ -967,7 +967,7 @@ class TestWallaceInvitationpolicy(unittest.TestCase):
         self.assertIsInstance(todo, pykolab.xml.Todo)
 
         # send a reply from jane to john
-        partstat = 'DECLINED'
+        partstat = 'COMPLETED'
         self.send_itip_reply(uid, self.jane['mail'], self.john['mail'], start=due, template=itip_todo_reply, partstat=partstat)
 
         # check for the updated task in john's tasklist
diff --git a/tests/unit/test-003-event.py b/tests/unit/test-003-event.py
index 6a9fd4f..7124e0c 100644
--- a/tests/unit/test-003-event.py
+++ b/tests/unit/test-003-event.py
@@ -5,6 +5,7 @@ import sys
 import unittest
 import kolabformat
 import icalendar
+import pykolab
 
 from pykolab.xml import Attendee
 from pykolab.xml import Event
@@ -15,6 +16,7 @@ from pykolab.xml import event_from_ical
 from pykolab.xml import event_from_string
 from pykolab.xml import event_from_message
 from pykolab.xml import compute_diff
+from pykolab.xml import property_to_string
 from collections import OrderedDict
 
 ical_event = """
@@ -247,6 +249,17 @@ xml_event = """
 class TestEventXML(unittest.TestCase):
     event = Event()
 
+    @classmethod
+    def setUp(self):
+        """ Compatibility for twisted.trial.unittest
+        """
+        self.setup_class()
+
+    @classmethod
+    def setup_class(self, *args, **kw):
+        # set language to default
+        pykolab.translate.setUserLanguage('en_US')
+
     def assertIsInstance(self, _value, _type):
         if hasattr(unittest.TestCase, 'assertIsInstance'):
             return unittest.TestCase.assertIsInstance(self, _value, _type)
@@ -640,6 +653,18 @@ END:VEVENT
         self.assertEqual(pa['new'], dict(partstat='DECLINED'))
 
 
+    def test_026_property_to_string(self):
+        data = event_from_string(xml_event).to_dict()
+        self.assertEqual(property_to_string('sequence', data['sequence']), "1")
+        self.assertEqual(property_to_string('start', data['start']), "2014-08-13 10:00 (GMT)")
+        self.assertEqual(property_to_string('organizer', data['organizer']), "Doe, John")
+        self.assertEqual(property_to_string('attendee', data['attendee'][0]), "jane at example.org, Accepted")
+        self.assertEqual(property_to_string('rrule', data['rrule']), "Every 1 day(s) until 2014-07-25")
+        self.assertEqual(property_to_string('exdate', data['exdate'][0]), "2014-07-19")
+        self.assertEqual(property_to_string('alarm', data['alarm'][0]), "Display message 2 hour(s) before")
+        self.assertEqual(property_to_string('attach', data['attach'][0]), "noname.1395223627.5555")
+
+
     def _find_prop_in_list(self, diff, name):
         for prop in diff:
             if prop['property'] == name:
diff --git a/wallace/module_invitationpolicy.py b/wallace/module_invitationpolicy.py
index 9ba1490..5c187c5 100644
--- a/wallace/module_invitationpolicy.py
+++ b/wallace/module_invitationpolicy.py
@@ -41,6 +41,7 @@ from pykolab.auth import Auth
 from pykolab.conf import Conf
 from pykolab.imap import IMAP
 from pykolab.xml import to_dt
+from pykolab.xml import utils as xmlutils
 from pykolab.xml import todo_from_message
 from pykolab.xml import event_from_message
 from pykolab.xml import participant_status_label
@@ -237,6 +238,10 @@ def execute(*args, **kw):
     # parse full message
     message = Parser().parse(open(filepath, 'r'))
 
+    # invalid message, skip
+    if not message.get('X-Kolab-To'):
+        return filepath
+
     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]
 
@@ -421,7 +426,7 @@ def process_itip_request(itip_event, policy, recipient_email, sender_email, rece
             itip_event['xml'].set_percentcomplete(existing.get_percentcomplete())
 
         if policy & COND_NOTIFY:
-            send_update_notification(itip_event['xml'], receiving_user, False)
+            send_update_notification(itip_event['xml'], receiving_user, existing, False)
 
     # if RSVP, send an iTip REPLY
     if rsvp or scheduling_required:
@@ -533,7 +538,7 @@ def process_itip_reply(itip_event, policy, recipient_email, sender_email, receiv
             # update the organizer's copy of the object
             if update_object(existing, receiving_user):
                 if policy & COND_NOTIFY:
-                    send_update_notification(existing, receiving_user, True)
+                    send_update_notification(existing, receiving_user, existing, True)
 
                 # update all other attendee's copies
                 if conf.get('wallace','invitationpolicy_autoupdate_other_attendees_on_reply'):
@@ -947,7 +952,7 @@ def delete_object(existing):
     imap.imap.m.expunge()
 
 
-def send_update_notification(object, receiving_user, reply=True):
+def send_update_notification(object, receiving_user, old=None, reply=True):
     """
         Send a (consolidated) notification about the current participant status to organizer
     """
@@ -1005,8 +1010,18 @@ def send_update_notification(object, receiving_user, reply=True):
             if len(attendees) > 0:
                 roundup += "\n" + participant_status_label(status) + ":\n" + "\n".join(attendees) + "\n"
     else:
-        # TODO: compose a diff of changes to previous version
-        roundup = "\n" + _("Minor changes submitted by %s have been automatically applied.") % (orgname if orgname else orgemail)
+        roundup = "\n" + _("Changes submitted by %s have been automatically applied.") % (orgname if orgname else orgemail)
+
+        # list properties changed from previous version
+        if old:
+            diff = xmlutils.compute_diff(old.to_dict(), object.to_dict())
+            if len(diff) > 1:
+                roundup += "\n"
+                for change in diff:
+                    if not change['property'] in ['created','lastmodified-date','sequence']:
+                        new_value = xmlutils.property_to_string(change['property'], change['new']) if change['new'] else _("(removed)")
+                        if new_value:
+                            roundup += "\n- %s: %s" % (xmlutils.property_label(change['property']), new_value)
 
     # compose different notification texts for events/tasks
     if object.type == 'task':
@@ -1023,7 +1038,7 @@ def send_update_notification(object, receiving_user, reply=True):
             %(roundup)s
         """ % {
             'summary': object.get_summary(),
-            'start': object.get_start().strftime('%Y-%m-%d %H:%M %Z'),
+            'start': xmlutils.property_to_string('start', object.get_start()),
             'roundup': roundup
         }
 
@@ -1081,7 +1096,7 @@ def send_cancel_notification(object, receiving_user):
             The copy in your calendar as been marked as cancelled accordingly.
         """ % {
             'summary': object.get_summary(),
-            'start': object.get_start().strftime('%Y-%m-%d %H:%M %Z'),
+            'start': xmlutils.property_to_string('start', object.get_start()),
             'organizer': orgname if orgname else orgemail
         }
 


commit 3231cd8408132d3f7ddb3ac1626a049474101101
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Aug 22 13:06:58 2014 -0400

    Map additional partstat values for Todos

diff --git a/pykolab/xml/attendee.py b/pykolab/xml/attendee.py
index cdfe86a..10fd006 100644
--- a/pykolab/xml/attendee.py
+++ b/pykolab/xml/attendee.py
@@ -19,9 +19,8 @@ participant_status_labels = {
         kolabformat.PartDeclined: N_("Declined"),
         kolabformat.PartTentative: N_("Tentatively Accepted"),
         kolabformat.PartDelegated: N_("Delegated"),
-        # waiting for libkolabxml to support these (#3472)
-        #kolabformat.PartCompleted: N_("Completed"),
-        #kolabformat.PartInProcess: N_("Started"),
+        kolabformat.PartCompleted: N_("Completed"),
+        kolabformat.PartInProcess: N_("Started"),
     }
 
 def participant_status_label(status):
@@ -41,9 +40,8 @@ class Attendee(kolabformat.Attendee):
             "DECLINED": kolabformat.PartDeclined,
             "TENTATIVE": kolabformat.PartTentative,
             "DELEGATED": kolabformat.PartDelegated,
-            # waiting for libkolabxml to support these (#3472)
-            #"COMPLETED": kolabformat.PartCompleted,
-            #"IN-PROCESS": kolabformat.PartInProcess,
+            "COMPLETED": kolabformat.PartCompleted,
+            "IN-PROCESS": kolabformat.PartInProcess,
         }
 
     # See RFC 2445, 5445


commit b34c2a611bb3e76526d17a651fae4aa669ba8f43
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Aug 22 13:05:53 2014 -0400

    Basic sanity check for input message

diff --git a/wallace/module_resources.py b/wallace/module_resources.py
index aa3c473..0eb4659 100644
--- a/wallace/module_resources.py
+++ b/wallace/module_resources.py
@@ -160,6 +160,10 @@ def execute(*args, **kw):
     # parse full message
     message = Parser().parse(open(filepath, 'r'))
 
+    # invalid message, skip
+    if not message.get('X-Kolab-To'):
+        return filepath
+
     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]
 


commit 38a99ecd5b487fe47c84c970a1bb50dd5627735d
Author: Thomas Bruederli <bruederli at kolabsys.com>
Date:   Fri Aug 22 11:51:51 2014 -0400

    Add utility function to compute diffs between two objects (converted to dicts)

diff --git a/pykolab/xml/__init__.py b/pykolab/xml/__init__.py
index 20b7e9f..3ca52b2 100644
--- a/pykolab/xml/__init__.py
+++ b/pykolab/xml/__init__.py
@@ -19,6 +19,7 @@ from todo import todo_from_ical
 from todo import todo_from_string
 from todo import todo_from_message
 
+from utils import compute_diff
 from utils import to_dt
 
 __all__ = [
@@ -34,6 +35,7 @@ __all__ = [
         "todo_from_ical",
         "todo_from_string",
         "todo_from_message",
+        "compute_diff",
         "to_dt",
     ]
 
diff --git a/pykolab/xml/utils.py b/pykolab/xml/utils.py
index aa05e11..35d7578 100644
--- a/pykolab/xml/utils.py
+++ b/pykolab/xml/utils.py
@@ -2,6 +2,8 @@ import datetime
 import pytz
 import kolabformat
 from dateutil.tz import tzlocal
+from collections import OrderedDict
+
 
 def to_dt(dt):
     """
@@ -109,3 +111,68 @@ def to_cdatetime(_datetime, with_timezone=True, as_utc=False):
         _cdatetime.setUTC(True)
 
     return _cdatetime
+
+
+def compute_diff(a, b, reduced=False):
+    """
+        List the differences between two given dicts
+    """
+    diff = []
+
+    properties = a.keys()
+    properties.extend([x for x in b.keys() if x not in properties])
+
+    for prop in properties:
+        aa = a[prop] if a.has_key(prop) else None
+        bb = b[prop] if b.has_key(prop) else None
+
+        # compare two lists
+        if isinstance(aa, list) or isinstance(bb, list):
+            if not isinstance(aa, list):
+                aa = [aa]
+            if not isinstance(bb, list):
+                bb = [bb]
+            index = 0
+            length = max(len(aa), len(bb))
+            while index < length:
+                aai = aa[index] if index < len(aa) else None
+                bbi = bb[index] if index < len(bb) else None
+                if not aai == bbi:
+                    if reduced:
+                        (old, new) = reduce_properties(aai, bbi)
+                    else:
+                        (old, new) = (aai, bbi)
+                    diff.append(OrderedDict([('property', prop), ('index', index), ('old', old), ('new', new)]))
+                index += 1
+
+        # the two properties differ
+        elif not aa.__class__ == bb.__class__ or not aa == bb:
+            if reduced:
+                (old, new) = reduce_properties(aa, bb)
+            else:
+                (old, new) = (aa, bb)
+            diff.append(OrderedDict([('property', prop), ('old', old), ('new', new)]))
+
+    return diff
+
+
+def reduce_properties(aa, bb):
+    """
+        Compares two given structs and removes equal values in bb
+    """
+    if not isinstance(aa, dict) or not isinstance(bb, dict):
+        return (aa, bb)
+
+    properties = aa.keys()
+    properties.extend([x for x in bb.keys() if x not in properties])
+
+    for prop in properties:
+        if not aa.has_key(prop) or not bb.has_key(prop):
+            continue
+        if isinstance(aa[prop], dict) and isinstance(bb[prop], dict):
+            (aa[prop], bb[prop]) = reduce_properties(aa[prop], bb[prop])
+        if aa[prop] == bb[prop]:
+            # del aa[prop]
+            del bb[prop]
+
+    return (aa, bb)
diff --git a/tests/unit/test-003-event.py b/tests/unit/test-003-event.py
index d9e05fa..6a9fd4f 100644
--- a/tests/unit/test-003-event.py
+++ b/tests/unit/test-003-event.py
@@ -14,6 +14,8 @@ from pykolab.xml import InvalidEventDateError
 from pykolab.xml import event_from_ical
 from pykolab.xml import event_from_string
 from pykolab.xml import event_from_message
+from pykolab.xml import compute_diff
+from collections import OrderedDict
 
 ical_event = """
 BEGIN:VEVENT
@@ -223,7 +225,7 @@ xml_event = """
                 <text>alarm 2</text>
               </description>
               <attendee>
-                  <cal-address>mailto:%3Cjohn.die%40example.org%3E</cal-address>
+                  <cal-address>mailto:%3Cjohn.doe%40example.org%3E</cal-address>
               </attendee>
               <trigger>
                 <parameters>
@@ -615,6 +617,35 @@ END:VEVENT
         self.assertEqual(data['alarm'][1]['trigger']['value'], '-P1D')
         self.assertEqual(len(data['alarm'][1]['attendee']), 1)
 
+    def test_026_compute_diff(self):
+        e1 = event_from_string(xml_event)
+        e2 = event_from_string(xml_event)
+
+        e2.set_summary("test2")
+        e2.set_end(e1.get_end() + datetime.timedelta(hours=2))
+        e2.set_sequence(e1.get_sequence() + 1)
+        e2.set_attendee_participant_status("jane at example.org", "DECLINED")
+        e2.set_lastmodified()
+
+        diff = compute_diff(e1.to_dict(), e2.to_dict(), True)
+        self.assertEqual(len(diff), 5)
+
+        ps = self._find_prop_in_list(diff, 'summary')
+        self.assertIsInstance(ps, OrderedDict)
+        self.assertEqual(ps['new'], "test2")
+
+        pa = self._find_prop_in_list(diff, 'attendee')
+        self.assertIsInstance(pa, OrderedDict)
+        self.assertEqual(pa['index'], 0)
+        self.assertEqual(pa['new'], dict(partstat='DECLINED'))
+
+
+    def _find_prop_in_list(self, diff, name):
+        for prop in diff:
+            if prop['property'] == name:
+                return prop
+        return None
+
 
 if __name__ == '__main__':
     unittest.main()




More information about the commits mailing list