aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Pipfile1
-rw-r--r--Pipfile.lock21
-rw-r--r--api/serializers.py20
-rw-r--r--api/tests/test_members.py118
-rw-r--r--api/urls.py3
-rw-r--r--api/viewsets.py128
6 files changed, 273 insertions, 18 deletions
diff --git a/Pipfile b/Pipfile
index d3b340ab..08eb6813 100644
--- a/Pipfile
+++ b/Pipfile
@@ -9,6 +9,7 @@ django-hosts = "*"
django-environ = "*"
"psycopg2-binary" = "*"
djangorestframework = "*"
+djangorestframework-bulk = "*"
[dev-packages]
"flake8" = "*"
diff --git a/Pipfile.lock b/Pipfile.lock
index 2feb2cb4..5474f64e 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "cc8270991baaef694efa7678b651fb4a7b97274cd2acb0a8bd41b7291a1203b1"
+ "sha256": "53ce8e461af483f9f6e80067bf7dc289780048278a90432dcd91b32ba943d970"
},
"pipfile-spec": 6,
"requires": {
@@ -18,11 +18,11 @@
"default": {
"django": {
"hashes": [
- "sha256:7f246078d5a546f63c28fc03ce71f4d7a23677ce42109219c24c9ffb28416137",
- "sha256:ea50d85709708621d956187c6b61d9f9ce155007b496dd914fdb35db8d790aec"
+ "sha256:04f2e423f2e60943c02bd2959174b844f7d1bcd19eabb7f8e4282999958021fd",
+ "sha256:e1cc1cd6b658aa4e052f5f2b148bfda08091d7c3558529708342e37e4e33f72c"
],
"index": "pypi",
- "version": "==2.1"
+ "version": "==2.1.1"
},
"django-environ": {
"hashes": [
@@ -48,6 +48,13 @@
"index": "pypi",
"version": "==3.8.2"
},
+ "djangorestframework-bulk": {
+ "hashes": [
+ "sha256:39230d8379acebd86d313df6c9150cafecb636eae1d097c30a26389ab9fee5b1"
+ ],
+ "index": "pypi",
+ "version": "==0.2.1"
+ },
"psycopg2-binary": {
"hashes": [
"sha256:04afb59bbbd2eab3148e6816beddc74348078b8c02a1113ea7f7822f5be4afe3",
@@ -95,10 +102,10 @@
"develop": {
"attrs": {
"hashes": [
- "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265",
- "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b"
+ "sha256:10cbf6e27dbce8c30807caf056c8eb50917e0eaafe86347671b57254006c3e69",
+ "sha256:ca4be454458f9dec299268d472aaa5a11f67a4ff70093396e1ceae9c76cf4bbb"
],
- "version": "==18.1.0"
+ "version": "==18.2.0"
},
"bandit": {
"hashes": [
diff --git a/api/serializers.py b/api/serializers.py
index 7a3a95e3..dc4d4a78 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -1,6 +1,7 @@
-from rest_framework.serializers import ModelSerializer
+from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField
+from rest_framework_bulk import BulkSerializerMixin
-from .models import DocumentationLink, OffTopicChannelName, SnakeName
+from .models import DocumentationLink, Member, OffTopicChannelName, Role, SnakeName
class DocumentationLinkSerializer(ModelSerializer):
@@ -22,3 +23,18 @@ class SnakeNameSerializer(ModelSerializer):
class Meta:
model = SnakeName
fields = ('name', 'scientific')
+
+
+class RoleSerializer(ModelSerializer):
+ class Meta:
+ model = Role
+ fields = ('id', 'name', 'colour', 'permissions')
+
+
+class MemberSerializer(BulkSerializerMixin, ModelSerializer):
+ roles = PrimaryKeyRelatedField(many=True, queryset=Role.objects.all())
+
+ class Meta:
+ model = Member
+ fields = ('id', 'avatar_hash', 'name', 'discriminator', 'roles')
+ depth = 1
diff --git a/api/tests/test_members.py b/api/tests/test_members.py
new file mode 100644
index 00000000..60ad8460
--- /dev/null
+++ b/api/tests/test_members.py
@@ -0,0 +1,118 @@
+from django.test import TestCase
+from django_hosts.resolvers import reverse
+
+from .base import APISubdomainTestCase
+from ..models import Member, Role
+
+
+class UnauthedDocumentationLinkAPITests(APISubdomainTestCase):
+ def setUp(self):
+ super().setUp()
+ self.client.force_authenticate(user=None)
+
+ def test_detail_lookup_returns_401(self):
+ url = reverse('bot:member-detail', args=('whatever',), host='api')
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_list_returns_401(self):
+ url = reverse('bot:member-list', host='api')
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_create_returns_401(self):
+ url = reverse('bot:member-list', host='api')
+ response = self.client.post(url, data={'hi': 'there'})
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_delete_returns_401(self):
+ url = reverse('bot:member-detail', args=('whatever',), host='api')
+ response = self.client.delete(url)
+
+ self.assertEqual(response.status_code, 401)
+
+
+class CreationTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.role = Role.objects.create(
+ id=5,
+ name="Test role pls ignore",
+ colour=2,
+ permissions=0b01010010101
+ )
+
+ def test_accepts_valid_data(self):
+ url = reverse('bot:member-list', host='api')
+ data = {
+ 'id': 42,
+ 'avatar_hash': "validavatarhashiswear",
+ 'name': "Test",
+ 'discriminator': 42,
+ 'roles': [
+ self.role.id
+ ]
+ }
+
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(response.json(), data)
+
+ user = Member.objects.get(id=42)
+ self.assertEqual(user.avatar_hash, data['avatar_hash'])
+ self.assertEqual(user.name, data['name'])
+ self.assertEqual(user.discriminator, data['discriminator'])
+
+ def test_supports_multi_creation(self):
+ url = reverse('bot:member-list', host='api')
+ data = [
+ {
+ 'id': 5,
+ 'avatar_hash': "hahayes",
+ 'name': "test man",
+ 'discriminator': 42,
+ 'roles': [
+ self.role.id
+ ]
+ },
+ {
+ 'id': 8,
+ 'avatar_hash': "maybenot",
+ 'name': "another test man",
+ 'discriminator': 555,
+ 'roles': []
+ }
+ ]
+
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(response.json(), data)
+
+ def test_returns_400_for_unknown_role_id(self):
+ url = reverse('bot:member-list', host='api')
+ data = {
+ 'id': 5,
+ 'avatar_hash': "hahayes",
+ 'name': "test man",
+ 'discriminator': 42,
+ 'roles': [
+ 190810291
+ ]
+ }
+
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_returns_400_for_bad_data(self):
+ url = reverse('bot:member-list', host='api')
+ data = {
+ 'id': True,
+ 'avatar_hash': 1902831,
+ 'discriminator': "totally!"
+ }
+
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 400)
diff --git a/api/urls.py b/api/urls.py
index 121e941c..dd7cdfce 100644
--- a/api/urls.py
+++ b/api/urls.py
@@ -2,13 +2,14 @@ from django.urls import include, path
from rest_framework.routers import SimpleRouter
from .views import HealthcheckView
-from .viewsets import DocumentationLinkViewSet, OffTopicChannelNameViewSet, SnakeNameViewSet
+from .viewsets import DocumentationLinkViewSet, OffTopicChannelNameViewSet, MemberViewSet, SnakeNameViewSet
# http://www.django-rest-framework.org/api-guide/routers/#simplerouter
bot_router = SimpleRouter(trailing_slash=False)
bot_router.register('documentation-links', DocumentationLinkViewSet)
bot_router.register('off-topic-channel-names', OffTopicChannelNameViewSet, base_name='offtopicchannelname')
+bot_router.register('members', MemberViewSet)
bot_router.register('snake-names', SnakeNameViewSet, base_name='snakename')
diff --git a/api/viewsets.py b/api/viewsets.py
index 82f8aea9..f3d530f0 100644
--- a/api/viewsets.py
+++ b/api/viewsets.py
@@ -3,10 +3,11 @@ from rest_framework.exceptions import ParseError
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
from rest_framework.response import Response
from rest_framework.status import HTTP_201_CREATED
-from rest_framework.viewsets import GenericViewSet, ViewSet
+from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
+from rest_framework_bulk import BulkCreateModelMixin
-from .models import DocumentationLink, OffTopicChannelName, SnakeName
-from .serializers import DocumentationLinkSerializer, OffTopicChannelNameSerializer, SnakeNameSerializer
+from .models import DocumentationLink, OffTopicChannelName, Member, SnakeName
+from .serializers import DocumentationLinkSerializer, OffTopicChannelNameSerializer, MemberSerializer, SnakeNameSerializer
class DocumentationLinkViewSet(CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet):
@@ -119,11 +120,6 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet):
def get_object(self):
queryset = self.get_queryset()
- if self.lookup_field not in self.kwargs:
- raise ParseError(detail={
- 'name': ["This query parameter is required."]
- })
-
name = self.kwargs[self.lookup_field]
return get_object_or_404(queryset, name=name)
@@ -219,3 +215,119 @@ class SnakeNameViewSet(ViewSet):
return Response(body)
return Response({})
+
+
+class MemberViewSet(BulkCreateModelMixin, ModelViewSet):
+ """
+ View providing CRUD operations on our Discord server's members through the bot.
+
+ ## Routes
+ ### GET /bot/members
+ Returns all members currently known.
+
+ #### Response format
+ >>> [
+ ... {
+ ... 'id': 409107086526644234,
+ ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb",
+ ... 'name': "Python",
+ ... 'discriminator': 4329,
+ ... 'roles': [
+ ... 352427296948486144,
+ ... 270988689419665409,
+ ... 277546923144249364,
+ ... 458226699344019457
+ ... ]
+ ... }
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ### GET /bot/members/<snowflake:int>
+ Gets a single member by ID.
+
+ #### Response format
+ >>> {
+ ... 'id': 409107086526644234,
+ ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb",
+ ... 'name': "Python",
+ ... 'discriminator': 4329,
+ ... 'roles': [
+ ... 352427296948486144,
+ ... 270988689419665409,
+ ... 277546923144249364,
+ ... 458226699344019457
+ ... ]
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: if a member with the given `snowflake` could not be found
+
+ ### POST /bot/members
+ Adds a single or multiple new members.
+ The roles attached to the member(s) must be roles known by the site.
+
+ #### Request body
+ >>> {
+ ... 'id': int,
+ ... 'avatar': str,
+ ... 'name': str,
+ ... 'discriminator': int,
+ ... 'roles': List[int]
+ ... }
+
+ Alternatively, request members can be POSTed as a list of above objects,
+ in which case multiple members will be created at once.
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if one of the given roles does not exist, or one of the given fields is invalid
+
+ ### PUT /bot/members/<snowflake:int>
+ Update the member with the given `snowflake`.
+ All fields in the request body are required.
+
+ #### Request body
+ >>> {
+ ... 'id': int,
+ ... 'avatar': str,
+ ... 'name': str,
+ ... 'discriminator': int,
+ ... 'roles': List[int]
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if the request body was invalid, see response body for details
+ - 404: if the member with the given `snowflake` could not be found
+
+ ### PATCH /bot/members/<snowflake:int>
+ Update the member with the given `snowflake`.
+ All fields in the request body are optional.
+
+ #### Request body
+ >>> {
+ ... 'id': int,
+ ... 'avatar': str,
+ ... 'name': str,
+ ... 'discriminator': int,
+ ... 'roles': List[int]
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if the request body was invalid, see response body for details
+ - 404: if the member with the given `snowflake` could not be found
+
+ ### DELETE /bot/members/<snowflake:int>
+ Deletes the member with the given `snowflake`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a member with the given `snowflake` does not exist
+ """
+
+ serializer_class = MemberSerializer
+ queryset = Member.objects.all()