Repository: ckan/ckan

Issue:
Allow `*_activity_list` actions to filter by `activity_type`
**CKAN version**: 2.9

It would be helpful if the `*_activity_list` actions had the ability to optionally accept a list of `activity_type`s to filter by.

e.g:

```py
get_action('package_activity_list')(context, {
    'id': 'my-dataset',
    'activity_types': ['changed package'],
})
```

- The default would be that all activities associated with `id` are returned (as it is now).
- Passing `activity_types` would filter to only the specified activity types.

This would be particularly useful for plugin authors leveraging custom activity types.


Discussion:
I think this will make life for extension developers much easier.
Perhaps it would make sense to have two different parameters:
* `include_activity_types`: only return activities of these types
* `exclude_activity_types`: ignore activities of these types

The implementation would not be much more complex and I can see both being useful in situations I've encountered working with custom activities.

Resolving patch:
diff --git a/ckan/logic/action/get.py b/ckan/logic/action/get.py
--- a/ckan/logic/action/get.py
+++ b/ckan/logic/action/get.py
@@ -2534,6 +2534,11 @@ def package_activity_list(context, data_dict):
         NB Only sysadmins may set include_hidden_activity to true.
         (default: false)
     :type include_hidden_activity: bool
+    :param activity_types: A list of activity types to include in the response
+    :type activity_types: list
+
+    :param exclude_activity_types: A list of activity types to exclude from the response
+    :type exclude_activity_types: list
 
     :rtype: list of dictionaries
 
@@ -2542,6 +2547,12 @@ def package_activity_list(context, data_dict):
     # authorized to read.
     data_dict['include_data'] = False
     include_hidden_activity = data_dict.get('include_hidden_activity', False)
+    activity_types = data_dict.pop('activity_types', None)
+    exclude_activity_types = data_dict.pop('exclude_activity_types', None)
+
+    if activity_types is not None and exclude_activity_types is not None:
+        raise ValidationError({'activity_types': ['Cannot be used together with `exclude_filters']})
+
     _check_access('package_activity_list', context, data_dict)
 
     model = context['model']
