aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site/apps/api
diff options
context:
space:
mode:
authorGravatar Steele <[email protected]>2022-06-13 18:42:40 -0400
committerGravatar Steele <[email protected]>2022-06-13 18:42:40 -0400
commit8351bf5c98aca9c6147c69d4a8bfe1e6d920653d (patch)
treef73baaa5f1020b8190a85033768f2c4ac2599ea1 /pydis_site/apps/api
parentAdd PyCharm logo. (diff)
parentMerge pull request #699 from camcaswell/contrib-streamline (diff)
Merge branch 'main' into swfarnsworth/resources
Diffstat (limited to 'pydis_site/apps/api')
-rw-r--r--pydis_site/apps/api/README.md71
-rw-r--r--pydis_site/apps/api/migrations/0080_add_aoc_tables.py32
-rw-r--r--pydis_site/apps/api/migrations/0081_bumpedthread.py22
-rw-r--r--pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py19
-rw-r--r--pydis_site/apps/api/models/__init__.py5
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py5
-rw-r--r--pydis_site/apps/api/models/bot/aoc_completionist_block.py26
-rw-r--r--pydis_site/apps/api/models/bot/aoc_link.py21
-rw-r--r--pydis_site/apps/api/models/bot/bumped_thread.py22
-rw-r--r--pydis_site/apps/api/models/bot/off_topic_channel_name.py2
-rw-r--r--pydis_site/apps/api/serializers.py56
-rw-r--r--pydis_site/apps/api/tests/test_bumped_threads.py63
-rw-r--r--pydis_site/apps/api/tests/test_infractions.py81
-rw-r--r--pydis_site/apps/api/tests/test_models.py7
-rw-r--r--pydis_site/apps/api/tests/test_off_topic_channel_names.py35
-rw-r--r--pydis_site/apps/api/tests/test_reminders.py17
-rw-r--r--pydis_site/apps/api/tests/test_users.py43
-rw-r--r--pydis_site/apps/api/urls.py19
-rw-r--r--pydis_site/apps/api/viewsets/__init__.py5
-rw-r--r--pydis_site/apps/api/viewsets/bot/__init__.py3
-rw-r--r--pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py73
-rw-r--r--pydis_site/apps/api/viewsets/bot/aoc_link.py71
-rw-r--r--pydis_site/apps/api/viewsets/bot/bumped_thread.py66
-rw-r--r--pydis_site/apps/api/viewsets/bot/infraction.py43
-rw-r--r--pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py3
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py5
26 files changed, 728 insertions, 87 deletions
diff --git a/pydis_site/apps/api/README.md b/pydis_site/apps/api/README.md
new file mode 100644
index 00000000..1c6358b3
--- /dev/null
+++ b/pydis_site/apps/api/README.md
@@ -0,0 +1,71 @@
+# The "api" app
+
+This application takes care of most of the heavy lifting in the site, that is,
+allowing our bot to manipulate and query information stored in the site's
+database.
+
+We make heavy use of [Django REST
+Framework](https://www.django-rest-framework.org) here, which builds on top of
+Django to allow us to easily build out the
+[REST](https://en.wikipedia.org/wiki/Representational_state_transfer) API
+consumed by our bot. Working with the API app requires basic knowledge of DRF -
+the [quickstart
+guide](https://www.django-rest-framework.org/tutorial/quickstart/) is a great
+resource to get started.
+
+## Directory structure
+
+Let's look over each of the subdirectories here:
+
+- `migrations` is the standard Django migrations folder. You usually won't need
+ to edit this manually, as `python manage.py makemigrations` handles this for
+ you in case you change our models. (Note that when generating migrations and
+ Django doesn't generate a human-readable name for you, please supply one
+ manually using `-n add_this_field`.)
+
+- `models` contains our Django model definitions. We put models into subfolders
+ relevant as to where they are used - in our case, the `bot` folder contains
+ models used by our bot when working with the API. Each model is contained
+ within its own module, such as `api/models/bot/message_deletion_context.py`,
+ which contains the `MessageDeletionContext` model.
+
+- `tests` contains tests for our API. If you're unfamilar with Django testing,
+ the [Django tutorial introducing automated
+ testing](https://docs.djangoproject.com/en/dev/intro/tutorial05/) is a great
+ resource, and you can also check out code in there to see how we test it.
+
+- `viewsets` contains our [DRF
+ viewsets](https://www.django-rest-framework.org/api-guide/viewsets/), and is
+ structured similarly to the `models` folder: The `bot` subfolder contains
+ viewsets relevant to the Python Bot, and each viewset is contained within its
+ own module.
+
+The remaining modules mostly do what their name suggests:
+
+- `admin.py`, which hooks up our models to the [Django admin
+ site](https://docs.djangoproject.com/en/dev/ref/contrib/admin/).
+
+- `apps.py` contains the Django [application
+ config](https://docs.djangoproject.com/en/dev/ref/applications/) for the `api`
+ app, and is used to run any code that should run when the app is loaded.
+
+- `pagination.py` contains custom
+ [paginators](https://www.django-rest-framework.org/api-guide/pagination/) used
+ within our DRF viewsets.
+
+- `serializers.py` contains [DRF
+ serializers](https://www.django-rest-framework.org/api-guide/serializers/) for
+ our models, and also includes validation logic for the models.
+
+- `signals.py` contains [Django
+ Signals](https://docs.djangoproject.com/en/dev/topics/signals/) for running
+ custom functionality in response to events such as deletion of a model
+ instance.
+
+- `urls.py` configures Django's [URL
+ dispatcher](https://docs.djangoproject.com/en/dev/topics/http/urls/) for our
+ API endpoints.
+
+- `views.py` is for any standard Django views that don't make sense to be put
+ into DRF viewsets as they provide static data or other functionality that
+ doesn't interact with our models.
diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
new file mode 100644
index 00000000..2c0c689a
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.1.14 on 2022-03-06 16:07
+
+from django.db import migrations, models
+import django.db.models.deletion
+import pydis_site.apps.api.models.mixins
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0079_merge_20220125_2022'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AocAccountLink',
+ fields=[
+ ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='api.user')),
+ ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)),
+ ],
+ bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='AocCompletionistBlock',
+ fields=[
+ ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='api.user')),
+ ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')),
+ ('reason', models.TextField(help_text='The reason for the AoC Completionist Role Block.', null=True)),
+ ],
+ bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0081_bumpedthread.py b/pydis_site/apps/api/migrations/0081_bumpedthread.py
new file mode 100644
index 00000000..03e66cc1
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0081_bumpedthread.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1.14 on 2022-02-19 16:26
+
+import django.core.validators
+from django.db import migrations, models
+import pydis_site.apps.api.models.mixins
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0080_add_aoc_tables'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BumpedThread',
+ fields=[
+ ('thread_id', models.BigIntegerField(help_text='The thread ID that should be bumped.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Thread IDs cannot be negative.')], verbose_name='Thread ID')),
+ ],
+ bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py b/pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py
new file mode 100644
index 00000000..abbb98ec
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1.14 on 2022-04-21 23:29
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0081_bumpedthread'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='offtopicchannelname',
+ name='name',
+ field=models.CharField(help_text='The actual channel name that will be used on our Discord server.', max_length=96, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex="^[a-z0-9\\U0001d5a0-\\U0001d5b9-ǃ?’'<>⧹⧸]+$")]),
+ ),
+ ]
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index fd5bf220..a197e988 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -1,15 +1,18 @@
# flake8: noqa
from .bot import (
- FilterList,
BotSetting,
+ BumpedThread,
DocumentationLink,
DeletedMessage,
+ FilterList,
Infraction,
Message,
MessageDeletionContext,
Nomination,
NominationEntry,
OffensiveMessage,
+ AocAccountLink,
+ AocCompletionistBlock,
OffTopicChannelName,
Reminder,
Role,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index ac864de3..013bb85e 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -1,10 +1,13 @@
# flake8: noqa
-from .filter_list import FilterList
from .bot_setting import BotSetting
+from .bumped_thread import BumpedThread
from .deleted_message import DeletedMessage
from .documentation_link import DocumentationLink
+from .filter_list import FilterList
from .infraction import Infraction
from .message import Message
+from .aoc_completionist_block import AocCompletionistBlock
+from .aoc_link import AocAccountLink
from .message_deletion_context import MessageDeletionContext
from .nomination import Nomination, NominationEntry
from .off_topic_channel_name import OffTopicChannelName
diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
new file mode 100644
index 00000000..acbc0eba
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
@@ -0,0 +1,26 @@
+from django.db import models
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+class AocCompletionistBlock(ModelReprMixin, models.Model):
+ """A Discord user blocked from getting the AoC completionist Role."""
+
+ user = models.OneToOneField(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The user that is blocked from getting the AoC Completionist Role",
+ primary_key=True
+ )
+
+ is_blocked = models.BooleanField(
+ default=True,
+ help_text="Whether this user is actively being blocked "
+ "from getting the AoC Completionist Role",
+ verbose_name="Blocked"
+ )
+ reason = models.TextField(
+ null=True,
+ help_text="The reason for the AoC Completionist Role Block."
+ )
diff --git a/pydis_site/apps/api/models/bot/aoc_link.py b/pydis_site/apps/api/models/bot/aoc_link.py
new file mode 100644
index 00000000..4e9d4882
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/aoc_link.py
@@ -0,0 +1,21 @@
+from django.db import models
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+class AocAccountLink(ModelReprMixin, models.Model):
+ """An AoC account link for a Discord User."""
+
+ user = models.OneToOneField(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The user that is blocked from getting the AoC Completionist Role",
+ primary_key=True
+ )
+
+ aoc_username = models.CharField(
+ max_length=120,
+ help_text="The AoC username associated with the Discord User.",
+ blank=False
+ )
diff --git a/pydis_site/apps/api/models/bot/bumped_thread.py b/pydis_site/apps/api/models/bot/bumped_thread.py
new file mode 100644
index 00000000..cdf9a950
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/bumped_thread.py
@@ -0,0 +1,22 @@
+from django.core.validators import MinValueValidator
+from django.db import models
+
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+class BumpedThread(ModelReprMixin, models.Model):
+ """A list of thread IDs to be bumped."""
+
+ thread_id = models.BigIntegerField(
+ primary_key=True,
+ help_text=(
+ "The thread ID that should be bumped."
+ ),
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Thread IDs cannot be negative."
+ ),
+ ),
+ verbose_name="Thread ID",
+ )
diff --git a/pydis_site/apps/api/models/bot/off_topic_channel_name.py b/pydis_site/apps/api/models/bot/off_topic_channel_name.py
index e9fec114..b380efad 100644
--- a/pydis_site/apps/api/models/bot/off_topic_channel_name.py
+++ b/pydis_site/apps/api/models/bot/off_topic_channel_name.py
@@ -11,7 +11,7 @@ class OffTopicChannelName(ModelReprMixin, models.Model):
primary_key=True,
max_length=96,
validators=(
- RegexValidator(regex=r"^[a-z0-9\U0001d5a0-\U0001d5b9-ǃ?’'<>]+$"),
+ RegexValidator(regex=r"^[a-z0-9\U0001d5a0-\U0001d5b9-ǃ?’'<>⧹⧸]+$"),
),
help_text="The actual channel name that will be used on our Discord server."
)
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 4a702d61..e53ccffa 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -13,7 +13,10 @@ from rest_framework.settings import api_settings
from rest_framework.validators import UniqueTogetherValidator
from .models import (
+ AocAccountLink,
+ AocCompletionistBlock,
BotSetting,
+ BumpedThread,
DeletedMessage,
DocumentationLink,
FilterList,
@@ -39,6 +42,32 @@ class BotSettingSerializer(ModelSerializer):
fields = ('name', 'data')
+class ListBumpedThreadSerializer(ListSerializer):
+ """Custom ListSerializer to override to_representation() when list views are triggered."""
+
+ def to_representation(self, objects: list[BumpedThread]) -> int:
+ """
+ Used by the `ListModelMixin` to return just the list of bumped thread ids.
+
+ Only the thread_id field is useful, hence it is unnecessary to create a nested dictionary.
+
+ Additionally, this allows bumped thread routes to simply return an
+ array of thread_id ints instead of objects, saving on bandwidth.
+ """
+ return [obj.thread_id for obj in objects]
+
+
+class BumpedThreadSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `BumpedThread` instances."""
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ list_serializer_class = ListBumpedThreadSerializer
+ model = BumpedThread
+ fields = ('thread_id',)
+
+
class DeletedMessageSerializer(ModelSerializer):
"""
A class providing (de-)serialization of `DeletedMessage` instances.
@@ -156,13 +185,6 @@ class InfractionSerializer(ModelSerializer):
'hidden',
'dm_sent'
)
- validators = [
- UniqueTogetherValidator(
- queryset=Infraction.objects.filter(active=True),
- fields=['user', 'type', 'active'],
- message='This user already has an active infraction of this type.',
- )
- ]
def validate(self, attrs: dict) -> dict:
"""Validate data constraints for the given data and abort if it is invalid."""
@@ -257,6 +279,26 @@ class ReminderSerializer(ModelSerializer):
)
+class AocCompletionistBlockSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `AocCompletionistBlock` instances."""
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = AocCompletionistBlock
+ fields = ("user", "is_blocked", "reason")
+
+
+class AocAccountLinkSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `AocAccountLink` instances."""
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = AocAccountLink
+ fields = ("user", "aoc_username")
+
+
class RoleSerializer(ModelSerializer):
"""A class providing (de-)serialization of `Role` instances."""
diff --git a/pydis_site/apps/api/tests/test_bumped_threads.py b/pydis_site/apps/api/tests/test_bumped_threads.py
new file mode 100644
index 00000000..316e3f0b
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_bumped_threads.py
@@ -0,0 +1,63 @@
+from django.urls import reverse
+
+from .base import AuthenticatedAPITestCase
+from ..models import BumpedThread
+
+
+class UnauthedBumpedThreadAPITests(AuthenticatedAPITestCase):
+ def setUp(self):
+ super().setUp()
+ self.client.force_authenticate(user=None)
+
+ def test_detail_lookup_returns_401(self):
+ url = reverse('api:bot:bumpedthread-detail', args=(1,))
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_list_returns_401(self):
+ url = reverse('api:bot:bumpedthread-list')
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_create_returns_401(self):
+ url = reverse('api:bot:bumpedthread-list')
+ response = self.client.post(url, {"thread_id": 3})
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_delete_returns_401(self):
+ url = reverse('api:bot:bumpedthread-detail', args=(1,))
+ response = self.client.delete(url)
+
+ self.assertEqual(response.status_code, 401)
+
+
+class BumpedThreadAPITests(AuthenticatedAPITestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.thread1 = BumpedThread.objects.create(
+ thread_id=1234,
+ )
+
+ def test_returns_bumped_threads_as_flat_list(self):
+ url = reverse('api:bot:bumpedthread-list')
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), [1234])
+
+ def test_returns_204_for_existing_data(self):
+ url = reverse('api:bot:bumpedthread-detail', args=(1234,))
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 204)
+ self.assertEqual(response.content, b"")
+
+ def test_returns_404_for_non_existing_data(self):
+ url = reverse('api:bot:bumpedthread-detail', args=(42,))
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response.json(), {"detail": "Not found."})
diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py
index b3dd16ee..f1107734 100644
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -3,6 +3,7 @@ from datetime import datetime as dt, timedelta, timezone
from unittest.mock import patch
from urllib.parse import quote
+from django.db import transaction
from django.db.utils import IntegrityError
from django.urls import reverse
@@ -79,7 +80,7 @@ class InfractionTests(AuthenticatedAPITestCase):
type='superstar',
reason='This one doesn\'t matter anymore.',
active=True,
- expires_at=datetime.datetime.utcnow() + datetime.timedelta(hours=5)
+ expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5)
)
cls.voiceban_expires_later = Infraction.objects.create(
user_id=cls.user.id,
@@ -87,7 +88,7 @@ class InfractionTests(AuthenticatedAPITestCase):
type='voice_ban',
reason='Jet engine mic',
active=True,
- expires_at=datetime.datetime.utcnow() + datetime.timedelta(days=5)
+ expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5)
)
def test_list_all(self):
@@ -492,6 +493,7 @@ class CreationTests(AuthenticatedAPITestCase):
)
for infraction_type, hidden in restricted_types:
+ # https://stackoverflow.com/a/23326971
with self.subTest(infraction_type=infraction_type):
invalid_infraction = {
'user': self.user.id,
@@ -516,37 +518,38 @@ class CreationTests(AuthenticatedAPITestCase):
for infraction_type in active_infraction_types:
with self.subTest(infraction_type=infraction_type):
- first_active_infraction = {
- 'user': self.user.id,
- 'actor': self.user.id,
- 'type': infraction_type,
- 'reason': 'Take me on!',
- 'active': True,
- 'expires_at': '2019-10-04T12:52:00+00:00'
- }
-
- # Post the first active infraction of a type and confirm it's accepted.
- first_response = self.client.post(url, data=first_active_infraction)
- self.assertEqual(first_response.status_code, 201)
+ with transaction.atomic():
+ first_active_infraction = {
+ 'user': self.user.id,
+ 'actor': self.user.id,
+ 'type': infraction_type,
+ 'reason': 'Take me on!',
+ 'active': True,
+ 'expires_at': '2019-10-04T12:52:00+00:00'
+ }
- second_active_infraction = {
- 'user': self.user.id,
- 'actor': self.user.id,
- 'type': infraction_type,
- 'reason': 'Take on me!',
- 'active': True,
- 'expires_at': '2019-10-04T12:52:00+00:00'
- }
- second_response = self.client.post(url, data=second_active_infraction)
- self.assertEqual(second_response.status_code, 400)
- self.assertEqual(
- second_response.json(),
- {
- 'non_field_errors': [
- 'This user already has an active infraction of this type.'
- ]
+ # Post the first active infraction of a type and confirm it's accepted.
+ first_response = self.client.post(url, data=first_active_infraction)
+ self.assertEqual(first_response.status_code, 201)
+
+ second_active_infraction = {
+ 'user': self.user.id,
+ 'actor': self.user.id,
+ 'type': infraction_type,
+ 'reason': 'Take on me!',
+ 'active': True,
+ 'expires_at': '2019-10-04T12:52:00+00:00'
}
- )
+ second_response = self.client.post(url, data=second_active_infraction)
+ self.assertEqual(second_response.status_code, 400)
+ self.assertEqual(
+ second_response.json(),
+ {
+ 'non_field_errors': [
+ 'This user already has an active infraction of this type.'
+ ]
+ }
+ )
def test_returns_201_for_second_active_infraction_of_different_type(self):
"""Test if the API accepts a second active infraction of a different type than the first."""
@@ -811,22 +814,6 @@ class SerializerTests(AuthenticatedAPITestCase):
self.assertTrue(serializer.is_valid(), msg=serializer.errors)
- def test_validation_error_if_active_duplicate(self):
- self.create_infraction('ban', active=True)
- instance = self.create_infraction('ban', active=False)
-
- data = {'active': True}
- serializer = InfractionSerializer(instance, data=data, partial=True)
-
- if not serializer.is_valid():
- self.assertIn('non_field_errors', serializer.errors)
-
- code = serializer.errors['non_field_errors'][0].code
- msg = f'Expected failure on unique validator but got {serializer.errors}'
- self.assertEqual(code, 'unique', msg=msg)
- else: # pragma: no cover
- self.fail('Validation unexpectedly succeeded.')
-
def test_is_valid_for_new_active_infraction(self):
self.create_infraction('ban', active=False)
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index 5c9ddea4..0fad467c 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -1,8 +1,7 @@
-from datetime import datetime as dt
+from datetime import datetime as dt, timezone
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase, TestCase
-from django.utils import timezone
from pydis_site.apps.api.models import (
DeletedMessage,
@@ -41,7 +40,7 @@ class NitroMessageLengthTest(TestCase):
self.context = MessageDeletionContext.objects.create(
id=50,
actor=self.user,
- creation=dt.utcnow()
+ creation=dt.now(timezone.utc)
)
def test_create(self):
@@ -99,7 +98,7 @@ class StringDunderMethodTests(SimpleTestCase):
name='shawn',
discriminator=555,
),
- creation=dt.utcnow()
+ creation=dt.now(timezone.utc)
),
embeds=[]
),
diff --git a/pydis_site/apps/api/tests/test_off_topic_channel_names.py b/pydis_site/apps/api/tests/test_off_topic_channel_names.py
index 2d273756..34098c92 100644
--- a/pydis_site/apps/api/tests/test_off_topic_channel_names.py
+++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py
@@ -74,6 +74,9 @@ class ListTests(AuthenticatedAPITestCase):
cls.test_name_3 = OffTopicChannelName.objects.create(
name="frozen-with-iceman", used=True, active=False
)
+ cls.test_name_4 = OffTopicChannelName.objects.create(
+ name="xith-is-cool", used=True, active=True
+ )
def test_returns_name_in_list(self):
"""Return all off-topic channel names."""
@@ -86,28 +89,46 @@ class ListTests(AuthenticatedAPITestCase):
{
self.test_name.name,
self.test_name_2.name,
- self.test_name_3.name
+ self.test_name_3.name,
+ self.test_name_4.name
}
)
- def test_returns_two_items_with_random_items_param_set_to_2(self):
- """Return not-used name instead used."""
+ def test_returns_two_active_items_with_random_items_param_set_to_2(self):
+ """Return not-used active names instead used."""
url = reverse('api:bot:offtopicchannelname-list')
response = self.client.get(f'{url}?random_items=2')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 2)
- self.assertEqual(set(response.json()), {self.test_name.name, self.test_name_2.name})
+ self.assertTrue(
+ all(
+ item in (self.test_name.name, self.test_name_2.name, self.test_name_4.name)
+ for item in response.json()
+ )
+ )
+
+ def test_returns_three_active_items_with_random_items_param_set_to_3(self):
+ """Return not-used active names instead used."""
+ url = reverse('api:bot:offtopicchannelname-list')
+ response = self.client.get(f'{url}?random_items=3')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(len(response.json()), 3)
+ self.assertEqual(
+ set(response.json()),
+ {self.test_name.name, self.test_name_2.name, self.test_name_4.name}
+ )
def test_running_out_of_names_with_random_parameter(self):
- """Reset names `used` parameter to `False` when running out of names."""
+ """Reset names `used` parameter to `False` when running out of active names."""
url = reverse('api:bot:offtopicchannelname-list')
response = self.client.get(f'{url}?random_items=3')
self.assertEqual(response.status_code, 200)
self.assertEqual(
set(response.json()),
- {self.test_name.name, self.test_name_2.name, self.test_name_3.name}
+ {self.test_name.name, self.test_name_2.name, self.test_name_4.name}
)
def test_returns_inactive_ot_names(self):
@@ -129,7 +150,7 @@ class ListTests(AuthenticatedAPITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(
set(response.json()),
- {self.test_name.name, self.test_name_2.name}
+ {self.test_name.name, self.test_name_2.name, self.test_name_4.name}
)
diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py
index 709685bc..e17569f0 100644
--- a/pydis_site/apps/api/tests/test_reminders.py
+++ b/pydis_site/apps/api/tests/test_reminders.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
from django.forms.models import model_to_dict
from django.urls import reverse
@@ -91,7 +91,7 @@ class ReminderDeletionTests(AuthenticatedAPITestCase):
cls.reminder = Reminder.objects.create(
author=cls.author,
content="Don't forget to set yourself a reminder",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="https://www.decliningmentalfaculties.com",
channel_id=123
)
@@ -122,7 +122,7 @@ class ReminderListTests(AuthenticatedAPITestCase):
cls.reminder_one = Reminder.objects.create(
author=cls.author,
content="We should take Bikini Bottom, and push it somewhere else!",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="https://www.icantseemyforehead.com",
channel_id=123
)
@@ -130,16 +130,17 @@ class ReminderListTests(AuthenticatedAPITestCase):
cls.reminder_two = Reminder.objects.create(
author=cls.author,
content="Gahhh-I love being purple!",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="https://www.goofygoobersicecreampartyboat.com",
channel_id=123,
active=False
)
+ drf_format = '%Y-%m-%dT%H:%M:%S.%fZ'
cls.rem_dict_one = model_to_dict(cls.reminder_one)
- cls.rem_dict_one['expiration'] += 'Z' # Massaging a quirk of the response time format
+ cls.rem_dict_one['expiration'] = cls.rem_dict_one['expiration'].strftime(drf_format)
cls.rem_dict_two = model_to_dict(cls.reminder_two)
- cls.rem_dict_two['expiration'] += 'Z' # Massaging a quirk of the response time format
+ cls.rem_dict_two['expiration'] = cls.rem_dict_two['expiration'].strftime(drf_format)
def test_reminders_in_full_list(self):
url = reverse('api:bot:reminder-list')
@@ -175,7 +176,7 @@ class ReminderRetrieveTests(AuthenticatedAPITestCase):
cls.reminder = Reminder.objects.create(
author=cls.author,
content="Reminder content",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="http://example.com/",
channel_id=123
)
@@ -203,7 +204,7 @@ class ReminderUpdateTests(AuthenticatedAPITestCase):
cls.reminder = Reminder.objects.create(
author=cls.author,
content="Squash those do-gooders",
- expiration=datetime.utcnow().isoformat(),
+ expiration=datetime.now(timezone.utc),
jump_url="https://www.decliningmentalfaculties.com",
channel_id=123
)
diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py
index e21bb32b..5d10069d 100644
--- a/pydis_site/apps/api/tests/test_users.py
+++ b/pydis_site/apps/api/tests/test_users.py
@@ -1,3 +1,4 @@
+import random
from unittest.mock import Mock, patch
from django.urls import reverse
@@ -520,3 +521,45 @@ class UserMetricityTests(AuthenticatedAPITestCase):
self.metricity.total_messages.side_effect = NotFoundError()
self.metricity.total_message_blocks.side_effect = NotFoundError()
self.metricity.top_channel_activity.side_effect = NotFoundError()
+
+
+class UserViewSetTests(AuthenticatedAPITestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.searched_user = User.objects.create(
+ id=12095219,
+ name=f"Test user {random.randint(100, 1000)}",
+ discriminator=random.randint(1, 9999),
+ in_guild=True,
+ )
+ cls.other_user = User.objects.create(
+ id=18259125,
+ name=f"Test user {random.randint(100, 1000)}",
+ discriminator=random.randint(1, 9999),
+ in_guild=True,
+ )
+
+ def test_search_lookup_of_wanted_user(self) -> None:
+ """Searching a user by name and discriminator should return that user."""
+ url = reverse('api:bot:user-list')
+ params = {
+ 'username': self.searched_user.name,
+ 'discriminator': self.searched_user.discriminator,
+ }
+ response = self.client.get(url, params)
+ result = response.json()
+ self.assertEqual(result['count'], 1)
+ [user] = result['results']
+ self.assertEqual(user['id'], self.searched_user.id)
+
+ def test_search_lookup_of_unknown_user(self) -> None:
+ """Searching an unknown user should return no results."""
+ url = reverse('api:bot:user-list')
+ params = {
+ 'username': "f-string enjoyer",
+ 'discriminator': 1245,
+ }
+ response = self.client.get(url, params)
+ result = response.json()
+ self.assertEqual(result['count'], 0)
+ self.assertEqual(result['results'], [])
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index b0ab545b..1e564b29 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -3,7 +3,10 @@ from rest_framework.routers import DefaultRouter
from .views import HealthcheckView, RulesView
from .viewsets import (
+ AocAccountLinkViewSet,
+ AocCompletionistBlockViewSet,
BotSettingViewSet,
+ BumpedThreadViewSet,
DeletedMessageViewSet,
DocumentationLinkViewSet,
FilterListViewSet,
@@ -19,14 +22,22 @@ from .viewsets import (
# https://www.django-rest-framework.org/api-guide/routers/#defaultrouter
bot_router = DefaultRouter(trailing_slash=False)
bot_router.register(
- 'filter-lists',
- FilterListViewSet
+ "aoc-account-links",
+ AocAccountLinkViewSet
+)
+bot_router.register(
+ "aoc-completionist-blocks",
+ AocCompletionistBlockViewSet
)
bot_router.register(
'bot-settings',
BotSettingViewSet
)
bot_router.register(
+ 'bumped-threads',
+ BumpedThreadViewSet
+)
+bot_router.register(
'deleted-messages',
DeletedMessageViewSet
)
@@ -35,6 +46,10 @@ bot_router.register(
DocumentationLinkViewSet
)
bot_router.register(
+ 'filter-lists',
+ FilterListViewSet
+)
+bot_router.register(
'infractions',
InfractionViewSet
)
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index f133e77f..ec52416a 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -1,12 +1,15 @@
# flake8: noqa
from .bot import (
- FilterListViewSet,
BotSettingViewSet,
+ BumpedThreadViewSet,
DeletedMessageViewSet,
DocumentationLinkViewSet,
+ FilterListViewSet,
InfractionViewSet,
NominationViewSet,
OffensiveMessageViewSet,
+ AocAccountLinkViewSet,
+ AocCompletionistBlockViewSet,
OffTopicChannelNameViewSet,
ReminderViewSet,
RoleViewSet,
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index 84b87eab..262aa59f 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -1,12 +1,15 @@
# flake8: noqa
from .filter_list import FilterListViewSet
from .bot_setting import BotSettingViewSet
+from .bumped_thread import BumpedThreadViewSet
from .deleted_message import DeletedMessageViewSet
from .documentation_link import DocumentationLinkViewSet
from .infraction import InfractionViewSet
from .nomination import NominationViewSet
from .off_topic_channel_name import OffTopicChannelNameViewSet
from .offensive_message import OffensiveMessageViewSet
+from .aoc_link import AocAccountLinkViewSet
+from .aoc_completionist_block import AocCompletionistBlockViewSet
from .reminder import ReminderViewSet
from .role import RoleViewSet
from .user import UserViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
new file mode 100644
index 00000000..3a4cec60
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
@@ -0,0 +1,73 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.mixins import (
+ CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import AocCompletionistBlock
+from pydis_site.apps.api.serializers import AocCompletionistBlockSerializer
+
+
+class AocCompletionistBlockViewSet(
+ GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+):
+ """
+ View providing management for Users blocked from gettign the AoC completionist Role.
+
+ ## Routes
+
+ ### GET /bot/aoc-completionist-blocks/
+ Returns all the AoC completionist blocks
+
+ #### Response format
+ >>> [
+ ... {
+ ... "user": 2,
+ ... "is_blocked": False,
+ ... "reason": "Too good to be true"
+ ... }
+ ... ]
+
+
+ ### GET /bot/aoc-completionist-blocks/<user__id:int>
+ Retrieve a single Block by User ID
+
+ #### Response format
+ >>>
+ ... {
+ ... "user": 2,
+ ... "is_blocked": False,
+ ... "reason": "Too good to be true"
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if an AoC completionist block with the given `user__id` was not found.
+
+ ### POST /bot/aoc-completionist-blocks
+ Adds a single AoC completionist block
+
+ #### Request body
+ >>> {
+ ... "user": int,
+ ... "is_blocked": bool,
+ ... "reason": string
+ ... }
+
+ #### Status codes
+ - 204: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/aoc-completionist-blocks/<user__id:int>
+ Deletes the AoC Completionist block item with the given `user__id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: returned if the AoC Completionist block with the given `user__id` was not found
+
+ """
+
+ serializer_class = AocCompletionistBlockSerializer
+ queryset = AocCompletionistBlock.objects.all()
+ filter_backends = (DjangoFilterBackend,)
+ filter_fields = ("user__id", "is_blocked")
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py
new file mode 100644
index 00000000..c7a96629
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py
@@ -0,0 +1,71 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.mixins import (
+ CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import AocAccountLink
+from pydis_site.apps.api.serializers import AocAccountLinkSerializer
+
+
+class AocAccountLinkViewSet(
+ GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+):
+ """
+ View providing management for Users who linked their AoC accounts to their Discord Account.
+
+ ## Routes
+
+ ### GET /bot/aoc-account-links
+ Returns all the AoC account links
+
+ #### Response format
+ >>> [
+ ... {
+ ... "user": 2,
+ ... "aoc_username": "AoCUser1"
+ ... },
+ ... ...
+ ... ]
+
+
+ ### GET /bot/aoc-account-links/<user__id:int>
+ Retrieve a AoC account link by User ID
+
+ #### Response format
+ >>>
+ ... {
+ ... "user": 2,
+ ... "aoc_username": "AoCUser1"
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if an AoC account link with the given `user__id` was not found.
+
+ ### POST /bot/aoc-account-links
+ Adds a single AoC account link block
+
+ #### Request body
+ >>> {
+ ... 'user': int,
+ ... 'aoc_username': str
+ ... }
+
+ #### Status codes
+ - 204: returned on success
+ - 400: if one of the given fields was invalid
+
+ ### DELETE /bot/aoc-account-links/<user__id:int>
+ Deletes the AoC account link item with the given `user__id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: returned if the AoC account link with the given `user__id` was not found
+
+ """
+
+ serializer_class = AocAccountLinkSerializer
+ queryset = AocAccountLink.objects.all()
+ filter_backends = (DjangoFilterBackend,)
+ filter_fields = ("user__id", "aoc_username")
diff --git a/pydis_site/apps/api/viewsets/bot/bumped_thread.py b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
new file mode 100644
index 00000000..9d77bb6b
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
@@ -0,0 +1,66 @@
+from rest_framework.mixins import (
+ CreateModelMixin, DestroyModelMixin, ListModelMixin
+)
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import BumpedThread
+from pydis_site.apps.api.serializers import BumpedThreadSerializer
+
+
+class BumpedThreadViewSet(
+ GenericViewSet, CreateModelMixin, DestroyModelMixin, ListModelMixin
+):
+ """
+ View providing CRUD (Minus the U) operations on threads to be bumped.
+
+ ## Routes
+ ### GET /bot/bumped-threads
+ Returns all BumpedThread items in the database.
+
+ #### Response format
+ >>> list[int]
+
+ #### Status codes
+ - 200: returned on success
+ - 401: returned if unauthenticated
+
+ ### GET /bot/bumped-threads/<thread_id:int>
+ Returns whether a specific BumpedThread exists in the database.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: returned if a BumpedThread with the given thread_id was not found.
+
+ ### POST /bot/bumped-threads
+ Adds a single BumpedThread item to the database.
+
+ #### Request body
+ >>> {
+ ... 'thread_id': int,
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/bumped-threads/<thread_id:int>
+ Deletes the BumpedThread item with the given `thread_id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a BumpedThread with the given `thread_id` does not exist
+ """
+
+ serializer_class = BumpedThreadSerializer
+ queryset = BumpedThread.objects.all()
+
+ def retrieve(self, request: Request, *args, **kwargs) -> Response:
+ """
+ DRF method for checking if the given BumpedThread exists.
+
+ Called by the Django Rest Framework in response to the corresponding HTTP request.
+ """
+ self.get_object()
+ return Response(status=204)
diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py
index 8a48ed1f..7f31292f 100644
--- a/pydis_site/apps/api/viewsets/bot/infraction.py
+++ b/pydis_site/apps/api/viewsets/bot/infraction.py
@@ -1,7 +1,9 @@
from datetime import datetime
+from django.db import IntegrityError
from django.db.models import QuerySet
from django.http.request import HttpRequest
+from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
@@ -183,20 +185,24 @@ class InfractionViewSet(
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
- )
+ expires_after_parsed = datetime.fromisoformat(filter_expires_after)
except ValueError:
raise ValidationError({'expires_after': ['failed to convert to datetime']})
+ additional_filters['expires_at__gte'] = timezone.make_aware(
+ expires_after_parsed,
+ timezone=timezone.utc,
+ )
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
- )
+ expires_before_parsed = datetime.fromisoformat(filter_expires_before)
except ValueError:
raise ValidationError({'expires_before': ['failed to convert to datetime']})
+ additional_filters['expires_at__lte'] = timezone.make_aware(
+ expires_before_parsed,
+ timezone=timezone.utc,
+ )
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']:
@@ -271,3 +277,28 @@ class InfractionViewSet(
"""
self.serializer_class = ExpandedInfractionSerializer
return self.partial_update(*args, **kwargs)
+
+ def create(self, request: HttpRequest, *args, **kwargs) -> Response:
+ """
+ Create an infraction for a target user.
+
+ Called by the Django Rest Framework in response to the corresponding HTTP request.
+ """
+ try:
+ return super().create(request, *args, **kwargs)
+ except IntegrityError as err:
+ # We need to use `__cause__` here, as Django reraises the internal
+ # UniqueViolation emitted by psycopg2 (which contains the attribute
+ # that we actually need)
+ #
+ # _meta is documented and mainly named that way to prevent
+ # name clashes: https://docs.djangoproject.com/en/dev/ref/models/meta/
+ if err.__cause__.diag.constraint_name == Infraction._meta.constraints[0].name:
+ raise ValidationError(
+ {
+ 'non_field_errors': [
+ 'This user already has an active infraction of this type.',
+ ]
+ }
+ )
+ raise # pragma: no cover - no other constraint to test with
diff --git a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
index 78f8c340..d0519e86 100644
--- a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
+++ b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
@@ -108,7 +108,7 @@ class OffTopicChannelNameViewSet(ModelViewSet):
'random_items': ["Must be a positive integer."]
})
- queryset = self.queryset.order_by('used', '?')[:random_count]
+ queryset = self.queryset.filter(active=True).order_by('used', '?')[:random_count]
# When any name is used in our listing then this means we reached end of round
# and we need to reset all other names `used` to False
@@ -133,7 +133,6 @@ class OffTopicChannelNameViewSet(ModelViewSet):
return Response(serialized.data)
params = {}
-
if active_param := request.query_params.get("active"):
params["active"] = active_param.lower() == "true"
diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py
index a867a80f..3318b2b9 100644
--- a/pydis_site/apps/api/viewsets/bot/user.py
+++ b/pydis_site/apps/api/viewsets/bot/user.py
@@ -2,6 +2,7 @@ import typing
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.decorators import action
from rest_framework.pagination import PageNumberPagination
@@ -77,6 +78,8 @@ class UserViewSet(ModelViewSet):
... }
#### Optional Query Parameters
+ - username: username to search for
+ - discriminator: discriminator to search for
- page_size: number of Users in one page, defaults to 10,000
- page: page number
@@ -233,6 +236,8 @@ class UserViewSet(ModelViewSet):
serializer_class = UserSerializer
queryset = User.objects.all().order_by("id")
pagination_class = UserListPagination
+ filter_backends = (DjangoFilterBackend,)
+ filter_fields = ('name', 'discriminator')
def get_serializer(self, *args, **kwargs) -> ModelSerializer:
"""Set Serializer many attribute to True if request body contains a list."""