aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site
diff options
context:
space:
mode:
authorGravatar sco1 <[email protected]>2019-07-19 16:48:35 -0400
committerGravatar sco1 <[email protected]>2019-07-19 16:48:35 -0400
commitb1d768916ea9f9184f2482a86932fde95f806f41 (patch)
tree92437b4a35aeb0ad21d9112d22f34ae1444a078a /pydis_site
parentPin pydocstyle<4.0 in pre-commit config to unbreak flake8-docstrings (diff)
parentMerge 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')
-rw-r--r--pydis_site/apps/api/admin.py6
-rw-r--r--pydis_site/apps/api/migrations/0036_alter_nominations_api.py42
-rw-r--r--pydis_site/apps/api/migrations/0037_nomination_field_name_change.py23
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py14
-rw-r--r--pydis_site/apps/api/serializers.py8
-rw-r--r--pydis_site/apps/api/tests/test_nominations.py446
-rw-r--r--pydis_site/apps/api/viewsets/bot/infraction.py9
-rw-r--r--pydis_site/apps/api/viewsets/bot/nomination.py206
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)