@@ -2557,6 +2568,8 @@ def package_activity_list(context, data_dict):
     activity_objects = model.activity.package_activity_list(
         package.id, limit=limit, offset=offset,
         include_hidden_activity=include_hidden_activity,
+        activity_types=activity_types,
+        exclude_activity_types=exclude_activity_types
     )
 
     return model_dictize.activity_list_dictize(
diff --git a/ckan/logic/schema.py b/ckan/logic/schema.py
--- a/ckan/logic/schema.py
+++ b/ckan/logic/schema.py
@@ -618,7 +618,8 @@ def default_dashboard_activity_list_schema(
 def default_activity_list_schema(
         not_missing, unicode_safe, configured_default,
         natural_number_validator, limit_to_configured_maximum,
-        ignore_missing, boolean_validator, ignore_not_sysadmin):
+        ignore_missing, boolean_validator, ignore_not_sysadmin,
+        list_of_strings):
     schema = default_pagination_schema()
     schema['id'] = [not_missing, unicode_safe]
     schema['limit'] = [
@@ -627,6 +628,8 @@ def default_activity_list_schema(
         limit_to_configured_maximum('ckan.activity_list_limit_max', 100)]
     schema['include_hidden_activity'] = [
         ignore_missing, ignore_not_sysadmin, boolean_validator]
+    schema['activity_types'] = [ignore_missing, list_of_strings]
+    schema['exclude_activity_types'] = [ignore_missing, list_of_strings]
     return schema
 
 
diff --git a/ckan/model/activity.py b/ckan/model/activity.py
--- a/ckan/model/activity.py
+++ b/ckan/model/activity.py
@@ -182,9 +182,12 @@ def _package_activity_query(package_id):
 
 
 def package_activity_list(
-        package_id, limit, offset, include_hidden_activity=False):
+        package_id, limit, offset, include_hidden_activity=False,
+        activity_types=None, exclude_activity_types=None):
     '''Return the given dataset (package)'s public activity stream.
 
+    activity_types, exclude_activity_types: Optional. list of strings for activity types
+
     Returns all activities about the given dataset, i.e. where the given
     dataset is the object of the activity, e.g.:
 
@@ -198,6 +201,11 @@ def package_activity_list(
     if not include_hidden_activity:
         q = _filter_activitites_from_users(q)
 
+    if activity_types:
+        q = _filter_activitites_from_type(q, include=True, types=activity_types)
+    elif exclude_activity_types:
+        q = _filter_activitites_from_type(q, include=False, types=exclude_activity_types)
+
     return _activities_at_offset(q, limit, offset)
 
 
@@ -452,7 +460,7 @@ def recently_changed_packages_activity_list(limit, offset):
 
 def _filter_activitites_from_users(q):
     '''
-    Adds a filter to an existing query object ot avoid activities from users
+    Adds a filter to an existing query object to avoid activities from users
     defined in :ref:`ckan.hide_activity_from_users` (defaults to the site user)
     '''
     users_to_avoid = _activity_stream_get_filtered_users()
@@ -461,6 +469,17 @@ def _filter_activitites_from_users(q):
 
     return q
 
+def _filter_activitites_from_type(q, types, include=True):
+    '''
+    Adds a filter to an existing query object to include or exclude (include=False)
+    activities based on a list of types
+    '''
+    if include:
+        q = q.filter(ckan.model.Activity.activity_type.in_(types))
+    else:
+        q = q.filter(ckan.model.Activity.activity_type.notin_(types))
+
+    return q
 
 def _activity_stream_get_filtered_users():
     '''


Test patch:
diff --git a/ckan/tests/logic/action/test_get.py b/ckan/tests/logic/action/test_get.py
--- a/ckan/tests/logic/action/test_get.py
+++ b/ckan/tests/logic/action/test_get.py
@@ -3177,6 +3177,72 @@ def test_private_dataset_delete_has_no_activity(self):
         )
         assert [activity["activity_type"] for activity in activities] == []
 
+    def _create_bulk_types_activities(self, types):
+        dataset = factories.Dataset()
+        from ckan import model
+
+        user = factories.User()
+
+        objs = [
+            model.Activity(
+                user_id=user['id'],
+                object_id=dataset["id"],
+                activity_type=activity_type,
+                data=None,
+            )
+            for activity_type in types
+        ]
+        model.Session.add_all(objs)
+        model.repo.commit_and_remove()
+        return dataset["id"]
+
+    def test_error_bad_search(self):
+        with pytest.raises(logic.ValidationError):
+            helpers.call_action(
+                "package_activity_list",
+                id=id,
+                activity_types=['new package'],
+                exclude_activity_types=['deleted package']
+            )
+
+    def test_activity_types_filter(self):
+        types = [
+            'new package',
+            'changed package',
+            'deleted package',
+            'changed package',
+            'new package'
+        ]
+        id = self._create_bulk_types_activities(types)
+
+        activities_new = helpers.call_action(
+            "package_activity_list",
+            id=id,
+            activity_types=['new package']
+        )
+        assert len(activities_new) == 2
+
+        activities_not_new = helpers.call_action(
+            "package_activity_list",
+            id=id,
+            exclude_activity_types=['new package']
+        )
+        assert len(activities_not_new) == 3
+
+        activities_delete = helpers.call_action(
+            "package_activity_list",
+            id=id,
+            activity_types=['deleted package']
+        )
+        assert len(activities_delete) == 1
+
+        activities_not_deleted = helpers.call_action(
+            "package_activity_list",
+            id=id,
+            exclude_activity_types=['deleted package']
+        )
+        assert len(activities_not_deleted) == 4
+
     def _create_bulk_package_activities(self, count):
         dataset = factories.Dataset()
         from ckan import model