summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThomas Bruederli <bruederli@kolabsys.com>2014-08-22 15:12:45 -0400
committerThomas Bruederli <bruederli@kolabsys.com>2014-08-22 15:12:45 -0400
commitfd68e0f4527f27fb406861036108d44cf500612e (patch)
treea4b7ed8a2939088c8d886635221ede79864f6eb0
parent3231cd8408132d3f7ddb3ac1626a049474101101 (diff)
downloadpykolab-fd68e0f4527f27fb406861036108d44cf500612e.tar.gz
List event/task properties changes in update notification mails (#3447)
-rw-r--r--pykolab/xml/__init__.py4
-rw-r--r--pykolab/xml/event.py7
-rw-r--r--pykolab/xml/recurrence_rule.py17
-rw-r--r--pykolab/xml/utils.py130
-rw-r--r--tests/functional/test_wallace/test_007_invitationpolicy.py2
-rw-r--r--tests/unit/test-003-event.py25
-rw-r--r--wallace/module_invitationpolicy.py29
7 files changed, 203 insertions, 11 deletions
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@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
}