aboutsummaryrefslogtreecommitdiffstats
path: root/api
diff options
context:
space:
mode:
authorGravatar ImportErr <[email protected]>2018-12-08 11:55:40 +0000
committerGravatar ImportErr <[email protected]>2018-12-08 11:55:40 +0000
commit65b3381cfd35949ef457d5f794f83dbd3a4bd45c (patch)
tree1867912cf613498a4ca56fe3a0ef8660876157e3 /api
parentRenamed class in test_users (diff)
parentBump minimum DRF version to `3.9.0`. (diff)
Fixed merge conflicts
Diffstat (limited to 'api')
-rw-r--r--api/admin.py3
-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.py63
-rw-r--r--api/serializers.py40
-rw-r--r--api/tests/test_infractions.py359
-rw-r--r--api/tests/test_models.py23
-rw-r--r--api/urls.py14
-rw-r--r--api/viewsets.py149
12 files changed, 731 insertions, 19 deletions
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_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 fc031b6e..21b5975a 100644
--- a/api/models.py
+++ b/api/models.py
@@ -155,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.'
@@ -234,3 +235,63 @@ class User(ModelReprMixin, models.Model):
def __str__(self):
return f"{self.name}#{self.discriminator}"
+
+
+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,
@@ -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(
- '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 86ab5758..aa5d5fd3 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/<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