diff options
author | 2021-07-04 21:35:32 -0400 | |
---|---|---|
committer | 2021-07-04 21:35:32 -0400 | |
commit | cb058d85af826077641f7a6b05c07a975122987d (patch) | |
tree | 030c02f91a3cffcc491b848eee25796e7ffb5b23 /pydis_site/apps/api | |
parent | Update templates with new resource urls. (diff) | |
parent | Merge pull request #547 from Numerlor/docker-override (diff) |
Merge branch 'main' of https://github.com/python-discord/site into swfarnsworth/smarter-resources/new-resources
Diffstat (limited to 'pydis_site/apps/api')
-rw-r--r-- | pydis_site/apps/api/migrations/0070_auto_20210618_2114.py | 19 | ||||
-rw-r--r-- | pydis_site/apps/api/migrations/0071_increase_message_content_4000.py | 18 | ||||
-rw-r--r-- | pydis_site/apps/api/models/bot/message.py | 2 | ||||
-rw-r--r-- | pydis_site/apps/api/models/bot/role.py | 8 | ||||
-rw-r--r-- | pydis_site/apps/api/tests/test_infractions.py | 179 | ||||
-rw-r--r-- | pydis_site/apps/api/tests/test_models.py | 40 | ||||
-rw-r--r-- | pydis_site/apps/api/viewsets/bot/infraction.py | 73 |
7 files changed, 324 insertions, 15 deletions
diff --git a/pydis_site/apps/api/migrations/0070_auto_20210618_2114.py b/pydis_site/apps/api/migrations/0070_auto_20210618_2114.py new file mode 100644 index 00000000..1d25e421 --- /dev/null +++ b/pydis_site/apps/api/migrations/0070_auto_20210618_2114.py @@ -0,0 +1,19 @@ +# Generated by Django 3.0.14 on 2021-06-18 21:14 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0069_documentationlink_validators'), + ] + + operations = [ + migrations.AlterField( + model_name='role', + name='permissions', + field=models.BigIntegerField(help_text='The integer value of the permission bitset of this role from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role permissions cannot be negative.')]), + ), + ] diff --git a/pydis_site/apps/api/migrations/0071_increase_message_content_4000.py b/pydis_site/apps/api/migrations/0071_increase_message_content_4000.py new file mode 100644 index 00000000..6ca5d21a --- /dev/null +++ b/pydis_site/apps/api/migrations/0071_increase_message_content_4000.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.14 on 2021-06-24 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0070_auto_20210618_2114'), + ] + + operations = [ + migrations.AlterField( + model_name='deletedmessage', + name='content', + field=models.CharField(blank=True, help_text='The content of this message, taken from Discord.', max_length=4000), + ), + ] diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index ff06de21..60e2a553 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -43,7 +43,7 @@ class Message(ModelReprMixin, models.Model): verbose_name="Channel ID" ) content = models.CharField( - max_length=2_000, + max_length=4_000, help_text="The content of this message, taken from Discord.", blank=True ) diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py index cfadfec4..733a8e08 100644 --- a/pydis_site/apps/api/models/bot/role.py +++ b/pydis_site/apps/api/models/bot/role.py @@ -1,6 +1,6 @@ from __future__ import annotations -from django.core.validators import MaxValueValidator, MinValueValidator +from django.core.validators import MinValueValidator from django.db import models from pydis_site.apps.api.models.mixins import ModelReprMixin @@ -38,16 +38,12 @@ class Role(ModelReprMixin, models.Model): ), help_text="The integer value of the colour of this role from Discord." ) - permissions = models.IntegerField( + permissions = models.BigIntegerField( validators=( MinValueValidator( limit_value=0, message="Role permissions cannot be negative." ), - MaxValueValidator( - limit_value=2 << 32, - message="Role permission bitset exceeds value of having all permissions" - ) ), help_text="The integer value of the permission bitset of this role from Discord." ) diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index 82b497aa..9aae16c0 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -1,3 +1,4 @@ +import datetime from datetime import datetime as dt, timedelta, timezone from unittest.mock import patch from urllib.parse import quote @@ -16,7 +17,7 @@ class UnauthenticatedTests(APISubdomainTestCase): self.client.force_authenticate(user=None) def test_detail_lookup_returns_401(self): - url = reverse('bot:infraction-detail', args=(5,), host='api') + url = reverse('bot:infraction-detail', args=(6,), host='api') response = self.client.get(url) self.assertEqual(response.status_code, 401) @@ -34,7 +35,7 @@ class UnauthenticatedTests(APISubdomainTestCase): self.assertEqual(response.status_code, 401) def test_partial_update_returns_401(self): - url = reverse('bot:infraction-detail', args=(5,), host='api') + url = reverse('bot:infraction-detail', args=(6,), host='api') response = self.client.patch(url, data={'reason': 'Have a nice day.'}) self.assertEqual(response.status_code, 401) @@ -44,7 +45,7 @@ class InfractionTests(APISubdomainTestCase): @classmethod def setUpTestData(cls): cls.user = User.objects.create( - id=5, + id=6, name='james', discriminator=1, ) @@ -64,6 +65,30 @@ class InfractionTests(APISubdomainTestCase): reason='James is an ass, and we won\'t be working with him again.', active=False ) + cls.mute_permanent = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='mute', + reason='He has a filthy mouth and I am his soap.', + active=True, + expires_at=None + ) + cls.superstar_expires_soon = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='superstar', + reason='This one doesn\'t matter anymore.', + active=True, + expires_at=datetime.datetime.utcnow() + datetime.timedelta(hours=5) + ) + cls.voiceban_expires_later = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='voice_ban', + reason='Jet engine mic', + active=True, + expires_at=datetime.datetime.utcnow() + datetime.timedelta(days=5) + ) def test_list_all(self): """Tests the list-view, which should be ordered by inserted_at (newest first).""" @@ -73,9 +98,12 @@ class InfractionTests(APISubdomainTestCase): self.assertEqual(response.status_code, 200) infractions = response.json() - self.assertEqual(len(infractions), 2) - self.assertEqual(infractions[0]['id'], self.ban_inactive.id) - self.assertEqual(infractions[1]['id'], self.ban_hidden.id) + self.assertEqual(len(infractions), 5) + self.assertEqual(infractions[0]['id'], self.voiceban_expires_later.id) + self.assertEqual(infractions[1]['id'], self.superstar_expires_soon.id) + self.assertEqual(infractions[2]['id'], self.mute_permanent.id) + self.assertEqual(infractions[3]['id'], self.ban_inactive.id) + self.assertEqual(infractions[4]['id'], self.ban_hidden.id) def test_filter_search(self): url = reverse('bot:infraction-list', host='api') @@ -98,6 +126,140 @@ class InfractionTests(APISubdomainTestCase): self.assertEqual(len(infractions), 1) self.assertEqual(infractions[0]['id'], self.ban_hidden.id) + def test_filter_permanent_false(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?type=mute&permanent=false') + + self.assertEqual(response.status_code, 200) + infractions = response.json() + + self.assertEqual(len(infractions), 0) + + def test_filter_permanent_true(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?type=mute&permanent=true') + + self.assertEqual(response.status_code, 200) + infractions = response.json() + + self.assertEqual(infractions[0]['id'], self.mute_permanent.id) + + def test_filter_after(self): + url = reverse('bot:infraction-list', host='api') + target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) + response = self.client.get(f'{url}?type=superstar&expires_after={target_time.isoformat()}') + + self.assertEqual(response.status_code, 200) + infractions = response.json() + self.assertEqual(len(infractions), 0) + + def test_filter_before(self): + url = reverse('bot:infraction-list', host='api') + target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) + response = self.client.get(f'{url}?type=superstar&expires_before={target_time.isoformat()}') + + self.assertEqual(response.status_code, 200) + infractions = response.json() + self.assertEqual(len(infractions), 1) + self.assertEqual(infractions[0]['id'], self.superstar_expires_soon.id) + + def test_filter_after_invalid(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?expires_after=gibberish') + + self.assertEqual(response.status_code, 400) + self.assertEqual(list(response.json())[0], "expires_after") + + def test_filter_before_invalid(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?expires_before=000000000') + + self.assertEqual(response.status_code, 400) + self.assertEqual(list(response.json())[0], "expires_before") + + def test_after_before_before(self): + url = reverse('bot:infraction-list', host='api') + target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=4) + target_time_late = datetime.datetime.utcnow() + datetime.timedelta(hours=6) + response = self.client.get( + f'{url}?expires_before={target_time_late.isoformat()}' + f'&expires_after={target_time.isoformat()}' + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["id"], self.superstar_expires_soon.id) + + def test_after_after_before_invalid(self): + url = reverse('bot:infraction-list', host='api') + target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) + target_time_late = datetime.datetime.utcnow() + datetime.timedelta(hours=9) + response = self.client.get( + f'{url}?expires_before={target_time.isoformat()}' + f'&expires_after={target_time_late.isoformat()}' + ) + + self.assertEqual(response.status_code, 400) + errors = list(response.json()) + self.assertIn("expires_before", errors) + self.assertIn("expires_after", errors) + + def test_permanent_after_invalid(self): + url = reverse('bot:infraction-list', host='api') + target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) + response = self.client.get(f'{url}?permanent=true&expires_after={target_time.isoformat()}') + + self.assertEqual(response.status_code, 400) + errors = list(response.json()) + self.assertEqual("permanent", errors[0]) + + def test_permanent_before_invalid(self): + url = reverse('bot:infraction-list', host='api') + target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5) + response = self.client.get(f'{url}?permanent=true&expires_before={target_time.isoformat()}') + + self.assertEqual(response.status_code, 400) + errors = list(response.json()) + self.assertEqual("permanent", errors[0]) + + def test_nonpermanent_before(self): + url = reverse('bot:infraction-list', host='api') + target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=6) + response = self.client.get( + f'{url}?permanent=false&expires_before={target_time.isoformat()}' + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json()[0]["id"], self.superstar_expires_soon.id) + + def test_filter_manytypes(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?types=mute,ban') + + self.assertEqual(response.status_code, 200) + infractions = response.json() + self.assertEqual(len(infractions), 3) + + def test_types_type_invalid(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?types=mute,ban&type=superstar') + + self.assertEqual(response.status_code, 400) + errors = list(response.json()) + self.assertEqual("types", errors[0]) + + def test_sort_expiresby(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?ordering=expires_at&permanent=false') + self.assertEqual(response.status_code, 200) + infractions = response.json() + + self.assertEqual(len(infractions), 3) + self.assertEqual(infractions[0]['id'], self.superstar_expires_soon.id) + self.assertEqual(infractions[1]['id'], self.voiceban_expires_later.id) + self.assertEqual(infractions[2]['id'], self.ban_hidden.id) + def test_returns_empty_for_no_match(self): url = reverse('bot:infraction-list', host='api') response = self.client.get(f'{url}?type=ban&search=poop') @@ -502,7 +664,10 @@ class CreationTests(APISubdomainTestCase): ) def test_integrity_error_if_missing_active_field(self): - pattern = 'null value in column "active" violates not-null constraint' + pattern = ( + 'null value in column "active" (of relation "api_infraction" )?' + 'violates not-null constraint' + ) with self.assertRaisesRegex(IntegrityError, pattern): Infraction.objects.create( user=self.user, diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index 66052e01..5c9ddea4 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -1,6 +1,7 @@ from datetime import datetime as dt -from django.test import SimpleTestCase +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase, TestCase from django.utils import timezone from pydis_site.apps.api.models import ( @@ -34,6 +35,43 @@ class ReprMixinTests(SimpleTestCase): self.assertEqual(repr(self.klass), expected) +class NitroMessageLengthTest(TestCase): + def setUp(self): + self.user = User.objects.create(id=50, name='bill', discriminator=5) + self.context = MessageDeletionContext.objects.create( + id=50, + actor=self.user, + creation=dt.utcnow() + ) + + def test_create(self): + message = DeletedMessage( + id=46, + author=self.user, + channel_id=666, + content="w"*4000, + deletion_context=self.context, + embeds=[] + ) + + try: + message.clean_fields() + except Exception as e: # pragma: no cover + self.fail(f"Creation of message of length 3950 failed with: {e}") + + def test_create_failure(self): + message = DeletedMessage( + id=47, + author=self.user, + channel_id=666, + content="w"*4001, + deletion_context=self.context, + embeds=[] + ) + + self.assertRaisesRegex(ValidationError, "content':", message.clean_fields) + + class StringDunderMethodTests(SimpleTestCase): def setUp(self): self.nomination = Nomination( diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index bd512ddd..f8b0cb9d 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -1,3 +1,6 @@ +from datetime import datetime + +from django.db.models import QuerySet from django.http.request import HttpRequest from django_filters.rest_framework import DjangoFilterBackend from rest_framework.decorators import action @@ -43,10 +46,17 @@ class InfractionViewSet( - **offset** `int`: the initial index from which to return the results (default 0) - **search** `str`: regular expression applied to the infraction's reason - **type** `str`: the type of the infraction + - **types** `str`: comma separated sequence of types to filter for - **user__id** `int`: snowflake of the user to which the infraction was applied - **ordering** `str`: comma-separated sequence of fields to order the returned results + - **permanent** `bool`: whether or not to retrieve permanent infractions (default True) + - **expires_after** `isodatetime`: the earliest expires_at time to return infractions for + - **expires_before** `isodatetime`: the latest expires_at time to return infractions for Invalid query parameters are ignored. + Only one of `type` and `types` may be provided. If both `expires_before` and `expires_after` + are provided, `expires_after` must come after `expires_before`. + If `permanent` is provided and true, `expires_before` and `expires_after` must not be provided. #### Response format Response is paginated but the result is returned without any pagination metadata. @@ -156,6 +166,69 @@ class InfractionViewSet( return Response(serializer.data) + def get_queryset(self) -> QuerySet: + """ + Called to fetch the initial queryset, used to implement some of the more complex filters. + + This provides the `permanent` and the `expires_gte` and `expires_lte` options. + """ + filter_permanent = self.request.query_params.get('permanent') + additional_filters = {} + if filter_permanent is not None: + additional_filters['expires_at__isnull'] = filter_permanent.lower() == 'true' + + filter_expires_after = self.request.query_params.get('expires_after') + if filter_expires_after: + try: + additional_filters['expires_at__gte'] = datetime.fromisoformat( + filter_expires_after + ) + except ValueError: + raise ValidationError({'expires_after': ['failed to convert to datetime']}) + + filter_expires_before = self.request.query_params.get('expires_before') + if filter_expires_before: + try: + additional_filters['expires_at__lte'] = datetime.fromisoformat( + filter_expires_before + ) + except ValueError: + raise ValidationError({'expires_before': ['failed to convert to datetime']}) + + if 'expires_at__lte' in additional_filters and 'expires_at__gte' in additional_filters: + if additional_filters['expires_at__gte'] > additional_filters['expires_at__lte']: + raise ValidationError({ + 'expires_before': ['cannot be after expires_after'], + 'expires_after': ['cannot be before expires_before'], + }) + + if ( + ('expires_at__lte' in additional_filters or 'expires_at__gte' in additional_filters) + and 'expires_at__isnull' in additional_filters + and additional_filters['expires_at__isnull'] + ): + raise ValidationError({ + 'permanent': [ + 'cannot filter for permanent infractions at the' + ' same time as expires_at or expires_before', + ] + }) + + if filter_expires_before: + # Filter out permanent infractions specifically if we want ones that will expire + # before a given date + additional_filters['expires_at__isnull'] = False + + filter_types = self.request.query_params.get('types') + if filter_types: + if self.request.query_params.get('type'): + raise ValidationError({ + 'types': ['you must provide only one of "type" or "types"'], + }) + additional_filters['type__in'] = [i.strip() for i in filter_types.split(",")] + + return self.queryset.filter(**additional_filters) + @action(url_path='expanded', detail=False) def list_expanded(self, *args, **kwargs) -> Response: """ |