diff options
author | 2019-07-12 13:14:30 +0200 | |
---|---|---|
committer | 2019-07-12 13:14:30 +0200 | |
commit | c78bcd5ec1f2b65a2017fb700d3a26ec657ee847 (patch) | |
tree | 5217be3fbc44c5d23c0ffe859cd3bd7db10ae1a9 | |
parent | Adding a tools page to the More menu (diff) | |
parent | Changing logic so an end_reason can never be specified when updating an activ... (diff) |
Merge pull request #220 from python-discord/django-api-bot-nomination-changes
Changing the way nominations work in the backend
-rw-r--r-- | pydis_site/apps/api/admin.py | 11 | ||||
-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, 716 insertions, 43 deletions
diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py index 3ae7f3c5..a5b75fa9 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -3,11 +3,11 @@ from django.contrib import admin from .models import ( BotSetting, DeletedMessage, DocumentationLink, Infraction, - MessageDeletionContext, OffTopicChannelName, - Role, SnakeFact, - SnakeIdiom, SnakeName, - SpecialSnake, Tag, - User + MessageDeletionContext, Nomination, + OffTopicChannelName, Role, + SnakeFact, SnakeIdiom, + SnakeName, SpecialSnake, + Tag, User ) @@ -16,6 +16,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(SnakeFact) 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 905a7f82..f1200d34 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -270,12 +270,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 4be153e1..d12f0862 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 62f5dd48..a9dacd68 100644 --- a/pydis_site/apps/api/viewsets/bot/nomination.py +++ b/pydis_site/apps/api/viewsets/bot/nomination.py @@ -1,25 +1,186 @@ +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 updating a Nomination. + 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. + + Called by the Django Rest Framework in response to the corresponding HTTP request. + """ for field in request.data: if field in self.frozen_fields: raise ValidationError({field: ['This field cannot be updated.']}) @@ -27,6 +188,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) |