diff options
| author | 2019-07-19 16:48:35 -0400 | |
|---|---|---|
| committer | 2019-07-19 16:48:35 -0400 | |
| commit | b1d768916ea9f9184f2482a86932fde95f806f41 (patch) | |
| tree | 92437b4a35aeb0ad21d9112d22f34ae1444a078a /pydis_site/apps | |
| parent | Pin pydocstyle<4.0 in pre-commit config to unbreak flake8-docstrings (diff) | |
| parent | Merge pull request #220 from python-discord/django-api-bot-nomination-changes (diff) | |
Merge branch 'django' into low-hanging-merge-fruit
Diffstat (limited to 'pydis_site/apps')
| -rw-r--r-- | pydis_site/apps/api/admin.py | 6 | ||||
| -rw-r--r-- | pydis_site/apps/api/migrations/0036_alter_nominations_api.py | 42 | ||||
| -rw-r--r-- | pydis_site/apps/api/migrations/0037_nomination_field_name_change.py | 23 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/nomination.py | 14 | ||||
| -rw-r--r-- | pydis_site/apps/api/serializers.py | 8 | ||||
| -rw-r--r-- | pydis_site/apps/api/tests/test_nominations.py | 446 | ||||
| -rw-r--r-- | pydis_site/apps/api/viewsets/bot/infraction.py | 9 | ||||
| -rw-r--r-- | pydis_site/apps/api/viewsets/bot/nomination.py | 206 | 
8 files changed, 715 insertions, 39 deletions
| diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py index b6021f7c..c3784317 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -3,8 +3,9 @@ from django.contrib import admin  from .models import (      BotSetting, DeletedMessage,      DocumentationLink, Infraction, -    MessageDeletionContext, OffTopicChannelName, -    Role, Tag, User +    MessageDeletionContext, Nomination, +    OffTopicChannelName, Role, +    Tag, User  ) @@ -13,6 +14,7 @@ admin.site.register(DeletedMessage)  admin.site.register(DocumentationLink)  admin.site.register(Infraction)  admin.site.register(MessageDeletionContext) +admin.site.register(Nomination)  admin.site.register(OffTopicChannelName)  admin.site.register(Role)  admin.site.register(Tag) diff --git a/pydis_site/apps/api/migrations/0036_alter_nominations_api.py b/pydis_site/apps/api/migrations/0036_alter_nominations_api.py new file mode 100644 index 00000000..f31be14c --- /dev/null +++ b/pydis_site/apps/api/migrations/0036_alter_nominations_api.py @@ -0,0 +1,42 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0035_create_table_log_entry'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='nomination', +            name='user', +            field=models.ForeignKey(help_text='The nominated user.', on_delete=django.db.models.deletion.CASCADE, related_name='nomination', to='api.User'), +        ), +        migrations.AlterField( +            model_name='nomination', +            name='author', +            field=models.ForeignKey(help_text='The staff member that nominated this user.', on_delete=django.db.models.deletion.CASCADE, related_name='nomination_set', to='api.User'), +        ), +        migrations.RenameField( +            model_name='nomination', +            old_name='author', +            new_name='actor', +        ), +        migrations.AddField( +            model_name='nomination', +            name='id', +            field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), +        ), +        migrations.AddField( +            model_name='nomination', +            name='unnominate_reason', +            field=models.TextField(default='', help_text='Why the nomination was ended.'), +        ), +        migrations.AddField( +            model_name='nomination', +            name='unwatched_at', +            field=models.DateTimeField(help_text='When the nomination was ended.', null=True), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0037_nomination_field_name_change.py b/pydis_site/apps/api/migrations/0037_nomination_field_name_change.py new file mode 100644 index 00000000..c5f2d0c5 --- /dev/null +++ b/pydis_site/apps/api/migrations/0037_nomination_field_name_change.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2 on 2019-06-28 18:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0036_alter_nominations_api'), +    ] + +    operations = [ +        migrations.RenameField( +            model_name='nomination', +            old_name='unnominate_reason', +            new_name='end_reason', +        ), +        migrations.RenameField( +            model_name='nomination', +            old_name='unwatched_at', +            new_name='ended_at', +        ), +    ] diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py index 5ebb9759..8a8f4d36 100644 --- a/pydis_site/apps/api/models/bot/nomination.py +++ b/pydis_site/apps/api/models/bot/nomination.py @@ -11,7 +11,7 @@ class Nomination(ModelReprMixin, models.Model):          default=True,          help_text="Whether this nomination is still relevant."      ) -    author = models.ForeignKey( +    actor = models.ForeignKey(          User,          on_delete=models.CASCADE,          help_text="The staff member that nominated this user.", @@ -20,14 +20,22 @@ class Nomination(ModelReprMixin, models.Model):      reason = models.TextField(          help_text="Why this user was nominated."      ) -    user = models.OneToOneField( +    user = models.ForeignKey(          User,          on_delete=models.CASCADE,          help_text="The nominated user.", -        primary_key=True,          related_name='nomination'      )      inserted_at = models.DateTimeField(          auto_now_add=True,          help_text="The creation date of this nomination."      ) +    end_reason = models.TextField( +        help_text="Why the nomination was ended.", +        default="" +    ) +    ended_at = models.DateTimeField( +        auto_now_add=False, +        help_text="When the nomination was ended.", +        null=True +    ) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index f5cfb1e6..51ad9276 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -224,12 +224,10 @@ class UserSerializer(BulkSerializerMixin, ModelSerializer):  class NominationSerializer(ModelSerializer):      """A class providing (de-)serialization of `Nomination` instances.""" -    author = PrimaryKeyRelatedField(queryset=User.objects.all()) -    user = PrimaryKeyRelatedField(queryset=User.objects.all()) -      class Meta:          """Metadata defined for the Django REST Framework."""          model = Nomination -        fields = ('active', 'author', 'reason', 'user', 'inserted_at') -        depth = 1 +        fields = ( +            'id', 'active', 'actor', 'reason', 'user', +            'inserted_at', 'end_reason', 'ended_at') diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py index 1f03d1b0..add5a7e4 100644 --- a/pydis_site/apps/api/tests/test_nominations.py +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -1,41 +1,449 @@ +from datetime import datetime as dt, timedelta, timezone +  from django_hosts.resolvers import reverse  from .base import APISubdomainTestCase  from ..models import Nomination, User +class CreationTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        cls.user = User.objects.create( +            id=1234, +            name='joe dart', +            discriminator=1111, +            avatar_hash=None +        ) + +    def test_accepts_valid_data(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'actor': self.user.id, +            'reason': 'Joe Dart on Fender Bass', +            'user': self.user.id, +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 201) + +        nomination = Nomination.objects.get(id=response.json()['id']) +        self.assertAlmostEqual( +            nomination.inserted_at, +            dt.now(timezone.utc), +            delta=timedelta(seconds=2) +        ) +        self.assertEqual(nomination.user.id, data['user']) +        self.assertEqual(nomination.actor.id, data['actor']) +        self.assertEqual(nomination.reason, data['reason']) +        self.assertEqual(nomination.active, True) + +    def test_returns_400_on_second_active_nomination(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'actor': self.user.id, +            'reason': 'Joe Dart on Fender Bass', +            'user': self.user.id, +        } + +        response1 = self.client.post(url, data=data) +        self.assertEqual(response1.status_code, 201) + +        response2 = self.client.post(url, data=data) +        self.assertEqual(response2.status_code, 400) +        self.assertEqual(response2.json(), { +            'active': ['There can only be one active nomination.'] +        }) + +    def test_returns_400_for_missing_user(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'actor': self.user.id, +            'reason': 'Joe Dart on Fender Bass', +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'user': ['This field is required.'] +        }) + +    def test_returns_400_for_missing_actor(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'user': self.user.id, +            'reason': 'Joe Dart on Fender Bass', +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'actor': ['This field is required.'] +        }) + +    def test_returns_400_for_missing_reason(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'user': self.user.id, +            'actor': self.user.id, +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'reason': ['This field is required.'] +        }) + +    def test_returns_400_for_bad_user(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'user': 1024, +            'reason': 'Joe Dart on Fender Bass', +            'actor': self.user.id, +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'user': ['Invalid pk "1024" - object does not exist.'] +        }) + +    def test_returns_400_for_bad_actor(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'user': self.user.id, +            'reason': 'Joe Dart on Fender Bass', +            'actor': 1024, +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'actor': ['Invalid pk "1024" - object does not exist.'] +        }) + +    def test_returns_400_for_end_reason_at_creation(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'user': self.user.id, +            'reason': 'Joe Dart on Fender Bass', +            'actor': self.user.id, +            'end_reason': "Joe Dart on the Joe Dart Bass" +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'end_reason': ['This field cannot be set at creation.'] +        }) + +    def test_returns_400_for_ended_at_at_creation(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'user': self.user.id, +            'reason': 'Joe Dart on Fender Bass', +            'actor': self.user.id, +            'ended_at': "Joe Dart on the Joe Dart Bass" +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'ended_at': ['This field cannot be set at creation.'] +        }) + +    def test_returns_400_for_inserted_at_at_creation(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'user': self.user.id, +            'reason': 'Joe Dart on Fender Bass', +            'actor': self.user.id, +            'inserted_at': "Joe Dart on the Joe Dart Bass" +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'inserted_at': ['This field cannot be set at creation.'] +        }) + +    def test_returns_400_for_active_at_creation(self): +        url = reverse('bot:nomination-list', host='api') +        data = { +            'user': self.user.id, +            'reason': 'Joe Dart on Fender Bass', +            'actor': self.user.id, +            'active': False +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'active': ['This field cannot be set at creation.'] +        }) + +  class NominationTests(APISubdomainTestCase):      @classmethod      def setUpTestData(cls):  # noqa -        cls.author = User.objects.create( -            id=5152, -            name='Ro Bert', -            discriminator=256, +        cls.user = User.objects.create( +            id=1234, +            name='joe dart', +            discriminator=1111,              avatar_hash=None          ) -        cls.user = cls.author -        cls.nomination = Nomination.objects.create( -            author=cls.author, -            reason="he's good", -            user=cls.author +        cls.active_nomination = Nomination.objects.create( +            user=cls.user, +            actor=cls.user, +            reason="He's pretty funky" +        ) +        cls.inactive_nomination = Nomination.objects.create( +            user=cls.user, +            actor=cls.user, +            reason="He's pretty funky", +            active=False, +            end_reason="His neck couldn't hold the funk", +            ended_at="5018-11-20T15:52:00+00:00"          ) -    def test_returns_400_on_attempt_to_update_frozen_field(self): -        url = reverse('bot:nomination-detail', args=(self.user.id,), host='api') -        response = self.client.put( -            url, -            data={'inserted_at': 'something bad'} +    def test_returns_200_update_reason_on_active(self): +        url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api') +        data = { +            'reason': "He's one funky duck" +        } + +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 200) + +        nomination = Nomination.objects.get(id=response.json()['id']) +        self.assertEqual(nomination.reason, data['reason']) + +    def test_returns_400_on_frozen_field_update(self): +        url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api') +        data = { +            'user': "Theo Katzman" +        } + +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'user': ['This field cannot be updated.'] +        }) + +    def test_returns_400_update_end_reason_on_active(self): +        url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api') +        data = { +            'end_reason': 'He started playing jazz' +        } + +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'end_reason': ["An active nomination can't have an end reason."] +        }) + +    def test_returns_200_update_reason_on_inactive(self): +        url = reverse('bot:nomination-detail', args=(self.inactive_nomination.id,), host='api') +        data = { +            'reason': "He's one funky duck" +        } + +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 200) + +        nomination = Nomination.objects.get(id=response.json()['id']) +        self.assertEqual(nomination.reason, data['reason']) + +    def test_returns_200_update_end_reason_on_inactive(self): +        url = reverse('bot:nomination-detail', args=(self.inactive_nomination.id,), host='api') +        data = { +            'end_reason': 'He started playing jazz' +        } + +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 200) + +        nomination = Nomination.objects.get(id=response.json()['id']) +        self.assertEqual(nomination.end_reason, data['end_reason']) + +    def test_returns_200_on_valid_end_nomination(self): +        url = reverse( +            'bot:nomination-detail', +            args=(self.active_nomination.id,), +            host='api' +        ) +        data = { +            'active': False, +            'end_reason': 'He started playing jazz' +        } +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 200) + +        nomination = Nomination.objects.get(id=response.json()['id']) + +        self.assertAlmostEqual( +            nomination.ended_at, +            dt.now(timezone.utc), +            delta=timedelta(seconds=2)          ) +        self.assertFalse(nomination.active) +        self.assertEqual(nomination.end_reason, data['end_reason']) + +    def test_returns_400_on_invalid_field_end_nomination(self): +        url = reverse( +            'bot:nomination-detail', +            args=(self.active_nomination.id,), +            host='api' +        ) +        data = { +            'active': False, +            'reason': 'Why does a whale have feet?', +        } +        response = self.client.patch(url, data=data)          self.assertEqual(response.status_code, 400)          self.assertEqual(response.json(), { -            'inserted_at': ['This field cannot be updated.'] +            'reason': ['This field cannot be set when ending a nomination.']          }) -    def test_returns_200_on_successful_update(self): -        url = reverse('bot:nomination-detail', args=(self.user.id,), host='api') -        response = self.client.patch( +    def test_returns_400_on_missing_end_reason_end_nomination(self): +        url = reverse( +            'bot:nomination-detail', +            args=(self.active_nomination.id,), +            host='api' +        ) +        data = { +            'active': False, +        } + +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'end_reason': ['This field is required when ending a nomination.'] +        }) + +    def test_returns_400_on_invalid_use_of_active(self): +        url = reverse( +            'bot:nomination-detail', +            args=(self.inactive_nomination.id,), +            host='api' +        ) +        data = { +            'active': False, +        } + +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'active': ['This field can only be used to end a nomination'] +        }) + +    def test_returns_404_on_get_unknown_nomination(self): +        url = reverse( +            'bot:nomination-detail', +            args=(9999,), +            host='api' +        ) + +        response = self.client.get(url, data={}) +        self.assertEqual(response.status_code, 404) +        self.assertEqual(response.json(), { +            "detail": "Not found." +        }) + +    def test_returns_404_on_patch_unknown_nomination(self): +        url = reverse( +            'bot:nomination-detail', +            args=(9999,), +            host='api' +        ) + +        response = self.client.patch(url, data={}) +        self.assertEqual(response.status_code, 404) +        self.assertEqual(response.json(), { +            "detail": "Not found." +        }) + +    def test_returns_405_on_list_put(self): +        url = reverse('bot:nomination-list', host='api') + +        response = self.client.put(url, data={}) +        self.assertEqual(response.status_code, 405) +        self.assertEqual(response.json(), { +            "detail": "Method \"PUT\" not allowed." +        }) + +    def test_returns_405_on_list_patch(self): +        url = reverse('bot:nomination-list', host='api') + +        response = self.client.patch(url, data={}) +        self.assertEqual(response.status_code, 405) +        self.assertEqual(response.json(), { +            "detail": "Method \"PATCH\" not allowed." +        }) + +    def test_returns_405_on_list_delete(self): +        url = reverse('bot:nomination-list', host='api') + +        response = self.client.delete(url, data={}) +        self.assertEqual(response.status_code, 405) +        self.assertEqual(response.json(), { +            "detail": "Method \"DELETE\" not allowed." +        }) + +    def test_returns_405_on_detail_post(self): +        url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api') + +        response = self.client.post(url, data={}) +        self.assertEqual(response.status_code, 405) +        self.assertEqual(response.json(), { +            "detail": "Method \"POST\" not allowed." +        }) + +    def test_returns_405_on_detail_delete(self): +        url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api') + +        response = self.client.delete(url, data={}) +        self.assertEqual(response.status_code, 405) +        self.assertEqual(response.json(), { +            "detail": "Method \"DELETE\" not allowed." +        }) + +    def test_returns_405_on_detail_put(self): +        url = reverse('bot:nomination-detail', args=(self.active_nomination.id,), host='api') + +        response = self.client.put(url, data={}) +        self.assertEqual(response.status_code, 405) +        self.assertEqual(response.json(), { +            "detail": "Method \"PUT\" not allowed." +        }) + +    def test_filter_returns_0_objects_unknown_user__id(self): +        url = reverse('bot:nomination-list', host='api') + +        response = self.client.get(              url, -            data={'reason': 'there are many like it, but this test is mine'} +            data={ +                "user__id": 99998888 +            }          ) +          self.assertEqual(response.status_code, 200) +        infractions = response.json() + +        self.assertEqual(len(infractions), 0) + +    def test_filter_returns_2_objects_for_testdata(self): +        url = reverse('bot:nomination-list', host='api') + +        response = self.client.get( +            url, +            data={ +                "user__id": self.user.id +            } +        ) + +        self.assertEqual(response.status_code, 200) +        infractions = response.json() + +        self.assertEqual(len(infractions), 2) diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index e1f9c431..c471ca2c 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -1,7 +1,7 @@  from django_filters.rest_framework import DjangoFilterBackend  from rest_framework.decorators import action  from rest_framework.exceptions import ValidationError -from rest_framework.filters import SearchFilter +from rest_framework.filters import OrderingFilter, SearchFilter  from rest_framework.mixins import (      CreateModelMixin,      ListModelMixin, @@ -28,11 +28,12 @@ class InfractionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge      #### Query parameters      - **active** `bool`: whether the infraction is still active -    - **actor** `int`: snowflake of the user which applied the infraction +    - **actor__id** `int`: snowflake of the user which applied the infraction      - **hidden** `bool`: whether the infraction is a shadow infraction      - **search** `str`: regular expression applied to the infraction's reason      - **type** `str`: the type of the infraction -    - **user** `int`: snowflake of the user to which the infraction was applied +    - **user__id** `int`: snowflake of the user to which the infraction was applied +    - **ordering** `str`: comma-separated sequence of fields to order the returned results      Invalid query parameters are ignored. @@ -117,7 +118,7 @@ class InfractionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge      serializer_class = InfractionSerializer      queryset = Infraction.objects.all() -    filter_backends = (DjangoFilterBackend, SearchFilter) +    filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter)      filter_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type')      search_fields = ('$reason',)      frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') diff --git a/pydis_site/apps/api/viewsets/bot/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py index dad33056..8d551697 100644 --- a/pydis_site/apps/api/viewsets/bot/nomination.py +++ b/pydis_site/apps/api/viewsets/bot/nomination.py @@ -1,19 +1,182 @@ +from collections import ChainMap + +from django.utils import timezone +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework import status  from rest_framework.exceptions import ValidationError +from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.mixins import ( +    CreateModelMixin, +    ListModelMixin, +    RetrieveModelMixin, +)  from rest_framework.response import Response -from rest_framework.viewsets import ModelViewSet +from rest_framework.viewsets import GenericViewSet  from pydis_site.apps.api.models.bot import Nomination  from pydis_site.apps.api.serializers import NominationSerializer -class NominationViewSet(ModelViewSet): -    """View providing CRUD operations on helper nominations done through the bot.""" +class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet): +    """ +    View providing CRUD operations on helper nominations done through the bot. + +    ## Routes +    ### GET /bot/nominations +    Retrieve all nominations. +    May be filtered and ordered by the query parameters. + +    #### Query parameters +    - **active** `bool`: whether the nomination is still active +    - **actor__id** `int`: snowflake of the user who nominated the user +    - **user__id** `int`: snowflake of the user who received the nomination +    - **ordering** `str`: comma-separated sequence of fields to order the returned results + +    Invalid query parameters are ignored. + +    #### Response format +    >>> [ +    ...     { +    ...         'id': 1, +    ...         'active': false, +    ...         'actor': 336843820513755157, +    ...         'reason': 'They know how to explain difficult concepts', +    ...         'user': 336843820513755157, +    ...         'inserted_at': '2019-04-25T14:02:37.775587Z', +    ...         'end_reason': 'They were helpered after a staff-vote', +    ...         'ended_at': '2019-04-26T15:12:22.123587Z' +    ...     } +    ... ] + +    #### Status codes +    - 200: returned on success + +    ### GET /bot/nominations/<id:int> +    Retrieve a single nomination by ID. + +    ### Response format +    >>> { +    ...     'id': 1, +    ...     'active': true, +    ...     'actor': 336843820513755157, +    ...     'reason': 'They know how to explain difficult concepts', +    ...     'user': 336843820513755157, +    ...     'inserted_at': '2019-04-25T14:02:37.775587Z', +    ...     'end_reason': 'They were helpered after a staff-vote', +    ...     'ended_at': '2019-04-26T15:12:22.123587Z' +    ... } + +    ### Status codes +    - 200: returned on success +    - 404: returned if a nomination with the given `id` could not be found + +    ### POST /bot/nominations +    Create a new, active nomination returns the created nominations. +    The `user`, `reason` and `actor` fields are required and the `user` +    and `actor` need to know by the site. Providing other valid fields +    is not allowed and invalid fields are ignored. A `user` is only +    allowed one active nomination at a time. + +    #### Request body +    >>> { +    ...     'actor': 409107086526644234 +    ...     'reason': 'He would make a great helper', +    ...     'user': 409107086526644234 +    ... } + +    #### Response format +    See `GET /bot/nominations/<id:int>` + +    #### Status codes +    - 201: returned on success +    - 400: returned on failure for one of the following reasons: +        - A user already has an active nomination; +        - The `user` or `actor` are unknown to the site; +        - The request contained a field that cannot be set at creation. + +    ### PATCH /bot/nominations/<id:int> +    Update or end the nomination with the given `id` and return the updated nomination. + +    The PATCH route can be used for three distinct operations: +    1. Updating the `reason` of `active` nomination; +    2. Ending an `active` nomination; +    3. Updating the `end_reason` or `reason` field of an `inactive` nomination. + +    While the response format and status codes are the same for all three operations (see +    below), the request bodies vary depending on the operation. For all operations it holds +    that providing other valid fields is not allowed and invalid fields are ignored. + +    ### 1. Updating the `reason` of `active` nomination + +    #### Request body +    >>> { +    ...     'reason': 'He would make a great helper', +    ... } + +    #### Response format +    See `GET /bot/nominations/<id:int>` + +    #### Status codes +    - 200: returned on success +    - 400: if a field in the request body is invalid or disallowed +    - 404: if an infraction with the given `id` could not be found + +    ### 2. Ending an `active` nomination + +    #### Request body +    >>> { +    ...     'active': False +    ...     'end_reason': 'They've been added to the Helpers team', +    ... } + +    See operation 1 for the response format and status codes. + +    ### 3. Updating the `end_reason` or `reason` field of an `inactive` nomination. + +    #### Request body +    >>> { +    ...     'reason': 'Updated reason for this nomination', +    ...     'end_reason': 'Updated end_reason for this nomination', +    ... } + +    Note: The request body may contain either or both fields. + +    See operation 1 for the response format and status codes. +    """      serializer_class = NominationSerializer -    queryset = Nomination.objects.prefetch_related('author', 'user') -    frozen_fields = ('author', 'inserted_at', 'user') +    queryset = Nomination.objects.all() +    filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter) +    filter_fields = ('user__id', 'actor__id', 'active') +    frozen_fields = ('id', 'actor', 'inserted_at', 'user', 'ended_at') +    frozen_on_create = ('ended_at', 'end_reason', 'active', 'inserted_at') -    def update(self, request, *args, **kwargs): +    def create(self, request, *args, **kwargs): +        """ +        DRF method for creating a Nomination. + +        Called by the Django Rest Framework in response to the corresponding HTTP request. +        """ +        for field in request.data: +            if field in self.frozen_on_create: +                raise ValidationError({field: ['This field cannot be set at creation.']}) + +        user_id = request.data.get("user") +        if Nomination.objects.filter(active=True, user__id=user_id).exists(): +            raise ValidationError({'active': ['There can only be one active nomination.']}) + +        serializer = self.get_serializer( +            data=ChainMap( +                request.data, +                {"active": True} +            ) +        ) +        serializer.is_valid(raise_exception=True) +        self.perform_create(serializer) +        headers = self.get_success_headers(serializer.data) +        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + +    def partial_update(self, request, *args, **kwargs):          """          DRF method for updating a Nomination. @@ -26,6 +189,37 @@ class NominationViewSet(ModelViewSet):          instance = self.get_object()          serializer = self.get_serializer(instance, data=request.data, partial=True)          serializer.is_valid(raise_exception=True) + +        data = serializer.validated_data + +        # There are three distinct PATCH scenarios we need to validate. +        if instance.active and 'active' not in data: +            # 1. We're updating an active nomination without ending it. +            if 'end_reason' in data: +                raise ValidationError( +                    {'end_reason': ["An active nomination can't have an end reason."]} +                ) + +        elif instance.active and not data['active']: +            # 2. We're ending an active nomination. +            if 'reason' in data: +                raise ValidationError( +                    {'reason': ['This field cannot be set when ending a nomination.']} +                ) + +            if 'end_reason' not in request.data: +                raise ValidationError( +                    {'end_reason': ['This field is required when ending a nomination.']} +                ) + +            instance.ended_at = timezone.now() + +        elif 'active' in data: +            # 3. The `active` field is only allowed when ending a nomination. +            raise ValidationError( +                {'active': ['This field can only be used to end a nomination']} +            ) +          serializer.save()          return Response(serializer.data) | 
