diff options
Diffstat (limited to 'api')
| -rw-r--r-- | api/admin.py | 3 | ||||
| -rw-r--r-- | api/migrations/0020_infraction.py | 30 | ||||
| -rw-r--r-- | api/migrations/0021_add_special_snake_validator.py | 19 | ||||
| -rw-r--r-- | api/migrations/0021_infraction_reason_null.py | 18 | ||||
| -rw-r--r-- | api/migrations/0022_infraction_remove_note.py | 18 | ||||
| -rw-r--r-- | api/migrations/0023_merge_infractions_snake_validators.py | 14 | ||||
| -rw-r--r-- | api/models.py | 63 | ||||
| -rw-r--r-- | api/serializers.py | 40 | ||||
| -rw-r--r-- | api/tests/test_infractions.py | 359 | ||||
| -rw-r--r-- | api/tests/test_models.py | 23 | ||||
| -rw-r--r-- | api/urls.py | 14 | ||||
| -rw-r--r-- | api/viewsets.py | 149 | 
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 | 
