Repository: python-discord/site

Issue:
Endpoint for getting activity from metricity
This issue is required for https://github.com/python-discord/bot/issues/2302

The endpoint should take a list of user IDs and a number of days (or just a timestamp?),  and return the number of messages sent by each user from now up to the date/interval provided. This data is available through metricity.

The reason I suggest an endpoint taking multiple user IDs at once is that I assume it would be more efficient as a single query, as we may want to use this endpoint on all nominated users at once (e.g. to check for users in the talent pool that haven't been active for a long time so can be removed). 


Discussion:

Resolving patch:
diff --git a/pydis_site/apps/api/models/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py
--- a/pydis_site/apps/api/models/bot/metricity.py
+++ b/pydis_site/apps/api/models/bot/metricity.py
@@ -130,3 +130,31 @@ def top_channel_activity(self, user_id: str) -> List[Tuple[str, int]]:
             raise NotFoundError()
 
         return values
+
+    def total_messages_in_past_n_days(
+        self,
+        user_ids: list[str],
+        days: int
+    ) -> list[tuple[str, int]]:
+        """
+        Query activity by a list of users in the past `days` days.
+
+        Returns a list of (user_id, message_count) tuples.
+        """
+        self.cursor.execute(
+            """
+            SELECT
+                author_id, COUNT(*)
+            FROM messages
+            WHERE
+                author_id IN %s
+                AND NOT is_deleted
+                AND channel_id NOT IN %s
+                AND created_at > now() - interval '%s days'
+            GROUP BY author_id
+            """,
+            [tuple(user_ids), EXCLUDE_CHANNELS, days]
+        )
+        values = self.cursor.fetchall()
+
+        return values
diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py
--- a/pydis_site/apps/api/viewsets/bot/user.py
+++ b/pydis_site/apps/api/viewsets/bot/user.py
@@ -3,8 +3,9 @@
 
 from django.db.models import Q
 from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework import status
+from rest_framework import fields, status
 from rest_framework.decorators import action
+from rest_framework.exceptions import ParseError
 from rest_framework.pagination import PageNumberPagination
 from rest_framework.request import Request
 from rest_framework.response import Response
@@ -138,6 +139,29 @@ class UserViewSet(ModelViewSet):
     - 200: returned on success
     - 404: if a user with the given `snowflake` could not be found
 
+    ### POST /bot/users/metricity_activity_data
+    Returns a mapping of user ID to message count in a given period for
+    the given user IDs.
+
+    #### Required Query Parameters
+    - days: how many days into the past to count message from.
+
+    #### Request Format
+    >>> [
+    ...     409107086526644234,
+    ...     493839819168808962
+    ... ]
+
+    #### Response format
+    >>> {
+    ...     "409107086526644234": 54,
+    ...     "493839819168808962": 0
+    ... }
+
+    #### Status codes
+    - 200: returned on success
+    - 400: if request body or query parameters were missing or invalid
+
     ### POST /bot/users
     Adds a single or multiple new users.
     The roles attached to the user(s) must be roles known by the site.
@@ -298,3 +322,34 @@ def metricity_review_data(self, request: Request, pk: str = None) -> Response:
             except NotFoundError:
                 return Response(dict(detail="User not found in metricity"),
                                 status=status.HTTP_404_NOT_FOUND)
+
+    @action(detail=False, methods=["POST"])
+    def metricity_activity_data(self, request: Request) -> Response:
+        """Request handler for metricity_activity_data endpoint."""
+        if "days" in request.query_params:
+            try:
+                days = int(request.query_params["days"])
+            except ValueError:
+                raise ParseError(detail={
+                    "days": ["This query parameter must be an integer."]
+                })
+        else:
+            raise ParseError(detail={
+                "days": ["This query parameter is required."]
+            })
+
+        user_id_list_validator = fields.ListField(
+            child=fields.IntegerField(min_value=0),
+            allow_empty=False
+        )
+        user_ids = [
+            str(user_id) for user_id in
+            user_id_list_validator.run_validation(request.data)
+        ]
+
+        with Metricity() as metricity:
+            data = metricity.total_messages_in_past_n_days(user_ids, days)
+
+        default_data = {user_id: 0 for user_id in user_ids}
+        response_data = default_data | dict(data)
+        return Response(response_data, status=status.HTTP_200_OK)


Test patch:
diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py
--- a/pydis_site/apps/api/tests/test_users.py
+++ b/pydis_site/apps/api/tests/test_users.py
@@ -502,6 +502,90 @@ def test_metricity_review_data(self):
             "total_messages": total_messages
         })
 
+    def test_metricity_activity_data(self):
+        # Given
+        self.mock_no_metricity_user()  # Other functions shouldn't be used.
+        self.metricity.total_messages_in_past_n_days.return_value = [(0, 10)]
+
+        # When
+        url = reverse("api:bot:user-metricity-activity-data")
+        response = self.client.post(
+            url,
+            data=[0, 1],
+            QUERY_STRING="days=10",
+        )
+
+        # Then
+        self.assertEqual(response.status_code, 200)
+        self.metricity.total_messages_in_past_n_days.assert_called_once_with(["0", "1"], 10)
+        self.assertEqual(response.json(), {"0": 10, "1": 0})
+
+    def test_metricity_activity_data_invalid_days(self):
+        # Given
+        self.mock_no_metricity_user()  # Other functions shouldn't be used.
+
+        # When
+        url = reverse("api:bot:user-metricity-activity-data")
+        response = self.client.post(
+            url,
+            data=[0, 1],
+            QUERY_STRING="days=fifty",
+        )
+
+        # Then
+        self.assertEqual(response.status_code, 400)
+        self.metricity.total_messages_in_past_n_days.assert_not_called()
+        self.assertEqual(response.json(), {"days": ["This query parameter must be an integer."]})
+
+    def test_metricity_activity_data_no_days(self):
+        # Given
+        self.mock_no_metricity_user()  # Other functions shouldn't be used.
+
+        # When
+        url = reverse('api:bot:user-metricity-activity-data')
+        response = self.client.post(
+            url,
+            data=[0, 1],
+        )
+
+        # Then
+        self.assertEqual(response.status_code, 400)
+        self.metricity.total_messages_in_past_n_days.assert_not_called()
+        self.assertEqual(response.json(), {'days': ["This query parameter is required."]})
+
+    def test_metricity_activity_data_no_users(self):
+        # Given
+        self.mock_no_metricity_user()  # Other functions shouldn't be used.
+
+        # When
+        url = reverse('api:bot:user-metricity-activity-data')
+        response = self.client.post(
+            url,
+            QUERY_STRING="days=10",
+        )
+
+        # Then
+        self.assertEqual(response.status_code, 400)
+        self.metricity.total_messages_in_past_n_days.assert_not_called()
+        self.assertEqual(response.json(), ['Expected a list of items but got type "dict".'])
+
+    def test_metricity_activity_data_invalid_users(self):
+        # Given
+        self.mock_no_metricity_user()  # Other functions shouldn't be used.
+
+        # When
+        url = reverse('api:bot:user-metricity-activity-data')
+        response = self.client.post(
+            url,
+            data=[123, 'username'],
+            QUERY_STRING="days=10",
+        )
+
+        # Then
+        self.assertEqual(response.status_code, 400)
+        self.metricity.total_messages_in_past_n_days.assert_not_called()
+        self.assertEqual(response.json(), {'1': ['A valid integer is required.']})
+
     def mock_metricity_user(self, joined_at, total_messages, total_blocks, top_channel_activity):
         patcher = patch("pydis_site.apps.api.viewsets.bot.user.Metricity")
         self.metricity = patcher.start()