From c58f5c749ae1f4a411dd4cc9a8395dedddf93027 Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 19 Nov 2018 13:17:36 -0800 Subject: Django - Add Support for Storing Users Not in Guild (#150) * rename Member to User * add boolean field to distinguish users in our server * mark roles as not required * fix import order lint errors * fix order of model registration --- api/admin.py | 6 +- api/migrations/0018_user_rename.py | 17 +++++ api/migrations/0019_user_in_guild.py | 18 ++++++ api/models.py | 8 ++- api/serializers.py | 12 ++-- api/tests/test_members.py | 117 --------------------------------- api/tests/test_models.py | 6 +- api/tests/test_users.py | 121 +++++++++++++++++++++++++++++++++++ api/urls.py | 8 +-- api/viewsets.py | 69 +++++++++++--------- 10 files changed, 215 insertions(+), 167 deletions(-) create mode 100644 api/migrations/0018_user_rename.py create mode 100644 api/migrations/0019_user_in_guild.py delete mode 100644 api/tests/test_members.py create mode 100644 api/tests/test_users.py diff --git a/api/admin.py b/api/admin.py index b06cc939..c98f24eb 100644 --- a/api/admin.py +++ b/api/admin.py @@ -1,16 +1,15 @@ from django.contrib import admin from .models import ( - DocumentationLink, Member, + DocumentationLink, OffTopicChannelName, Role, SnakeFact, SnakeIdiom, SnakeName, SpecialSnake, - Tag + Tag, User ) admin.site.register(DocumentationLink) -admin.site.register(Member) admin.site.register(OffTopicChannelName) admin.site.register(Role) admin.site.register(SnakeFact) @@ -18,3 +17,4 @@ admin.site.register(SnakeIdiom) admin.site.register(SnakeName) admin.site.register(SpecialSnake) admin.site.register(Tag) +admin.site.register(User) diff --git a/api/migrations/0018_user_rename.py b/api/migrations/0018_user_rename.py new file mode 100644 index 00000000..f88eb5bc --- /dev/null +++ b/api/migrations/0018_user_rename.py @@ -0,0 +1,17 @@ +# Generated by Django 2.1.3 on 2018-11-19 20:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0017_auto_20181029_1921'), + ] + + operations = [ + migrations.RenameModel( + old_name='Member', + new_name='User', + ), + ] diff --git a/api/migrations/0019_user_in_guild.py b/api/migrations/0019_user_in_guild.py new file mode 100644 index 00000000..fda008c4 --- /dev/null +++ b/api/migrations/0019_user_in_guild.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-19 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0018_user_rename'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='in_guild', + field=models.BooleanField(default=True, help_text='Whether this user is in our server.'), + ), + ] diff --git a/api/models.py b/api/models.py index 9990e266..bcf4af78 100644 --- a/api/models.py +++ b/api/models.py @@ -167,8 +167,8 @@ class Role(ModelReprMixin, models.Model): return self.name -class Member(ModelReprMixin, models.Model): - """A member of our Discord server.""" +class User(ModelReprMixin, models.Model): + """A Discord user.""" id = models.BigIntegerField( # noqa primary_key=True, @@ -205,6 +205,10 @@ class Member(ModelReprMixin, models.Model): Role, help_text="Any roles this user has on our server." ) + in_guild = models.BooleanField( + default=True, + help_text="Whether this user is in our server." + ) def __str__(self): return f"{self.name}#{self.discriminator}" diff --git a/api/serializers.py b/api/serializers.py index f8d15bbf..ba6dfaaf 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -3,10 +3,10 @@ from rest_framework_bulk import BulkSerializerMixin from .models import ( DocumentationLink, - Member, OffTopicChannelName, + OffTopicChannelName, Role, SnakeFact, SnakeIdiom, SnakeName, - SpecialSnake, Tag + SpecialSnake, Tag, User ) @@ -61,10 +61,10 @@ class TagSerializer(ModelSerializer): fields = ('title', 'embed') -class MemberSerializer(BulkSerializerMixin, ModelSerializer): - roles = PrimaryKeyRelatedField(many=True, queryset=Role.objects.all()) +class UserSerializer(BulkSerializerMixin, ModelSerializer): + roles = PrimaryKeyRelatedField(many=True, queryset=Role.objects.all(), required=False) class Meta: - model = Member - fields = ('id', 'avatar_hash', 'name', 'discriminator', 'roles') + model = User + fields = ('id', 'avatar_hash', 'name', 'discriminator', 'roles', 'in_guild') depth = 1 diff --git a/api/tests/test_members.py b/api/tests/test_members.py deleted file mode 100644 index 47466b62..00000000 --- a/api/tests/test_members.py +++ /dev/null @@ -1,117 +0,0 @@ -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): # noqa - 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/tests/test_models.py b/api/tests/test_models.py index 91db2def..2e606801 100644 --- a/api/tests/test_models.py +++ b/api/tests/test_models.py @@ -1,11 +1,11 @@ from django.test import SimpleTestCase from ..models import ( - DocumentationLink, Member, ModelReprMixin, + DocumentationLink, ModelReprMixin, OffTopicChannelName, Role, SnakeFact, SnakeIdiom, SnakeName, SpecialSnake, - Tag + Tag, User ) @@ -41,7 +41,7 @@ class StringDunderMethodTests(SimpleTestCase): id=5, name='test role', colour=0x5, permissions=0 ), - Member( + User( id=5, name='bob', discriminator=1, avatar_hash=None ), diff --git a/api/tests/test_users.py b/api/tests/test_users.py new file mode 100644 index 00000000..8dadcbdb --- /dev/null +++ b/api/tests/test_users.py @@ -0,0 +1,121 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import Role, User + + +class UnauthedDocumentationLinkAPITests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_detail_lookup_returns_401(self): + url = reverse('bot:user-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:user-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_create_returns_401(self): + url = reverse('bot:user-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:user-detail', args=('whatever',), host='api') + response = self.client.delete(url) + + self.assertEqual(response.status_code, 401) + + +class CreationTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + 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:user-list', host='api') + data = { + 'id': 42, + 'avatar_hash': "validavatarhashiswear", + 'name': "Test", + 'discriminator': 42, + 'roles': [ + self.role.id + ], + 'in_guild': True + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json(), data) + + user = User.objects.get(id=42) + self.assertEqual(user.avatar_hash, data['avatar_hash']) + self.assertEqual(user.name, data['name']) + self.assertEqual(user.discriminator, data['discriminator']) + self.assertEqual(user.in_guild, data['in_guild']) + + def test_supports_multi_creation(self): + url = reverse('bot:user-list', host='api') + data = [ + { + 'id': 5, + 'avatar_hash': "hahayes", + 'name': "test man", + 'discriminator': 42, + 'roles': [ + self.role.id + ], + 'in_guild': True + }, + { + 'id': 8, + 'avatar_hash': "maybenot", + 'name': "another test man", + 'discriminator': 555, + 'roles': [], + 'in_guild': False + } + ] + + 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:user-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:user-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 8229b08c..59853934 100644 --- a/api/urls.py +++ b/api/urls.py @@ -3,11 +3,11 @@ from rest_framework.routers import DefaultRouter from .views import HealthcheckView from .viewsets import ( - DocumentationLinkViewSet, MemberViewSet, + DocumentationLinkViewSet, OffTopicChannelNameViewSet, SnakeFactViewSet, SnakeIdiomViewSet, SnakeNameViewSet, SpecialSnakeViewSet, - TagViewSet + TagViewSet, UserViewSet ) @@ -23,8 +23,8 @@ bot_router.register( base_name='offtopicchannelname' ) bot_router.register( - 'members', - MemberViewSet + 'users', + UserViewSet ) bot_router.register( 'snake-facts', diff --git a/api/viewsets.py b/api/viewsets.py index 08660810..de5ddaf6 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -10,18 +10,18 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet from rest_framework_bulk import BulkCreateModelMixin from .models import ( - DocumentationLink, Member, + DocumentationLink, OffTopicChannelName, SnakeFact, SnakeIdiom, SnakeName, SpecialSnake, - Tag + Tag, User ) from .serializers import ( DocumentationLinkSerializer, - MemberSerializer, OffTopicChannelNameSerializer, + OffTopicChannelNameSerializer, SnakeFactSerializer, SnakeIdiomSerializer, SnakeNameSerializer, SpecialSnakeSerializer, - TagSerializer + TagSerializer, UserSerializer ) @@ -413,13 +413,13 @@ class TagViewSet(ModelViewSet): queryset = Tag.objects.all() -class MemberViewSet(BulkCreateModelMixin, ModelViewSet): +class UserViewSet(BulkCreateModelMixin, ModelViewSet): """ - View providing CRUD operations on our Discord server's members through the bot. + View providing CRUD operations on Discord users through the bot. ## Routes - ### GET /bot/members - Returns all members currently known. + ### GET /bot/users + Returns all users currently known. #### Response format >>> [ @@ -433,15 +433,16 @@ class MemberViewSet(BulkCreateModelMixin, ModelViewSet): ... 270988689419665409, ... 277546923144249364, ... 458226699344019457 - ... ] + ... ], + ... 'in_guild': True ... } ... ] #### Status codes - 200: returned on success - ### GET /bot/members/ - Gets a single member by ID. + ### GET /bot/users/ + Gets a single user by ID. #### Response format >>> { @@ -454,16 +455,17 @@ class MemberViewSet(BulkCreateModelMixin, ModelViewSet): ... 270988689419665409, ... 277546923144249364, ... 458226699344019457 - ... ] + ... ], + ... 'in_guild': True ... } #### Status codes - 200: returned on success - - 404: if a member with the given `snowflake` could not be found + - 404: if a user 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. + ### POST /bot/users + Adds a single or multiple new users. + The roles attached to the user(s) must be roles known by the site. #### Request body >>> { @@ -471,18 +473,19 @@ class MemberViewSet(BulkCreateModelMixin, ModelViewSet): ... 'avatar': str, ... 'name': str, ... 'discriminator': int, - ... 'roles': List[int] + ... 'roles': List[int], + ... 'in_guild': bool ... } - Alternatively, request members can be POSTed as a list of above objects, - in which case multiple members will be created at once. + Alternatively, request users can be POSTed as a list of above objects, + in which case multiple users 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/ - Update the member with the given `snowflake`. + ### PUT /bot/users/ + Update the user with the given `snowflake`. All fields in the request body are required. #### Request body @@ -491,16 +494,17 @@ class MemberViewSet(BulkCreateModelMixin, ModelViewSet): ... 'avatar': str, ... 'name': str, ... 'discriminator': int, - ... 'roles': List[int] + ... 'roles': List[int], + ... 'in_guild': bool ... } #### 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 + - 404: if the user with the given `snowflake` could not be found - ### PATCH /bot/members/ - Update the member with the given `snowflake`. + ### PATCH /bot/users/ + Update the user with the given `snowflake`. All fields in the request body are optional. #### Request body @@ -509,21 +513,22 @@ class MemberViewSet(BulkCreateModelMixin, ModelViewSet): ... 'avatar': str, ... 'name': str, ... 'discriminator': int, - ... 'roles': List[int] + ... 'roles': List[int], + ... 'in_guild': bool ... } #### 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 + - 404: if the user with the given `snowflake` could not be found - ### DELETE /bot/members/ - Deletes the member with the given `snowflake`. + ### DELETE /bot/users/ + Deletes the user with the given `snowflake`. #### Status codes - 204: returned on success - - 404: if a member with the given `snowflake` does not exist + - 404: if a user with the given `snowflake` does not exist """ - serializer_class = MemberSerializer - queryset = Member.objects.all() + serializer_class = UserSerializer + queryset = User.objects.all() -- cgit v1.2.3 From 8bdd41f00499b230ac00caa3bf779851da597c07 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Thu, 22 Nov 2018 19:20:38 +0100 Subject: Use `ERROR` log level in tests if no log level is explicitly set. --- pysite/settings.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/pysite/settings.py b/pysite/settings.py index 5badde5c..c3373250 100644 --- a/pysite/settings.py +++ b/pysite/settings.py @@ -11,7 +11,7 @@ https://docs.djangoproject.com/en/2.1/ref/settings/ """ import os -# import sys +import sys import environ @@ -207,7 +207,22 @@ LOGGING = { 'django': { 'handlers': ['console'], 'propagate': True, - 'level': 'INFO' if DEBUG else env('LOG_LEVEL', default='WARN') + 'level': env( + 'LOG_LEVEL', + default=( + # If there is no explicit `LOG_LEVEL` set, + # use `DEBUG` if we're running in debug mode but not + # testing. Use `ERROR` if we're running tests, else + # default to using `WARN`. + 'DEBUG' + if DEBUG and 'test' not in sys.argv + else ( + 'ERROR' + if 'test' in sys.argv + else 'WARN' + ) + ) + ) } } } -- cgit v1.2.3 From 04afaefa471d47959fb941cada7a415550090299 Mon Sep 17 00:00:00 2001 From: Hasan <31344273+ImportErr@users.noreply.github.com> Date: Sun, 25 Nov 2018 09:25:08 +0000 Subject: Add a regex validator to snakename fields. (#151) --- api/migrations/0020_auto_20181124_1711.py | 24 ++++++++++++++++++++++++ api/models.py | 6 ++++-- 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 api/migrations/0020_auto_20181124_1711.py diff --git a/api/migrations/0020_auto_20181124_1711.py b/api/migrations/0020_auto_20181124_1711.py new file mode 100644 index 00000000..3b625f9b --- /dev/null +++ b/api/migrations/0020_auto_20181124_1711.py @@ -0,0 +1,24 @@ +# Generated by Django 2.1.2 on 2018-11-24 17:11 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0019_user_in_guild'), + ] + + operations = [ + migrations.AlterField( + model_name='snakename', + name='name', + field=models.CharField(help_text="The regular name for this snake, e.g. 'Python'.", max_length=100, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), + ), + migrations.AlterField( + model_name='snakename', + name='scientific', + field=models.CharField(help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", max_length=150, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), + ), + ] diff --git a/api/models.py b/api/models.py index bcf4af78..7623c86c 100644 --- a/api/models.py +++ b/api/models.py @@ -92,11 +92,13 @@ class SnakeName(ModelReprMixin, models.Model): name = models.CharField( primary_key=True, max_length=100, - help_text="The regular name for this snake, e.g. 'Python'." + help_text="The regular name for this snake, e.g. 'Python'.", + validators=[RegexValidator(regex=r'^([^0-9])+$')] ) scientific = models.CharField( max_length=150, - help_text="The scientific name for this snake, e.g. 'Python bivittatus'." + help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", + validators=[RegexValidator(regex=r'^([^0-9])+$')] ) def __str__(self): -- cgit v1.2.3 From 5354a964cd44ee58b6399a22b0e4e36f4db53bbf Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 25 Nov 2018 10:44:06 +0100 Subject: Use more descriptive migration name. --- api/migrations/0020_add_snake_field_validators.py | 24 +++++++++++++++++++++++ api/migrations/0020_auto_20181124_1711.py | 24 ----------------------- 2 files changed, 24 insertions(+), 24 deletions(-) create mode 100644 api/migrations/0020_add_snake_field_validators.py delete mode 100644 api/migrations/0020_auto_20181124_1711.py diff --git a/api/migrations/0020_add_snake_field_validators.py b/api/migrations/0020_add_snake_field_validators.py new file mode 100644 index 00000000..3b625f9b --- /dev/null +++ b/api/migrations/0020_add_snake_field_validators.py @@ -0,0 +1,24 @@ +# Generated by Django 2.1.2 on 2018-11-24 17:11 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0019_user_in_guild'), + ] + + operations = [ + migrations.AlterField( + model_name='snakename', + name='name', + field=models.CharField(help_text="The regular name for this snake, e.g. 'Python'.", max_length=100, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), + ), + migrations.AlterField( + model_name='snakename', + name='scientific', + field=models.CharField(help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", max_length=150, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), + ), + ] diff --git a/api/migrations/0020_auto_20181124_1711.py b/api/migrations/0020_auto_20181124_1711.py deleted file mode 100644 index 3b625f9b..00000000 --- a/api/migrations/0020_auto_20181124_1711.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 2.1.2 on 2018-11-24 17:11 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('api', '0019_user_in_guild'), - ] - - operations = [ - migrations.AlterField( - model_name='snakename', - name='name', - field=models.CharField(help_text="The regular name for this snake, e.g. 'Python'.", max_length=100, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), - ), - migrations.AlterField( - model_name='snakename', - name='scientific', - field=models.CharField(help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", max_length=150, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), - ), - ] -- cgit v1.2.3 From 7cad434189a4b3afb49a88df20f7c4a303f1d47c Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Sun, 25 Nov 2018 11:07:17 +0100 Subject: Set up image pushing and building on Azure. (#152) Closes #152. --- azure-pipelines.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 90232430..1c6a746a 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -55,6 +55,9 @@ jobs: - job: test displayName: Test + dependsOn: + - lint_misc + - lint_python pool: vmImage: ubuntu-16.04 strategy: @@ -112,5 +115,25 @@ jobs: testResultsFiles: "**/TEST-*.xml" testRunTitle: 'Python $(python.version) with PostgreSQL $(postgres.version)' + - job: push_image + displayName: Push Docker image + dependsOn: test + condition: and(succeeded(), eq(variables['build.sourceBranch'], 'refs/heads/django')) + pool: + vmImage: ubuntu-16.04 + + steps: + - task: Docker@1 + displayName: Login to Docker Hub + + inputs: + containerregistrytype: 'Container Registry' + dockerRegistryEndpoint: 'DockerHub' + command: 'login' + + - script: | + docker build -t pythondiscord/django:latest . + docker push pythondiscord/django:latest + displayName: Build and push the image # vim: sw=2 ts=2: -- cgit v1.2.3 From 59c45ee43e10737f6aa48ef929b56b176886d0bf Mon Sep 17 00:00:00 2001 From: Hasan <31344273+ImportErr@users.noreply.github.com> Date: Thu, 29 Nov 2018 19:10:39 +0000 Subject: Added regex validator to special snake name. (#153) --- api/migrations/0021_add_special_snake_validator.py | 19 +++++++++++++++++++ api/models.py | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 api/migrations/0021_add_special_snake_validator.py diff --git a/api/migrations/0021_add_special_snake_validator.py b/api/migrations/0021_add_special_snake_validator.py new file mode 100644 index 00000000..d41b96e5 --- /dev/null +++ b/api/migrations/0021_add_special_snake_validator.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.2 on 2018-11-25 14:59 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0020_add_snake_field_validators'), + ] + + operations = [ + migrations.AlterField( + model_name='specialsnake', + name='name', + field=models.CharField(help_text='A special snake name.', max_length=140, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), + ), + ] diff --git a/api/models.py b/api/models.py index 7623c86c..c67f558d 100644 --- a/api/models.py +++ b/api/models.py @@ -111,7 +111,8 @@ class SpecialSnake(ModelReprMixin, models.Model): name = models.CharField( max_length=140, primary_key=True, - help_text='A special snake name.' + help_text='A special snake name.', + validators=[RegexValidator(regex=r'^([^0-9])+$')] ) info = models.TextField( help_text='Info about a special snake.' -- cgit v1.2.3 From 9b0eeff865bb39454f201eb82b460fdc27899a90 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Nov 2018 12:27:07 -0800 Subject: Django - Add Infractions API (#149) * add Infraction model and serialiser The model in not finalised. * fix mix up of serialiser fields * remove explicit id field and add foreign keys * remove unused import * disallow null for user * add view set and route * fix model and create migration * fix typo choice => choices * specify names for reverse accessors for User FKs * add django-filter * add filters to view set * add string dunder method to model * add list/retrieve tests * make reason nullable * add creation tests * remove support for PUT and DELETE * add support for PATCH * assert timestamps using strings rather than datetimes This is done to keep 3.6 support; datetime.fromisoformat() is 3.7+ * assert inserted_at * add unauthenticated tests * add bad value tests for list filters and retrieve * remove prefetch cache invalidation * make __str__ more descriptive * add field validation & remove note type * add tests for field validation * fix coverage for Infraction string dunder test * fix coverage (for sure this time) * return 400 for partial updates with frozen fields * add expanded serialiser and endpoints * test expanded endpoints * remove extra retrieve call * remove unnecessary try-finally blocks * remove extra blank line * document endpoints (except expanded) * document expanded routes * fix wrong routes in docstring (/infraction -> /infractions) * make merge migration --- api/admin.py | 3 +- api/migrations/0020_infraction.py | 30 ++ api/migrations/0021_infraction_reason_null.py | 18 ++ api/migrations/0022_infraction_remove_note.py | 18 ++ .../0023_merge_infractions_snake_validators.py | 14 + api/models.py | 60 ++++ api/serializers.py | 40 ++- api/tests/test_infractions.py | 359 +++++++++++++++++++++ api/tests/test_models.py | 23 +- api/urls.py | 14 +- api/viewsets.py | 149 ++++++++- pysite/settings.py | 1 + setup.py | 1 + 13 files changed, 712 insertions(+), 18 deletions(-) create mode 100644 api/migrations/0020_infraction.py create mode 100644 api/migrations/0021_infraction_reason_null.py create mode 100644 api/migrations/0022_infraction_remove_note.py create mode 100644 api/migrations/0023_merge_infractions_snake_validators.py create mode 100644 api/tests/test_infractions.py diff --git a/api/admin.py b/api/admin.py index c98f24eb..2c8c130b 100644 --- a/api/admin.py +++ b/api/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from .models import ( - DocumentationLink, + DocumentationLink, Infraction, OffTopicChannelName, Role, SnakeFact, SnakeIdiom, SnakeName, SpecialSnake, @@ -10,6 +10,7 @@ from .models import ( admin.site.register(DocumentationLink) +admin.site.register(Infraction) admin.site.register(OffTopicChannelName) admin.site.register(Role) admin.site.register(SnakeFact) diff --git a/api/migrations/0020_infraction.py b/api/migrations/0020_infraction.py new file mode 100644 index 00000000..2844a7f7 --- /dev/null +++ b/api/migrations/0020_infraction.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1.3 on 2018-11-19 22:02 + +import api.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0019_user_in_guild'), + ] + + operations = [ + migrations.CreateModel( + name='Infraction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('inserted_at', models.DateTimeField(auto_now_add=True, help_text='The date and time of the creation of this infraction.')), + ('expires_at', models.DateTimeField(help_text="The date and time of the expiration of this infraction. Null if the infraction is permanent or it can't expire.", null=True)), + ('active', models.BooleanField(default=True, help_text='Whether the infraction is still active.')), + ('type', models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('mute', 'Mute'), ('ban', 'Ban'), ('kick', 'Kick'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9)), + ('reason', models.TextField(help_text='The reason for the infraction.')), + ('hidden', models.BooleanField(default=False, help_text='Whether the infraction is a shadow infraction.')), + ('actor', models.ForeignKey(help_text='The user which applied the infraction.', on_delete=django.db.models.deletion.CASCADE, related_name='infractions_given', to='api.User')), + ('user', models.ForeignKey(help_text='The user to which the infraction was applied.', on_delete=django.db.models.deletion.CASCADE, related_name='infractions_received', to='api.User')), + ], + bases=(api.models.ModelReprMixin, models.Model), + ), + ] diff --git a/api/migrations/0021_infraction_reason_null.py b/api/migrations/0021_infraction_reason_null.py new file mode 100644 index 00000000..6600f230 --- /dev/null +++ b/api/migrations/0021_infraction_reason_null.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-21 00:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0020_infraction'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='reason', + field=models.TextField(help_text='The reason for the infraction.', null=True), + ), + ] diff --git a/api/migrations/0022_infraction_remove_note.py b/api/migrations/0022_infraction_remove_note.py new file mode 100644 index 00000000..eba84610 --- /dev/null +++ b/api/migrations/0022_infraction_remove_note.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-21 21:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0021_infraction_reason_null'), + ] + + operations = [ + migrations.AlterField( + model_name='infraction', + name='type', + field=models.CharField(choices=[('warning', 'Warning'), ('mute', 'Mute'), ('ban', 'Ban'), ('kick', 'Kick'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9), + ), + ] diff --git a/api/migrations/0023_merge_infractions_snake_validators.py b/api/migrations/0023_merge_infractions_snake_validators.py new file mode 100644 index 00000000..916f78f2 --- /dev/null +++ b/api/migrations/0023_merge_infractions_snake_validators.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.3 on 2018-11-29 19:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0022_infraction_remove_note'), + ('api', '0021_add_special_snake_validator'), + ] + + operations = [ + ] diff --git a/api/models.py b/api/models.py index c67f558d..3387e9be 100644 --- a/api/models.py +++ b/api/models.py @@ -235,3 +235,63 @@ class Tag(ModelReprMixin, models.Model): def __str__(self): return self.title + + +class Infraction(ModelReprMixin, models.Model): + """An infraction for a Discord user.""" + + TYPE_CHOICES = ( + ("warning", "Warning"), + ("mute", "Mute"), + ("ban", "Ban"), + ("kick", "Kick"), + ("superstar", "Superstar") + ) + inserted_at = models.DateTimeField( + auto_now_add=True, + help_text="The date and time of the creation of this infraction." + ) + expires_at = models.DateTimeField( + null=True, + help_text=( + "The date and time of the expiration of this infraction. " + "Null if the infraction is permanent or it can't expire." + ) + ) + active = models.BooleanField( + default=True, + help_text="Whether the infraction is still active." + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='infractions_received', + help_text="The user to which the infraction was applied." + ) + actor = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='infractions_given', + help_text="The user which applied the infraction." + ) + type = models.CharField( + max_length=9, + choices=TYPE_CHOICES, + help_text="The type of the infraction." + ) + reason = models.TextField( + null=True, + help_text="The reason for the infraction." + ) + hidden = models.BooleanField( + default=False, + help_text="Whether the infraction is a shadow infraction." + ) + + def __str__(self): + s = f"#{self.id}: {self.type} on {self.user_id}" + if self.expires_at: + s += f" until {self.expires_at}" + if self.hidden: + s += " (hidden)" + return s diff --git a/api/serializers.py b/api/serializers.py index ba6dfaaf..612ce5b4 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -1,8 +1,8 @@ -from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField +from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError from rest_framework_bulk import BulkSerializerMixin from .models import ( - DocumentationLink, + DocumentationLink, Infraction, OffTopicChannelName, Role, SnakeFact, SnakeIdiom, SnakeName, @@ -16,6 +16,42 @@ class DocumentationLinkSerializer(ModelSerializer): fields = ('package', 'base_url', 'inventory_url') +class InfractionSerializer(ModelSerializer): + class Meta: + model = Infraction + fields = ( + 'id', 'inserted_at', 'expires_at', 'active', 'user', 'actor', 'type', 'reason', 'hidden' + ) + + def validate(self, attrs): + infr_type = attrs.get('type') + + expires_at = attrs.get('expires_at') + if expires_at and infr_type in ('kick', 'warning'): + raise ValidationError({'expires_at': [f'{infr_type} infractions cannot expire.']}) + + hidden = attrs.get('hidden') + if hidden and infr_type in ('superstar',): + raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']}) + + return attrs + + +class ExpandedInfractionSerializer(InfractionSerializer): + def to_representation(self, instance): + ret = super().to_representation(instance) + + user = User.objects.get(id=ret['user']) + user_data = UserSerializer(user).data + ret['user'] = user_data + + actor = User.objects.get(id=ret['actor']) + actor_data = UserSerializer(actor).data + ret['actor'] = actor_data + + return ret + + class OffTopicChannelNameSerializer(ModelSerializer): class Meta: model = OffTopicChannelName diff --git a/api/tests/test_infractions.py b/api/tests/test_infractions.py new file mode 100644 index 00000000..42010973 --- /dev/null +++ b/api/tests/test_infractions.py @@ -0,0 +1,359 @@ +from datetime import datetime as dt, timedelta, timezone +from urllib.parse import quote + +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import Infraction, User + + +class UnauthenticatedTests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_detail_lookup_returns_401(self): + url = reverse('bot:infraction-detail', args=(5,), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_list_returns_401(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 401) + + def test_create_returns_401(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.post(url, data={'reason': 'Have a nice day.'}) + + self.assertEqual(response.status_code, 401) + + def test_partial_update_returns_401(self): + url = reverse('bot:infraction-detail', args=(5,), host='api') + response = self.client.patch(url, data={'reason': 'Have a nice day.'}) + + self.assertEqual(response.status_code, 401) + + +class InfractionTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.user = User.objects.create( + id=5, + name='james', + discriminator=1, + avatar_hash=None + ) + cls.ban_hidden = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='ban', + reason='He terk my jerb!', + hidden=True, + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) + ) + cls.ban_inactive = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='ban', + reason='James is an ass, and we won\'t be working with him again.', + active=False + ) + + def test_list_all(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + infractions = response.json() + + self.assertEqual(len(infractions), 2) + self.assertEqual(infractions[0]['id'], self.ban_hidden.id) + self.assertEqual(infractions[1]['id'], self.ban_inactive.id) + + def test_filter_search(self): + url = reverse('bot:infraction-list', host='api') + pattern = quote(r'^James(\s\w+){3},') + response = self.client.get(f'{url}?search={pattern}') + + self.assertEqual(response.status_code, 200) + infractions = response.json() + + self.assertEqual(len(infractions), 1) + self.assertEqual(infractions[0]['id'], self.ban_inactive.id) + + def test_filter_field(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?type=ban&hidden=true') + + self.assertEqual(response.status_code, 200) + infractions = response.json() + + self.assertEqual(len(infractions), 1) + self.assertEqual(infractions[0]['id'], self.ban_hidden.id) + + def test_returns_empty_for_no_match(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?type=ban&search=poop') + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 0) + + def test_ignores_bad_filters(self): + url = reverse('bot:infraction-list', host='api') + response = self.client.get(f'{url}?type=ban&hidden=maybe&foo=bar') + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 2) + + def test_retrieve_single_from_id(self): + url = reverse('bot:infraction-detail', args=(self.ban_inactive.id,), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()['id'], self.ban_inactive.id) + + def test_retrieve_returns_404_for_absent_id(self): + url = reverse('bot:infraction-detail', args=(1337,), host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 404) + + def test_partial_update(self): + url = reverse('bot:infraction-detail', args=(self.ban_hidden.id,), host='api') + data = { + 'expires_at': '4143-02-15T21:04:31+00:00', + 'active': False, + 'reason': 'durka derr' + } + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 200) + infraction = Infraction.objects.get(id=self.ban_hidden.id) + + # These fields were updated. + self.assertEqual(infraction.expires_at.isoformat(), data['expires_at']) + self.assertEqual(infraction.active, data['active']) + self.assertEqual(infraction.reason, data['reason']) + + # These fields are still the same. + self.assertEqual(infraction.id, self.ban_hidden.id) + self.assertEqual(infraction.inserted_at, self.ban_hidden.inserted_at) + self.assertEqual(infraction.user.id, self.ban_hidden.user.id) + self.assertEqual(infraction.actor.id, self.ban_hidden.actor.id) + self.assertEqual(infraction.type, self.ban_hidden.type) + self.assertEqual(infraction.hidden, self.ban_hidden.hidden) + + def test_partial_update_returns_400_for_frozen_field(self): + url = reverse('bot:infraction-detail', args=(self.ban_hidden.id,), host='api') + data = {'user': 6} + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'user': ['This field cannot be updated.'] + }) + + +class CreationTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.user = User.objects.create( + id=5, + name='james', + discriminator=1, + avatar_hash=None + ) + + def test_accepts_valid_data(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'ban', + 'reason': 'He terk my jerb!', + 'hidden': True, + 'expires_at': '5018-11-20T15:52:00+00:00' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 201) + + infraction = Infraction.objects.get(id=1) + self.assertAlmostEqual( + infraction.inserted_at, + dt.now(timezone.utc), + delta=timedelta(seconds=2) + ) + self.assertEqual(infraction.expires_at.isoformat(), data['expires_at']) + self.assertEqual(infraction.user.id, data['user']) + self.assertEqual(infraction.actor.id, data['actor']) + self.assertEqual(infraction.type, data['type']) + self.assertEqual(infraction.reason, data['reason']) + self.assertEqual(infraction.hidden, data['hidden']) + self.assertEqual(infraction.active, True) + + def test_returns_400_for_missing_user(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'actor': self.user.id, + 'type': 'kick' + } + + 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_bad_user(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': 1337, + 'actor': self.user.id, + 'type': 'kick' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'user': ['Invalid pk "1337" - object does not exist.'] + }) + + def test_returns_400_for_bad_type(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'hug' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'type': ['"hug" is not a valid choice.'] + }) + + def test_returns_400_for_bad_expired_at_format(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'ban', + 'expires_at': '20/11/5018 15:52:00' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'expires_at': [ + 'Datetime has wrong format. Use one of these formats instead: ' + 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z].' + ] + }) + + def test_returns_400_for_expiring_non_expirable_type(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'kick', + 'expires_at': '5018-11-20T15:52:00+00:00' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'expires_at': [f'{data["type"]} infractions cannot expire.'] + }) + + def test_returns_400_for_hidden_non_hideable_type(self): + url = reverse('bot:infraction-list', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'superstar', + 'hidden': True + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), { + 'hidden': [f'{data["type"]} infractions cannot be hidden.'] + }) + + +class ExpandedTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): # noqa + cls.user = User.objects.create( + id=5, + name='james', + discriminator=1, + avatar_hash=None + ) + cls.kick = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='kick' + ) + cls.warning = Infraction.objects.create( + user_id=cls.user.id, + actor_id=cls.user.id, + type='warning' + ) + + def check_expanded_fields(self, infraction): + for key in ('user', 'actor'): + obj = infraction[key] + for field in ('id', 'name', 'discriminator', 'avatar_hash', 'roles', 'in_guild'): + self.assertTrue(field in obj, msg=f'field "{field}" missing from {key}') + + def test_list_expanded(self): + url = reverse('bot:infraction-list-expanded', host='api') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + response_data = response.json() + self.assertEqual(len(response_data), 2) + + for infraction in response_data: + self.check_expanded_fields(infraction) + + def test_create_expanded(self): + url = reverse('bot:infraction-list-expanded', host='api') + data = { + 'user': self.user.id, + 'actor': self.user.id, + 'type': 'warning' + } + + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 201) + + self.assertEqual(len(Infraction.objects.all()), 3) + self.check_expanded_fields(response.json()) + + def test_retrieve_expanded(self): + url = reverse('bot:infraction-detail-expanded', args=(self.warning.id,), host='api') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + infraction = response.json() + self.assertEqual(infraction['id'], self.warning.id) + self.check_expanded_fields(infraction) + + def test_partial_update_expanded(self): + url = reverse('bot:infraction-detail-expanded', args=(self.kick.id,), host='api') + data = {'active': False} + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 200) + + infraction = Infraction.objects.get(id=self.kick.id) + self.assertEqual(infraction.active, data['active']) + self.check_expanded_fields(response.json()) diff --git a/api/tests/test_models.py b/api/tests/test_models.py index 2e606801..1419a7d7 100644 --- a/api/tests/test_models.py +++ b/api/tests/test_models.py @@ -1,9 +1,11 @@ +from datetime import datetime as dt, timezone + from django.test import SimpleTestCase from ..models import ( - DocumentationLink, ModelReprMixin, - OffTopicChannelName, Role, - SnakeFact, SnakeIdiom, + DocumentationLink, Infraction, + ModelReprMixin, OffTopicChannelName, + Role, SnakeFact, SnakeIdiom, SnakeName, SpecialSnake, Tag, User ) @@ -41,13 +43,22 @@ class StringDunderMethodTests(SimpleTestCase): id=5, name='test role', colour=0x5, permissions=0 ), + Tag( + title='bob', + embed={'content': "the builder"} + ), User( id=5, name='bob', discriminator=1, avatar_hash=None ), - Tag( - title='bob', - embed={'content': "the builder"} + Infraction( + user_id=5, actor_id=5, + type='kick', reason='He terk my jerb!' + ), + Infraction( + user_id=5, actor_id=5, hidden=True, + type='kick', reason='He terk my jerb!', + expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) ) ) diff --git a/api/urls.py b/api/urls.py index 59853934..af275381 100644 --- a/api/urls.py +++ b/api/urls.py @@ -3,7 +3,7 @@ from rest_framework.routers import DefaultRouter from .views import HealthcheckView from .viewsets import ( - DocumentationLinkViewSet, + DocumentationLinkViewSet, InfractionViewSet, OffTopicChannelNameViewSet, SnakeFactViewSet, SnakeIdiomViewSet, SnakeNameViewSet, SpecialSnakeViewSet, @@ -17,15 +17,15 @@ bot_router.register( 'documentation-links', DocumentationLinkViewSet ) +bot_router.register( + 'infractions', + InfractionViewSet +) bot_router.register( 'off-topic-channel-names', OffTopicChannelNameViewSet, base_name='offtopicchannelname' ) -bot_router.register( - 'users', - UserViewSet -) bot_router.register( 'snake-facts', SnakeFactViewSet @@ -47,6 +47,10 @@ bot_router.register( 'tags', TagViewSet ) +bot_router.register( + 'users', + UserViewSet +) app_name = 'api' urlpatterns = ( diff --git a/api/viewsets.py b/api/viewsets.py index de5ddaf6..2784bbad 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -1,5 +1,8 @@ from django.shortcuts import get_object_or_404 -from rest_framework.exceptions import ParseError +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.decorators import action +from rest_framework.exceptions import ParseError, ValidationError +from rest_framework.filters import SearchFilter from rest_framework.mixins import ( CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin @@ -10,15 +13,15 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet from rest_framework_bulk import BulkCreateModelMixin from .models import ( - DocumentationLink, + DocumentationLink, Infraction, OffTopicChannelName, SnakeFact, SnakeIdiom, SnakeName, SpecialSnake, Tag, User ) from .serializers import ( - DocumentationLinkSerializer, - OffTopicChannelNameSerializer, + DocumentationLinkSerializer, ExpandedInfractionSerializer, + InfractionSerializer, OffTopicChannelNameSerializer, SnakeFactSerializer, SnakeIdiomSerializer, SnakeNameSerializer, SpecialSnakeSerializer, TagSerializer, UserSerializer @@ -89,6 +92,144 @@ class DocumentationLinkViewSet( lookup_field = 'package' +class InfractionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet): + """ + View providing CRUD operations on infractions for Discord users. + + ## Routes + ### GET /bot/infractions + Retrieve all infractions. + May be filtered by the query parameters. + + #### Query parameters + - **active** `bool`: whether the infraction is still active + - **actor** `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 + + Invalid query parameters are ignored. + + #### Response format + >>> [ + ... { + ... 'id': 5, + ... 'inserted_at': '2018-11-22T07:24:06.132307Z', + ... 'expires_at': '5018-11-20T15:52:00Z', + ... 'active': False, + ... 'user': 172395097705414656, + ... 'actor': 125435062127820800, + ... 'type': 'ban', + ... 'reason': 'He terk my jerb!', + ... 'hidden': True + ... } + ... ] + + #### Status codes + - 200: returned on success + + ### GET /bot/infractions/ + Retrieve a single infraction by ID. + + #### Response format + See `GET /bot/infractions`. + + #### Status codes + - 200: returned on success + - 404: if an infraction with the given `id` could not be found + + ### POST /bot/infractions + Create a new infraction and return the created infraction. + Only `actor`, `type`, and `user` are required. + The `actor` and `user` must be users known by the site. + + #### Request body + >>> { + ... 'active': False, + ... 'actor': 125435062127820800, + ... 'expires_at': '5018-11-20T15:52:00+00:00', + ... 'hidden': True, + ... 'type': 'ban', + ... 'reason': 'He terk my jerb!', + ... 'user': 172395097705414656 + ... } + + #### Response format + See `GET /bot/infractions`. + + #### Status codes + - 201: returned on success + - 400: if a given user is unknown or a field in the request body is invalid + + ### PATCH /bot/infractions/ + Update the infraction with the given `id` and return the updated infraction. + Only `active`, `reason`, and `expires_at` may be updated. + + #### Request body + >>> { + ... 'active': True, + ... 'expires_at': '4143-02-15T21:04:31+00:00', + ... 'reason': 'durka derr' + ... } + + #### Response format + See `GET /bot/infractions`. + + #### 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 + + ### Expanded routes + All routes support expansion of `user` and `actor` in responses. To use an expanded route, + append `/expanded` to the end of the route e.g. `GET /bot/infractions/expanded`. + + #### Response format + See `GET /bot/users/` for the expanded formats of `user` and `actor`. Responses + are otherwise identical to their non-expanded counterparts. + """ + + serializer_class = InfractionSerializer + queryset = Infraction.objects.all() + filter_backends = (DjangoFilterBackend, SearchFilter) + filter_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type') + search_fields = ('$reason',) + frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') + + def partial_update(self, request, *args, **kwargs): + for field in request.data: + if field in self.frozen_fields: + raise ValidationError({field: ['This field cannot be updated.']}) + + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data) + + @action(url_path='expanded', detail=False) + def list_expanded(self, *args, **kwargs): + self.serializer_class = ExpandedInfractionSerializer + return self.list(*args, **kwargs) + + @list_expanded.mapping.post + def create_expanded(self, *args, **kwargs): + self.serializer_class = ExpandedInfractionSerializer + return self.create(*args, **kwargs) + + @action(url_path='expanded', url_name='detail-expanded', detail=True) + def retrieve_expanded(self, *args, **kwargs): + self.serializer_class = ExpandedInfractionSerializer + return self.retrieve(*args, **kwargs) + + @retrieve_expanded.mapping.patch + def partial_update_expanded(self, *args, **kwargs): + self.serializer_class = ExpandedInfractionSerializer + return self.partial_update(*args, **kwargs) + + class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet): """ View of off-topic channel names used by the bot diff --git a/pysite/settings.py b/pysite/settings.py index c3373250..0175fb46 100644 --- a/pysite/settings.py +++ b/pysite/settings.py @@ -73,6 +73,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django_hosts', + 'django_filters', 'rest_framework', 'rest_framework.authtoken' ] diff --git a/setup.py b/setup.py index ab4a61a2..ac133d2b 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ setup( 'djangorestframework-bulk>=0.2.1', 'django-hosts>=3.0', 'django-environ>=0.4.5', + 'django-filter>=2.0.0', 'psycopg2-binary>=2.7.5' ], extras_require={ -- cgit v1.2.3 From 1435d100286c0ea434c2995d1cd993045b2103f0 Mon Sep 17 00:00:00 2001 From: ImportErr Date: Fri, 30 Nov 2018 18:48:32 +0000 Subject: Fixed member route typos --- api/viewsets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/viewsets.py b/api/viewsets.py index de5ddaf6..86ab5758 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -365,7 +365,7 @@ class TagViewSet(ModelViewSet): - 201: returned on success - 400: if one of the given fields is invalid - ### PUT /bot/members/ + ### PUT /bot/tags/ Update the tag with the given `title`. #### Request body @@ -383,7 +383,7 @@ class TagViewSet(ModelViewSet): - 400: if the request body was invalid, see response body for details - 404: if the tag with the given `title` could not be found - ### PATCH /bot/members/ + ### PATCH /bot/tags/ Update the tag with the given `title`. #### Request body @@ -401,7 +401,7 @@ class TagViewSet(ModelViewSet): - 400: if the request body was invalid, see response body for details - 404: if the tag with the given `title` could not be found - ### DELETE /bot/members/ + ### DELETE /bot/tags/ Deletes the tag with the given `title`. #### Status codes -- cgit v1.2.3 From ccdf2b163bf7a439ea38c5f3ba7fd479c055d8e0 Mon Sep 17 00:00:00 2001 From: ImportErr Date: Sat, 1 Dec 2018 12:08:35 +0000 Subject: Ordered models alphabetically --- api/models.py | 110 +++++++++++++++++++++++++++++----------------------------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/api/models.py b/api/models.py index 7623c86c..fc031b6e 100644 --- a/api/models.py +++ b/api/models.py @@ -60,6 +60,50 @@ class OffTopicChannelName(ModelReprMixin, models.Model): return self.name +class Role(ModelReprMixin, models.Model): + """A role on our Discord server.""" + + id = models.BigIntegerField( # noqa + 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." + ) + + def __str__(self): + return self.name + + class SnakeFact(ModelReprMixin, models.Model): """A snake fact used by the bot's snake cog.""" @@ -125,48 +169,24 @@ class SpecialSnake(ModelReprMixin, models.Model): return self.name -class Role(ModelReprMixin, models.Model): - """A role on our Discord server.""" +class Tag(ModelReprMixin, models.Model): + """A tag providing (hopefully) useful information.""" - id = models.BigIntegerField( # noqa - 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( + title = 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 title of this tag, shown in searches and providing " + "a quick overview over what this embed contains." ), - help_text="The integer value of the colour of this role from Discord." + primary_key=True ) - 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." + embed = pgfields.JSONField( + help_text="The actual embed shown by this tag.", + validators=(validate_tag_embed,) ) def __str__(self): - return self.name + return self.title class User(ModelReprMixin, models.Model): @@ -214,23 +234,3 @@ class User(ModelReprMixin, models.Model): def __str__(self): return f"{self.name}#{self.discriminator}" - - -class Tag(ModelReprMixin, models.Model): - """A tag providing (hopefully) useful information.""" - - title = models.CharField( - max_length=100, - help_text=( - "The title of this tag, shown in searches and providing " - "a quick overview over what this embed contains." - ), - primary_key=True - ) - embed = pgfields.JSONField( - help_text="The actual embed shown by this tag.", - validators=(validate_tag_embed,) - ) - - def __str__(self): - return self.title -- cgit v1.2.3 From 9d5b6491f8825e4b366be3e6db0429237311dd1e Mon Sep 17 00:00:00 2001 From: ImportErr Date: Sat, 1 Dec 2018 12:23:02 +0000 Subject: Renamed class in test_users --- api/tests/test_users.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/test_users.py b/api/tests/test_users.py index 8dadcbdb..90bc3d30 100644 --- a/api/tests/test_users.py +++ b/api/tests/test_users.py @@ -4,7 +4,7 @@ from .base import APISubdomainTestCase from ..models import Role, User -class UnauthedDocumentationLinkAPITests(APISubdomainTestCase): +class UnauthedUserAPITests(APISubdomainTestCase): def setUp(self): super().setUp() self.client.force_authenticate(user=None) -- cgit v1.2.3 From bbdf44aeca943c6fd0b0a37254419ecb1c8a94f5 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 5 Dec 2018 22:32:21 +0100 Subject: Bump minimum DRF version to `3.9.0`. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ac133d2b..e2f03070 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ setup( python_requires='>= 3.6', install_requires=[ 'django>=2.1.1', - 'djangorestframework>=3.8.2', + 'djangorestframework>=3.9.0', 'djangorestframework-bulk>=0.2.1', 'django-hosts>=3.0', 'django-environ>=0.4.5', -- cgit v1.2.3