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