diff options
author | 2018-09-01 23:04:05 +0200 | |
---|---|---|
committer | 2018-09-01 23:04:05 +0200 | |
commit | 96957d276b091f32ce92a7eb44a5e666fa773bb9 (patch) | |
tree | e616618bba54b67eeff0b78b61b46490188cc6d6 /api | |
parent | Add tests for untested branches. (diff) | |
parent | Add the member viewset. (diff) |
Merge branch 'django+add-users-api' into django
Diffstat (limited to 'api')
-rw-r--r-- | api/migrations/0004_role.py | 23 | ||||
-rw-r--r-- | api/migrations/0005_user.py | 38 | ||||
-rw-r--r-- | api/models.py | 83 | ||||
-rw-r--r-- | api/serializers.py | 20 | ||||
-rw-r--r-- | api/tests/test_members.py | 118 | ||||
-rw-r--r-- | api/urls.py | 3 | ||||
-rw-r--r-- | api/viewsets.py | 128 |
7 files changed, 401 insertions, 12 deletions
diff --git a/api/migrations/0004_role.py b/api/migrations/0004_role.py new file mode 100644 index 00000000..0a6b6c43 --- /dev/null +++ b/api/migrations/0004_role.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2018-09-01 19:54 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_offtopicchannelname'), + ] + + operations = [ + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.BigIntegerField(help_text="The role's ID, taken from Discord.", primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')])), + ('name', models.CharField(help_text="The role's name, taken from Discord.", max_length=100)), + ('colour', models.IntegerField(help_text='The integer value of the colour of this role from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Colour hex cannot be negative.')])), + ('permissions', models.IntegerField(help_text='The integer value of the permission bitset of this role from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role permissions cannot be negative.'), django.core.validators.MaxValueValidator(limit_value=8589934592, message='Role permission bitset exceeds value of having all permissions')])), + ], + ), + ] diff --git a/api/migrations/0005_user.py b/api/migrations/0005_user.py new file mode 100644 index 00000000..a771119c --- /dev/null +++ b/api/migrations/0005_user.py @@ -0,0 +1,38 @@ +# Generated by Django 2.1 on 2018-09-01 20:02 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_role'), + ] + + operations = [ + migrations.CreateModel( + name='Member', + fields=[ + ('id', models.BigIntegerField(help_text='The ID of this user, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='User IDs cannot be negative.')])), + ('name', models.CharField(help_text='The username, taken from Discord.', max_length=32)), + ('discriminator', models.PositiveSmallIntegerField(help_text='The discriminator of this user, taken from Discord.', validators=[django.core.validators.MaxValueValidator(limit_value=9999, message='Discriminators may not exceed `9999`.')])), + ('avatar_hash', models.CharField(help_text="The user's avatar hash, taken from Discord. Null if the user does not have any custom avatar.", max_length=100, null=True)), + ], + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigIntegerField(help_text='The role ID, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')]), + ), + migrations.AlterField( + model_name='role', + name='name', + field=models.CharField(help_text='The role name, taken from Discord.', max_length=100), + ), + migrations.AddField( + model_name='member', + name='roles', + field=models.ManyToManyField(help_text='Any roles this user has on our server.', to='api.Role'), + ), + ] diff --git a/api/models.py b/api/models.py index 7f4175af..d3986ba5 100644 --- a/api/models.py +++ b/api/models.py @@ -1,4 +1,4 @@ -from django.core.validators import RegexValidator +from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator from django.db import models @@ -23,3 +23,84 @@ class SnakeName(models.Model): name = models.CharField(primary_key=True, max_length=100) scientific = models.CharField(max_length=150) + + +class Role(models.Model): + """A role on our Discord server.""" + + id = models.BigIntegerField( + primary_key=True, + validators=( + MinValueValidator( + limit_value=0, + message="Role IDs cannot be negative." + ), + ), + help_text="The role ID, taken from Discord." + ) + name = models.CharField( + max_length=100, + help_text="The role name, taken from Discord." + ) + colour = models.IntegerField( + validators=( + MinValueValidator( + limit_value=0, + message="Colour hex cannot be negative." + ), + ), + help_text="The integer value of the colour of this role from Discord." + ) + permissions = models.IntegerField( + validators=( + MinValueValidator( + limit_value=0, + message="Role permissions cannot be negative." + ), + MaxValueValidator( + limit_value=2 << 32, + message="Role permission bitset exceeds value of having all permissions" + ) + ), + help_text="The integer value of the permission bitset of this role from Discord." + ) + + +class Member(models.Model): + """A member of our Discord server.""" + + id = models.BigIntegerField( + primary_key=True, + validators=( + MinValueValidator( + limit_value=0, + message="User IDs cannot be negative." + ), + ), + help_text="The ID of this user, taken from Discord." + ) + name = models.CharField( + max_length=32, + help_text="The username, taken from Discord." + ) + discriminator = models.PositiveSmallIntegerField( + validators=( + MaxValueValidator( + limit_value=9999, + message="Discriminators may not exceed `9999`." + ), + ), + help_text="The discriminator of this user, taken from Discord." + ) + avatar_hash = models.CharField( + max_length=100, + help_text=( + "The user's avatar hash, taken from Discord. " + "Null if the user does not have any custom avatar." + ), + null=True + ) + roles = models.ManyToManyField( + Role, + help_text="Any roles this user has on our server." + ) 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() |