aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site/apps/api
diff options
context:
space:
mode:
authorGravatar mbaruh <[email protected]>2023-01-27 22:22:06 +0200
committerGravatar mbaruh <[email protected]>2023-01-27 22:57:51 +0200
commit963d9368b8e07d8aab04662f27bf47087f5b574c (patch)
tree72ea3b5e60babe27b169b8816a4d39853d180742 /pydis_site/apps/api
parentUpdate tests (diff)
parentMerge pull request #860 from python-discord/official-tutorial-resource (diff)
Merge branch 'main' into new-filter-schema
Diffstat (limited to 'pydis_site/apps/api')
-rw-r--r--pydis_site/apps/api/github_utils.py4
-rw-r--r--pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py18
-rw-r--r--pydis_site/apps/api/migrations/0086_new_filter_schema.py (renamed from pydis_site/apps/api/migrations/0085_new_filter_schema.py)2
-rw-r--r--pydis_site/apps/api/migrations/0087_unique_constraint_filters.py (renamed from pydis_site/apps/api/migrations/0086_unique_constraint_filters.py)2
-rw-r--r--pydis_site/apps/api/migrations/0088_unique_filter_list.py (renamed from pydis_site/apps/api/migrations/0087_unique_filter_list.py)2
-rw-r--r--pydis_site/apps/api/migrations/0089_antispam_filter_list.py (renamed from pydis_site/apps/api/migrations/0088_antispam_filter_list.py)2
-rw-r--r--pydis_site/apps/api/models/bot/metricity.py28
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py4
-rw-r--r--pydis_site/apps/api/serializers.py10
-rw-r--r--pydis_site/apps/api/tests/test_github_utils.py7
-rw-r--r--pydis_site/apps/api/tests/test_nominations.py32
-rw-r--r--pydis_site/apps/api/tests/test_users.py84
-rw-r--r--pydis_site/apps/api/viewsets/bot/nomination.py21
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py57
14 files changed, 254 insertions, 19 deletions
diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py
index 986c64e1..44c571c3 100644
--- a/pydis_site/apps/api/github_utils.py
+++ b/pydis_site/apps/api/github_utils.py
@@ -11,8 +11,6 @@ from pydis_site import settings
MAX_RUN_TIME = datetime.timedelta(minutes=10)
"""The maximum time allowed before an action is declared timed out."""
-ISO_FORMAT_STRING = "%Y-%m-%dT%H:%M:%SZ"
-"""The datetime string format GitHub uses."""
class ArtifactProcessingError(Exception):
@@ -147,7 +145,7 @@ def authorize(owner: str, repo: str) -> httpx.Client:
def check_run_status(run: WorkflowRun) -> str:
"""Check if the provided run has been completed, otherwise raise an exception."""
- created_at = datetime.datetime.strptime(run.created_at, ISO_FORMAT_STRING)
+ created_at = datetime.datetime.strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT)
run_time = datetime.datetime.utcnow() - created_at
if run.status != "completed":
diff --git a/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py b/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py
new file mode 100644
index 00000000..56a24cc3
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.2 on 2022-11-12 14:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0084_infraction_last_applied'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='nomination',
+ name='thread_id',
+ field=models.BigIntegerField(help_text="The nomination vote's thread id.", null=True),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0085_new_filter_schema.py b/pydis_site/apps/api/migrations/0086_new_filter_schema.py
index 96d03bf4..9067a380 100644
--- a/pydis_site/apps/api/migrations/0085_new_filter_schema.py
+++ b/pydis_site/apps/api/migrations/0086_new_filter_schema.py
@@ -89,7 +89,7 @@ def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
class Migration(migrations.Migration):
dependencies = [
- ('api', '0084_infraction_last_applied'),
+ ('api', '0085_add_thread_id_to_nominations'),
]
operations = [
diff --git a/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0087_unique_constraint_filters.py
index b83e395c..b2ff91f1 100644
--- a/pydis_site/apps/api/migrations/0086_unique_constraint_filters.py
+++ b/pydis_site/apps/api/migrations/0087_unique_constraint_filters.py
@@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('api', '0085_new_filter_schema'),
+ ('api', '0086_new_filter_schema'),
]
operations = [
diff --git a/pydis_site/apps/api/migrations/0087_unique_filter_list.py b/pydis_site/apps/api/migrations/0088_unique_filter_list.py
index b8087d9c..3f3a34bb 100644
--- a/pydis_site/apps/api/migrations/0087_unique_filter_list.py
+++ b/pydis_site/apps/api/migrations/0088_unique_filter_list.py
@@ -91,7 +91,7 @@ def create_unique_list(apps: Apps, _):
class Migration(migrations.Migration):
dependencies = [
- ('api', '0086_unique_constraint_filters'),
+ ('api', '0087_unique_constraint_filters'),
]
operations = [
diff --git a/pydis_site/apps/api/migrations/0088_antispam_filter_list.py b/pydis_site/apps/api/migrations/0089_antispam_filter_list.py
index fcb56781..284d87e7 100644
--- a/pydis_site/apps/api/migrations/0088_antispam_filter_list.py
+++ b/pydis_site/apps/api/migrations/0089_antispam_filter_list.py
@@ -41,7 +41,7 @@ def create_antispam_list(apps: Apps, _):
class Migration(migrations.Migration):
dependencies = [
- ('api', '0087_unique_filter_list'),
+ ('api', '0088_unique_filter_list'),
]
operations = [
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/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py
index 221d8534..58e70a83 100644
--- a/pydis_site/apps/api/models/bot/nomination.py
+++ b/pydis_site/apps/api/models/bot/nomination.py
@@ -35,6 +35,10 @@ class Nomination(ModelReprMixin, models.Model):
default=False,
help_text="Whether a review was made."
)
+ thread_id = models.BigIntegerField(
+ help_text="The nomination vote's thread id.",
+ null=True,
+ )
def __str__(self):
"""Representation that makes the target and state of the nomination immediately evident."""
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 83ab4584..7f9461ec 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -640,7 +640,15 @@ class NominationSerializer(ModelSerializer):
model = Nomination
fields = (
- 'id', 'active', 'user', 'inserted_at', 'end_reason', 'ended_at', 'reviewed', 'entries'
+ 'id',
+ 'active',
+ 'user',
+ 'inserted_at',
+ 'end_reason',
+ 'ended_at',
+ 'reviewed',
+ 'entries',
+ 'thread_id'
)
diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py
index 2eaf48d9..95bafec0 100644
--- a/pydis_site/apps/api/tests/test_github_utils.py
+++ b/pydis_site/apps/api/tests/test_github_utils.py
@@ -11,6 +11,7 @@ import rest_framework.response
import rest_framework.test
from django.urls import reverse
+from pydis_site import settings
from .. import github_utils
@@ -49,7 +50,7 @@ class CheckRunTests(unittest.TestCase):
"head_sha": "sha",
"status": "completed",
"conclusion": "success",
- "created_at": datetime.datetime.utcnow().strftime(github_utils.ISO_FORMAT_STRING),
+ "created_at": datetime.datetime.utcnow().strftime(settings.GITHUB_TIMESTAMP_FORMAT),
"artifacts_url": "url",
}
@@ -74,7 +75,7 @@ class CheckRunTests(unittest.TestCase):
# to guarantee the right conclusion
kwargs["created_at"] = (
datetime.datetime.utcnow() - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10)
- ).strftime(github_utils.ISO_FORMAT_STRING)
+ ).strftime(settings.GITHUB_TIMESTAMP_FORMAT)
with self.assertRaises(github_utils.RunTimeoutError):
github_utils.check_run_status(github_utils.WorkflowRun(**kwargs))
@@ -178,7 +179,7 @@ class ArtifactFetcherTests(unittest.TestCase):
run = github_utils.WorkflowRun(
name="action_name",
head_sha="action_sha",
- created_at=datetime.datetime.now().strftime(github_utils.ISO_FORMAT_STRING),
+ created_at=datetime.datetime.now().strftime(settings.GITHUB_TIMESTAMP_FORMAT),
status="completed",
conclusion="success",
artifacts_url="artifacts_url"
diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py
index 62b2314c..b3742cdd 100644
--- a/pydis_site/apps/api/tests/test_nominations.py
+++ b/pydis_site/apps/api/tests/test_nominations.py
@@ -524,3 +524,35 @@ class NominationTests(AuthenticatedAPITestCase):
self.assertEqual(response.json(), {
'actor': ["The actor doesn't exist or has not nominated the user."]
})
+
+ def test_patch_nomination_set_thread_id_of_active_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,))
+ data = {'thread_id': 9876543210}
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 200)
+
+ def test_patch_nomination_set_thread_id_and_reviewed_of_active_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,))
+ data = {'thread_id': 9876543210, "reviewed": True}
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 200)
+
+ def test_modifying_thread_id_when_ending_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,))
+ data = {'thread_id': 9876543210, 'active': False, 'end_reason': "What?"}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'thread_id': ['This field cannot be set when ending a nomination.']
+ })
+
+ def test_patch_thread_id_for_inactive_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.inactive_nomination.id,))
+ data = {'thread_id': 9876543210}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'thread_id': ['This field cannot be set if the nomination is inactive.']
+ })
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/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py
index 6af42bcb..78687e0e 100644
--- a/pydis_site/apps/api/viewsets/bot/nomination.py
+++ b/pydis_site/apps/api/viewsets/bot/nomination.py
@@ -273,6 +273,11 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'reviewed': ['This field cannot be set while you are ending a nomination.']}
)
+ if 'thread_id' in request.data:
+ raise ValidationError(
+ {'thread_id': ['This field cannot be set when ending a nomination.']}
+ )
+
instance.ended_at = timezone.now()
elif 'active' in data:
@@ -281,13 +286,15 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'active': ['This field can only be used to end a nomination']}
)
- # This is actually covered, but for some reason coverage don't think so.
- elif 'reviewed' in request.data: # pragma: no cover
- # 4. We are altering the reviewed state of the nomination.
- if not instance.active:
- raise ValidationError(
- {'reviewed': ['This field cannot be set if the nomination is inactive.']}
- )
+ elif not instance.active and 'reviewed' in request.data:
+ raise ValidationError(
+ {'reviewed': ['This field cannot be set if the nomination is inactive.']}
+ )
+
+ elif not instance.active and 'thread_id' in request.data:
+ raise ValidationError(
+ {'thread_id': ['This field cannot be set if the nomination is inactive.']}
+ )
if 'reason' in request.data:
if 'actor' not in request.data:
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)