Repository: ckan/ckan

Issue:
Organization changes do not have detailed Activity Stream UI
**CKAN version**
2.9.1

**Describe the bug**
One of the major features in 2.9 is the detailed Dataset Activity Stream.  Even though Organization changes are also stored in the `activity` table, it only logs that the Organization has been updated.

**Steps to reproduce**
Make changes to an Organization and check the Activity Stream.

**Expected behavior**
The Activity Stream should at least show the diff, similar to how its done with Datasets, as the Organization metadata is stored in `activity.data` column.




Discussion:
@amercader @jqnatividad , I am interested to work on this issue. I have started the investigation.

Resolving patch:
diff --git a/ckan/lib/changes.py b/ckan/lib/changes.py
--- a/ckan/lib/changes.py
+++ b/ckan/lib/changes.py
@@ -370,6 +370,24 @@ def check_metadata_changes(change_list, old, new):
     _extra_fields(change_list, old, new)
 
 
+def check_metadata_org_changes(change_list, old, new):
+    '''
+    Compares two versions of a organization and records the changes between
+    them in change_list.
+    '''
+    # if the title has changed
+    if old.get(u'title') != new.get(u'title'):
+        _title_change(change_list, old, new)
+
+    # if the description of the organization changed
+    if old.get(u'description') != new.get(u'description'):
+        _description_change(change_list, old, new)
+
+    # if the image URL has changed
+    if old.get(u'image_url') != new.get(u'image_url'):
+        _image_url_change(change_list, old, new)
+
+
 def _title_change(change_list, old, new):
     '''
     Appends a summary of a change to a dataset's title between two versions
@@ -832,3 +850,53 @@ def _extra_fields(change_list, old, new):
                                 u'pkg_id': new.get(u'id'),
                                 u'title': new.get(u'title'),
                                 u'key_list': deleted_fields})
+
+
+def _description_change(change_list, old, new):
+    '''
+    Appends a summary of a change to a organization's description between two
+    versions (old and new) to change_list.
+    '''
+
+    # if the old organization had a description
+    if old.get(u'description') and new.get(u'description'):
+        change_list.append({u'type': u'description', u'pkg_id':
+                            new.get(u'id'), u'title': new.get(u'title'),
+                            u'new_description': new.get(u'description'),
+                            u'old_description': old.get(u'description'),
+                            u'method': u'change'})
+    elif not new.get(u'description'):
+        change_list.append({u'type': u'description', u'pkg_id':
+                            new.get(u'id'), u'title': new.get(u'title'),
+                            u'method': u'remove'})
+    else:
+        change_list.append({u'type': u'description', u'pkg_id':
+                            new.get(u'id'), u'title': new.get(u'title'),
+                            u'new_description': new.get(u'description'),
+                            u'method': u'add'})
+
+
+def _image_url_change(change_list, old, new):
+    '''
+    Appends a summary of a change to a organization's image URL between two
+    versions (old and new) to change_list.
+    '''
+    # if both old and new versions have image  URLs
+    if old.get(u'image_url') and new.get(u'image_url'):
+        change_list.append({u'type': u'image_url', u'method': u'change',
+                            u'pkg_id': new.get(u'id'), u'title':
+                            new.get(u'title'), u'new_image_url':
+                            new.get(u'image_url'), u'old_image_url':
+                            old.get(u'image_url')})
+    # if the user removed the image URL
+    elif not new.get(u'image_url'):
+        change_list.append({u'type': u'image_url', u'method': u'remove',
+                            u'pkg_id': new.get(u'id'),
+                            u'title': new.get(u'title'),
+                            u'old_image_url': old.get(u'image_url')})
+    # if there wasn't one there before
+    else:
+        change_list.append({u'type': u'image_url', u'method': u'add',
+                            u'pkg_id': new.get(u'id'),
+                            u'title': new.get(u'title'),
+                            u'new_image_url': new.get(u'image_url')})
diff --git a/ckan/lib/helpers.py b/ckan/lib/helpers.py
--- a/ckan/lib/helpers.py
+++ b/ckan/lib/helpers.py
@@ -2966,6 +2966,35 @@ def compare_pkg_dicts(old, new, old_activity_id):
     return change_list
 
 
+@core_helper
+def compare_group_dicts(old, new, old_activity_id):
+    '''
+    Takes two package dictionaries that represent consecutive versions of
+    the same organization and returns a list of detailed & formatted summaries
+    of the changes between the two versions. old and new are the two package
+    dictionaries. The function assumes that both dictionaries will have
+    all of the default package dictionary keys, and also checks for fields
+    added by extensions and extra fields added by the user in the web
+    interface.
+
+    Returns a list of dictionaries, each of which corresponds to a change
+    to the dataset made in this revision. The dictionaries each contain a
+    string indicating the type of change made as well as other data necessary
+    to form a detailed summary of the change.
+    '''
+    from ckan.lib.changes import check_metadata_org_changes
+    change_list = []
+
+    check_metadata_org_changes(change_list, old, new)
+
+    # if the organization was updated but none of the fields we check
+    # were changed, display a message stating that
+    if len(change_list) == 0:
+        change_list.append({u'type': 'no_change'})
+
+    return change_list
+
+
 @core_helper
 def activity_list_select(pkg_activity_list, current_activity_id):
     '''
