diff options
| -rw-r--r-- | .coveragerc | 17 | ||||
| -rw-r--r-- | Pipfile | 1 | ||||
| -rw-r--r-- | Pipfile.lock | 21 | ||||
| -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 | 
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 @@ -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() | 
