aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar Johannes Christ <[email protected]>2019-01-01 21:15:29 +0100
committerGravatar Johannes Christ <[email protected]>2019-01-01 21:15:29 +0100
commita6e81b6757b43c68ee749006086035c3e8033f15 (patch)
treeb9fbfe6a0daac1884c83ad60476cc34a7b5d7971
parentapply stash (diff)
parentMerge pull request #156 from python-discord/django-beautify (diff)
Merge branch 'django' into django+add-role-viewset
-rw-r--r--api/admin.py7
-rw-r--r--api/migrations/0018_user_rename.py17
-rw-r--r--api/migrations/0019_user_in_guild.py18
-rw-r--r--api/migrations/0020_add_snake_field_validators.py24
-rw-r--r--api/migrations/0020_infraction.py30
-rw-r--r--api/migrations/0021_add_special_snake_validator.py19
-rw-r--r--api/migrations/0021_infraction_reason_null.py18
-rw-r--r--api/migrations/0022_infraction_remove_note.py18
-rw-r--r--api/migrations/0023_merge_infractions_snake_validators.py14
-rw-r--r--api/models.py171
-rw-r--r--api/serializers.py52
-rw-r--r--api/tests/test_infractions.py359
-rw-r--r--api/tests/test_models.py27
-rw-r--r--api/tests/test_users.py (renamed from api/tests/test_members.py)32
-rw-r--r--api/urls.py22
-rw-r--r--api/viewsets.py229
-rw-r--r--azure-pipelines.yml23
-rw-r--r--pysite/settings.py20
-rw-r--r--setup.py3
19 files changed, 965 insertions, 138 deletions
diff --git a/api/admin.py b/api/admin.py
index b06cc939..2c8c130b 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -1,16 +1,16 @@
from django.contrib import admin
from .models import (
- DocumentationLink, Member,
+ DocumentationLink, Infraction,
OffTopicChannelName, Role,
SnakeFact, SnakeIdiom,
SnakeName, SpecialSnake,
- Tag
+ Tag, User
)
admin.site.register(DocumentationLink)
-admin.site.register(Member)
+admin.site.register(Infraction)
admin.site.register(OffTopicChannelName)
admin.site.register(Role)
admin.site.register(SnakeFact)
@@ -18,3 +18,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/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_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_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/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 9990e266..21b5975a 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."""
@@ -92,11 +136,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):
@@ -109,7 +155,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.'
@@ -123,52 +170,28 @@ 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 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,26 +228,70 @@ 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}"
-class Tag(ModelReprMixin, models.Model):
- """A tag providing (hopefully) useful information."""
+class Infraction(ModelReprMixin, models.Model):
+ """An infraction for a Discord user."""
- title = models.CharField(
- max_length=100,
+ 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 title of this tag, shown in searches and providing "
- "a quick overview over what this embed contains."
- ),
- primary_key=True
+ "The date and time of the expiration of this infraction. "
+ "Null if the infraction is permanent or it can't expire."
+ )
)
- embed = pgfields.JSONField(
- help_text="The actual embed shown by this tag.",
- validators=(validate_tag_embed,)
+ 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):
- return self.title
+ 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 f8d15bbf..612ce5b4 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -1,12 +1,12 @@
-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,
- Member, OffTopicChannelName,
+ DocumentationLink, Infraction,
+ OffTopicChannelName,
Role, SnakeFact,
SnakeIdiom, SnakeName,
- SpecialSnake, Tag
+ SpecialSnake, Tag, User
)
@@ -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
@@ -61,10 +97,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_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 91db2def..1419a7d7 100644
--- a/api/tests/test_models.py
+++ b/api/tests/test_models.py
@@ -1,11 +1,13 @@
+from datetime import datetime as dt, timezone
+
from django.test import SimpleTestCase
from ..models import (
- DocumentationLink, Member, ModelReprMixin,
- OffTopicChannelName, Role,
- SnakeFact, SnakeIdiom,
+ DocumentationLink, Infraction,
+ ModelReprMixin, OffTopicChannelName,
+ Role, SnakeFact, SnakeIdiom,
SnakeName, SpecialSnake,
- Tag
+ Tag, User
)
@@ -41,13 +43,22 @@ class StringDunderMethodTests(SimpleTestCase):
id=5, name='test role',
colour=0x5, permissions=0
),
- Member(
- id=5, name='bob',
- discriminator=1, avatar_hash=None
- ),
Tag(
title='bob',
embed={'content': "the builder"}
+ ),
+ User(
+ id=5, name='bob',
+ discriminator=1, avatar_hash=None
+ ),
+ 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/tests/test_members.py b/api/tests/test_users.py
index 47466b62..90bc3d30 100644
--- a/api/tests/test_members.py
+++ b/api/tests/test_users.py
@@ -1,34 +1,34 @@
from django_hosts.resolvers import reverse
from .base import APISubdomainTestCase
-from ..models import Member, Role
+from ..models import Role, User
-class UnauthedDocumentationLinkAPITests(APISubdomainTestCase):
+class UnauthedUserAPITests(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')
+ 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:member-list', host='api')
+ 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:member-list', host='api')
+ 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:member-detail', args=('whatever',), host='api')
+ url = reverse('bot:user-detail', args=('whatever',), host='api')
response = self.client.delete(url)
self.assertEqual(response.status_code, 401)
@@ -45,7 +45,7 @@ class CreationTests(APISubdomainTestCase):
)
def test_accepts_valid_data(self):
- url = reverse('bot:member-list', host='api')
+ url = reverse('bot:user-list', host='api')
data = {
'id': 42,
'avatar_hash': "validavatarhashiswear",
@@ -53,20 +53,22 @@ class CreationTests(APISubdomainTestCase):
'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 = Member.objects.get(id=42)
+ 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:member-list', host='api')
+ url = reverse('bot:user-list', host='api')
data = [
{
'id': 5,
@@ -75,14 +77,16 @@ class CreationTests(APISubdomainTestCase):
'discriminator': 42,
'roles': [
self.role.id
- ]
+ ],
+ 'in_guild': True
},
{
'id': 8,
'avatar_hash': "maybenot",
'name': "another test man",
'discriminator': 555,
- 'roles': []
+ 'roles': [],
+ 'in_guild': False
}
]
@@ -91,7 +95,7 @@ class CreationTests(APISubdomainTestCase):
self.assertEqual(response.json(), data)
def test_returns_400_for_unknown_role_id(self):
- url = reverse('bot:member-list', host='api')
+ url = reverse('bot:user-list', host='api')
data = {
'id': 5,
'avatar_hash': "hahayes",
@@ -106,7 +110,7 @@ class CreationTests(APISubdomainTestCase):
self.assertEqual(response.status_code, 400)
def test_returns_400_for_bad_data(self):
- url = reverse('bot:member-list', host='api')
+ url = reverse('bot:user-list', host='api')
data = {
'id': True,
'avatar_hash': 1902831,
diff --git a/api/urls.py b/api/urls.py
index 203b6b00..7d6a4f7d 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 (
- DeletedMessageViewSet, DocumentationLinkViewSet,
- MemberViewSet, OffTopicChannelNameViewSet,
- RoleViewSet, SnakeFactViewSet,
- SnakeIdiomViewSet, SnakeNameViewSet,
- SpecialSnakeViewSet, TagViewSet
+ DocumentationLinkViewSet, InfractionViewSet,
+ OffTopicChannelNameViewSet, RoleViewSet,
+ SnakeFactViewSet, SnakeIdiomViewSet,
+ SnakeNameViewSet, SpecialSnakeViewSet,
+ TagViewSet, UserViewSet
)
@@ -18,15 +18,15 @@ bot_router.register(
DocumentationLinkViewSet
)
bot_router.register(
+ 'infractions',
+ InfractionViewSet
+)
+bot_router.register(
'off-topic-channel-names',
OffTopicChannelNameViewSet,
base_name='offtopicchannelname'
)
bot_router.register(
- 'members',
- MemberViewSet
-)
-bot_router.register(
'roles',
RoleViewSet
)
@@ -51,6 +51,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 67e89ea6..ab62f8a7 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,18 +13,19 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
from rest_framework_bulk import BulkCreateModelMixin
from .models import (
- DocumentationLink, Member,
- OffTopicChannelName,
- Role, SnakeFact,
- SnakeIdiom, SnakeName,
- SpecialSnake, Tag
+ DocumentationLink, Infraction,
+ OffTopicChannelName, Role,
+ SnakeFact, SnakeIdiom,
+ SnakeName, SpecialSnake,
+ Tag, User
)
from .serializers import (
- DocumentationLinkSerializer, MemberSerializer,
- OffTopicChannelNameSerializer,
+ DocumentationLinkSerializer, ExpandedInfractionSerializer,
+ InfractionSerializer, OffTopicChannelNameSerializer,
RoleSerializer, SnakeFactSerializer,
SnakeIdiomSerializer, SnakeNameSerializer,
- SpecialSnakeSerializer, TagSerializer
+ SpecialSnakeSerializer, TagSerializer,
+ UserSerializer
)
@@ -89,6 +93,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/<id:int>
+ 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/<id:int>
+ 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/<snowflake:int>` 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
@@ -266,7 +408,7 @@ class RoleViewSet(ModelViewSet):
"""
queryset = Role.objects.all()
- serializer = RoleSerializer
+ serializer_class = RoleSerializer
class SnakeFactViewSet(ListModelMixin, GenericViewSet):
@@ -456,7 +598,7 @@ class TagViewSet(ModelViewSet):
- 201: returned on success
- 400: if one of the given fields is invalid
- ### PUT /bot/members/<title:str>
+ ### PUT /bot/tags/<title:str>
Update the tag with the given `title`.
#### Request body
@@ -474,7 +616,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/<title:str>
+ ### PATCH /bot/tags/<title:str>
Update the tag with the given `title`.
#### Request body
@@ -492,7 +634,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/<title:str>
+ ### DELETE /bot/tags/<title:str>
Deletes the tag with the given `title`.
#### Status codes
@@ -504,13 +646,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
>>> [
@@ -524,15 +666,16 @@ class MemberViewSet(BulkCreateModelMixin, ModelViewSet):
... 270988689419665409,
... 277546923144249364,
... 458226699344019457
- ... ]
+ ... ],
+ ... 'in_guild': True
... }
... ]
#### Status codes
- 200: returned on success
- ### GET /bot/members/<snowflake:int>
- Gets a single member by ID.
+ ### GET /bot/users/<snowflake:int>
+ Gets a single user by ID.
#### Response format
>>> {
@@ -545,16 +688,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
>>> {
@@ -562,18 +706,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/<snowflake:int>
- Update the member with the given `snowflake`.
+ ### PUT /bot/users/<snowflake:int>
+ Update the user with the given `snowflake`.
All fields in the request body are required.
#### Request body
@@ -582,16 +727,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/<snowflake:int>
- Update the member with the given `snowflake`.
+ ### PATCH /bot/users/<snowflake:int>
+ Update the user with the given `snowflake`.
All fields in the request body are optional.
#### Request body
@@ -600,21 +746,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/<snowflake:int>
- Deletes the member with the given `snowflake`.
+ ### DELETE /bot/users/<snowflake:int>
+ 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()
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:
diff --git a/pysite/settings.py b/pysite/settings.py
index 5badde5c..0175fb46 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
@@ -73,6 +73,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'django_hosts',
+ 'django_filters',
'rest_framework',
'rest_framework.authtoken'
]
@@ -207,7 +208,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'
+ )
+ )
+ )
}
}
}
diff --git a/setup.py b/setup.py
index ab4a61a2..e2f03070 100644
--- a/setup.py
+++ b/setup.py
@@ -15,10 +15,11 @@ 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',
+ 'django-filter>=2.0.0',
'psycopg2-binary>=2.7.5'
],
extras_require={