aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.coveragerc17
-rw-r--r--Pipfile1
-rw-r--r--Pipfile.lock21
-rw-r--r--api/migrations/0004_role.py23
-rw-r--r--api/migrations/0005_user.py38
-rw-r--r--api/models.py83
-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
10 files changed, 419 insertions, 33 deletions
diff --git a/.coveragerc b/.coveragerc
index 60ef892f..93f53edf 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,15 +1,4 @@
[run]
-omit = /usr/*,
- gunicorn_config.py,
- deploy.py,
- app_test.py,
- app.py,
- pysite/websockets.py,
- pysite/views/*__init__.py,
- pysite/route_manager.py,
- pysite/migrations/runner.py
-
-[report]
-exclude_lines = return jsonify,
- raise RuntimeError,
- return
+omit = */apps.py
+ pysite/wsgi.py
+ pysite/settings.py
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/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()