diff options
Diffstat (limited to 'pydis_site/apps/api')
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."""  |