diff options
| -rw-r--r-- | pydis_site/apps/api/models/bot/metricity.py | 28 | ||||
| -rw-r--r-- | pydis_site/apps/api/tests/test_users.py | 84 | ||||
| -rw-r--r-- | pydis_site/apps/api/viewsets/bot/user.py | 57 | 
3 files changed, 168 insertions, 1 deletions
| diff --git a/pydis_site/apps/api/models/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py index abd25ef0..f53dd33c 100644 --- a/pydis_site/apps/api/models/bot/metricity.py +++ b/pydis_site/apps/api/models/bot/metricity.py @@ -130,3 +130,31 @@ class Metricity:              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/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py index 5d10069d..d86e80bb 100644 --- a/pydis_site/apps/api/tests/test_users.py +++ b/pydis_site/apps/api/tests/test_users.py @@ -502,6 +502,90 @@ class UserMetricityTests(AuthenticatedAPITestCase):              "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() diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py index ba1bcd9d..db73a83c 100644 --- a/pydis_site/apps/api/viewsets/bot/user.py +++ b/pydis_site/apps/api/viewsets/bot/user.py @@ -3,8 +3,9 @@ from collections import OrderedDict  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 @@ class UserViewSet(ModelViewSet):              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) | 