diff --git a/ckan/views/group.py b/ckan/views/group.py
--- a/ckan/views/group.py
+++ b/ckan/views/group.py
@@ -7,6 +7,7 @@
 import six
 from six import string_types
 from six.moves.urllib.parse import urlencode
+from datetime import datetime
 
 import ckan.lib.base as base
 import ckan.lib.helpers as h
@@ -507,6 +508,132 @@ def activity(id, group_type, is_organization, offset=0):
         _get_group_template(u'activity_template', group_type), extra_vars)
 
 
+def changes(id, group_type, is_organization):
+    '''
+    Shows the changes to an organization in one particular activity stream
+    item.
+    '''
+    activity_id = id
+    context = {
+        u'model': model, u'session': model.Session,
+        u'user': g.user, u'auth_user_obj': g.userobj
+    }
+    try:
+        activity_diff = get_action(u'activity_diff')(
+            context, {u'id': activity_id, u'object_type': u'group',
+                      u'diff_type': u'html'})
+    except NotFound as e:
+        log.info(u'Activity not found: {} - {}'.format(str(e), activity_id))
+        return base.abort(404, _(u'Activity not found'))
+    except NotAuthorized:
+        return base.abort(403, _(u'Unauthorized to view activity data'))
+
+    # 'group_dict' needs to go to the templates for page title & breadcrumbs.
+    # Use the current version of the package, in case the name/title have
+    # changed, and we need a link to it which works
+    group_id = activity_diff[u'activities'][1][u'data'][u'group'][u'id']
+    current_group_dict = get_action(group_type + u'_show')(
+        context, {u'id': group_id})
+    group_activity_list = get_action(group_type + u'_activity_list')(
+        context, {
+            u'id': group_id,
+            u'limit': 100
+        }
+    )
+
+    return base.render(
+        u'organization/changes.html', {
+            u'activity_diffs': [activity_diff],
+            u'group_dict': current_group_dict,
+            u'group_activity_list': group_activity_list,
+            u'group_type': current_group_dict[u'type'],
+        }
+    )
+
+
+def changes_multiple(is_organization, group_type=None):
+    '''
+    Called when a user specifies a range of versions they want to look at
+    changes between. Verifies that the range is valid and finds the set of
+    activity diffs for the changes in the given version range, then
+    re-renders changes.html with the list.
+    '''
+
+    new_id = h.get_request_param(u'new_id')
+    old_id = h.get_request_param(u'old_id')
+
+    context = {
+        u'model': model, u'session': model.Session,
+        u'user': g.user, u'auth_user_obj': g.userobj
+    }
+
+    # check to ensure that the old activity is actually older than
+    # the new activity
+    old_activity = get_action(u'activity_show')(context, {
+        u'id': old_id,
+        u'include_data': False})
+    new_activity = get_action(u'activity_show')(context, {
+        u'id': new_id,
+        u'include_data': False})
+
+    old_timestamp = old_activity[u'timestamp']
+    new_timestamp = new_activity[u'timestamp']
+
+    t1 = datetime.strptime(old_timestamp, u'%Y-%m-%dT%H:%M:%S.%f')
+    t2 = datetime.strptime(new_timestamp, u'%Y-%m-%dT%H:%M:%S.%f')
+
+    time_diff = t2 - t1
+    # if the time difference is negative, just return the change that put us
+    # at the more recent ID we were just looking at
+    # TODO: do something better here - go back to the previous page,
+    # display a warning that the user can't look at a sequence where
+    # the newest item is older than the oldest one, etc
+    if time_diff.total_seconds() < 0:
+        return changes(h.get_request_param(u'current_new_id'))
+
+    done = False
+    current_id = new_id
+    diff_list = []
+
+    while not done:
+        try:
+            activity_diff = get_action(u'activity_diff')(
+                context, {
+                    u'id': current_id,
+                    u'object_type': u'group',
+                    u'diff_type': u'html'})
+        except NotFound as e:
+            log.info(
+                u'Activity not found: {} - {}'.format(str(e), current_id)
+            )
+            return base.abort(404, _(u'Activity not found'))
+        except NotAuthorized:
+            return base.abort(403, _(u'Unauthorized to view activity data'))
+
+        diff_list.append(activity_diff)
+
+        if activity_diff['activities'][0]['id'] == old_id:
+            done = True
+        else:
+            current_id = activity_diff['activities'][0]['id']
+
+    group_id = diff_list[0][u'activities'][1][u'data'][u'group'][u'id']
+    current_group_dict = get_action(group_type + u'_show')(
+        context, {u'id': group_id})
+    group_activity_list = get_action(group_type + u'_activity_list')(context, {
+        u'id': group_id,
+        u'limit': 100})
+
+    return base.render(
+        u'organization/changes.html', {
+            u'activity_diffs': diff_list,
+            u'group_dict': current_group_dict,
+            u'group_activity_list': group_activity_list,
+            u'group_type': current_group_dict[u'type'],
+        }
+    )
+
+
 def about(id, group_type, is_organization):
     extra_vars = {}
     set_org(is_organization)
@@ -1200,6 +1327,10 @@ def register_group_plugin_rules(blueprint):
             u'/{0}/<id>'.format(action),
             methods=[u'GET', u'POST'],
             view_func=globals()[action])
+    blueprint.add_url_rule(u'/changes/<id>', view_func=changes)
+    blueprint.add_url_rule(
+        u'/organization.changes_multiple',
+        view_func=changes_multiple)
 
 
 register_group_plugin_rules(group)


Test patch: