aboutsummaryrefslogtreecommitdiffstats
path: root/api
diff options
context:
space:
mode:
authorGravatar Johannes Christ <[email protected]>2019-01-20 09:52:57 +0100
committerGravatar Johannes Christ <[email protected]>2019-01-20 09:52:57 +0100
commitbdc337d70a386cae954399e55bb128119f601128 (patch)
treef1b90e9a2c028478e1b82325704ffdd943c4b3b6 /api
parentMerge branch 'django' into django+add-logs-api. (diff)
parentAdd an example `docker-compose.yml`. (diff)
Merge branch 'django' into django+add-logs-api
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/migrations/0024_add_note_infraction_type.py18
-rw-r--r--api/migrations/0025_allow_custom_inserted_at_infraction_field.py19
-rw-r--r--api/migrations/0026_use_proper_default_for_infraction_insertion_date.py19
-rw-r--r--api/migrations/0027_merge_20190120_0852.py14
-rw-r--r--api/models.py159
-rw-r--r--api/serializers.py48
-rw-r--r--api/tests/test_infractions.py359
-rw-r--r--api/tests/test_models.py33
-rw-r--r--api/tests/test_rules.py35
-rw-r--r--api/tests/test_users.py2
-rw-r--r--api/urls.py26
-rw-r--r--api/validators.py2
-rw-r--r--api/views.py136
-rw-r--r--api/viewsets.py165
20 files changed, 1048 insertions, 89 deletions
diff --git a/api/admin.py b/api/admin.py
index bcd41a7e..ab7e814e 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -2,7 +2,7 @@ from django.contrib import admin
from .models import (
DeletedMessage, DocumentationLink,
- MessageDeletionContext,
+ Infraction, MessageDeletionContext,
OffTopicChannelName, Role,
SnakeFact, SnakeIdiom,
SnakeName, SpecialSnake,
@@ -12,6 +12,7 @@ from .models import (
admin.site.register(DeletedMessage)
admin.site.register(DocumentationLink)
+admin.site.register(Infraction)
admin.site.register(MessageDeletionContext)
admin.site.register(OffTopicChannelName)
admin.site.register(Role)
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/migrations/0024_add_note_infraction_type.py b/api/migrations/0024_add_note_infraction_type.py
new file mode 100644
index 00000000..4adb53b8
--- /dev/null
+++ b/api/migrations/0024_add_note_infraction_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.4 on 2019-01-05 14:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0023_merge_infractions_snake_validators'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='infraction',
+ name='type',
+ field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9),
+ ),
+ ]
diff --git a/api/migrations/0025_allow_custom_inserted_at_infraction_field.py b/api/migrations/0025_allow_custom_inserted_at_infraction_field.py
new file mode 100644
index 00000000..0c02cb91
--- /dev/null
+++ b/api/migrations/0025_allow_custom_inserted_at_infraction_field.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.1.4 on 2019-01-06 16:01
+
+import datetime
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0024_add_note_infraction_type'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='infraction',
+ name='inserted_at',
+ field=models.DateTimeField(default=datetime.datetime.utcnow, help_text='The date and time of the creation of this infraction.'),
+ ),
+ ]
diff --git a/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py b/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py
new file mode 100644
index 00000000..56f3b2b8
--- /dev/null
+++ b/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.1.5 on 2019-01-09 19:50
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0025_allow_custom_inserted_at_infraction_field'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='infraction',
+ name='inserted_at',
+ field=models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time of the creation of this infraction.'),
+ ),
+ ]
diff --git a/api/migrations/0027_merge_20190120_0852.py b/api/migrations/0027_merge_20190120_0852.py
new file mode 100644
index 00000000..6fab4fd0
--- /dev/null
+++ b/api/migrations/0027_merge_20190120_0852.py
@@ -0,0 +1,14 @@
+# Generated by Django 2.1.5 on 2019-01-20 08:52
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0026_use_proper_default_for_infraction_insertion_date'),
+ ('api', '0021_merge_20181125_1015'),
+ ]
+
+ operations = [
+ ]
diff --git a/api/models.py b/api/models.py
index 68833328..b063b78c 100644
--- a/api/models.py
+++ b/api/models.py
@@ -3,6 +3,7 @@ from operator import itemgetter
from django.contrib.postgres import fields as pgfields
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
+from django.utils import timezone
from .validators import validate_tag_embed
@@ -60,6 +61,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."""
@@ -111,7 +156,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.'
@@ -125,48 +171,24 @@ class SpecialSnake(ModelReprMixin, models.Model):
return self.name
-class Role(ModelReprMixin, models.Model):
- """A role on our Discord server."""
+class Tag(ModelReprMixin, models.Model):
+ """A tag providing (hopefully) useful information."""
- id = models.BigIntegerField( # noqa
- primary_key=True,
- validators=(
- MinValueValidator(
- limit_value=0,
- message="Role IDs cannot be negative."
- ),
- ),
- help_text="The role ID, taken from Discord."
- )
- name = models.CharField(
+ title = models.CharField(
max_length=100,
- help_text="The role name, taken from Discord."
- )
- colour = models.IntegerField(
- validators=(
- MinValueValidator(
- limit_value=0,
- message="Colour hex cannot be negative."
- ),
+ help_text=(
+ "The title of this tag, shown in searches and providing "
+ "a quick overview over what this embed contains."
),
- help_text="The integer value of the colour of this role from Discord."
+ primary_key=True
)
- permissions = models.IntegerField(
- validators=(
- MinValueValidator(
- limit_value=0,
- message="Role permissions cannot be negative."
- ),
- MaxValueValidator(
- limit_value=2 << 32,
- message="Role permission bitset exceeds value of having all permissions"
- )
- ),
- help_text="The integer value of the permission bitset of this role from Discord."
+ embed = pgfields.JSONField(
+ help_text="The actual embed shown by this tag.",
+ validators=(validate_tag_embed,)
)
def __str__(self):
- return self.name
+ return self.title
class User(ModelReprMixin, models.Model):
@@ -286,21 +308,62 @@ class DeletedMessage(Message):
)
-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 = (
+ ("note", "Note"),
+ ("warning", "Warning"),
+ ("mute", "Mute"),
+ ("kick", "Kick"),
+ ("ban", "Ban"),
+ ("superstar", "Superstar")
+ )
+ inserted_at = models.DateTimeField(
+ default=timezone.now,
+ 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 8091ac63..764f85e5 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -1,13 +1,13 @@
-from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField
+from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError
from rest_framework_bulk import BulkSerializerMixin
from .models import (
DeletedMessage, DocumentationLink,
- MessageDeletionContext, OffTopicChannelName,
- Role, SnakeFact,
- SnakeIdiom, SnakeName,
- SpecialSnake, Tag,
- User
+ Infraction, MessageDeletionContext,
+ OffTopicChannelName, Role,
+ SnakeFact, SnakeIdiom,
+ SnakeName, SpecialSnake,
+ Tag, User
)
@@ -29,6 +29,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 968f003e..1fe60c0d 100644
--- a/api/tests/test_models.py
+++ b/api/tests/test_models.py
@@ -1,15 +1,15 @@
-from datetime import datetime
+from datetime import datetime as dt, timezone
from django.test import SimpleTestCase
from ..models import (
DeletedMessage, DocumentationLink,
- Message, MessageDeletionContext,
- ModelReprMixin, OffTopicChannelName,
- Role, SnakeFact,
- SnakeIdiom, SnakeName,
- SpecialSnake, Tag,
- User
+ Infraction, Message,
+ MessageDeletionContext, ModelReprMixin,
+ OffTopicChannelName, Role,
+ SnakeFact, SnakeIdiom,
+ SnakeName, SpecialSnake,
+ Tag, User
)
@@ -43,7 +43,7 @@ class StringDunderMethodTests(SimpleTestCase):
id=5555, name='shawn',
discriminator=555, avatar_hash=None
),
- creation=datetime.utcnow()
+ creation=dt.utcnow()
),
embeds=[]
),
@@ -77,15 +77,24 @@ class StringDunderMethodTests(SimpleTestCase):
id=5555, name='shawn',
discriminator=555, avatar_hash=None
),
- creation=datetime.utcnow()
+ creation=dt.utcnow()
+ ),
+ 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/tests/test_rules.py b/api/tests/test_rules.py
new file mode 100644
index 00000000..c94f89cc
--- /dev/null
+++ b/api/tests/test_rules.py
@@ -0,0 +1,35 @@
+from django_hosts.resolvers import reverse
+
+from .base import APISubdomainTestCase
+from ..views import RulesView
+
+
+class RuleAPITests(APISubdomainTestCase):
+ def setUp(self):
+ super().setUp()
+ self.client.force_authenticate(user=None)
+
+ def test_can_access_rules_view(self):
+ url = reverse('rules', host='api')
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIsInstance(response.json(), list)
+
+ def test_link_format_query_param_produces_different_results(self):
+ url = reverse('rules', host='api')
+ markdown_links_response = self.client.get(url + '?link_format=md')
+ html_links_response = self.client.get(url + '?link_format=html')
+ self.assertNotEqual(
+ markdown_links_response.json(),
+ html_links_response.json()
+ )
+
+ def test_format_link_raises_value_error_for_invalid_target(self):
+ with self.assertRaises(ValueError):
+ RulesView._format_link("a", "b", "c")
+
+ def test_get_returns_400_for_wrong_link_format(self):
+ url = reverse('rules', host='api')
+ response = self.client.get(url + '?link_format=unknown')
+ self.assertEqual(response.status_code, 400)
diff --git a/api/tests/test_users.py b/api/tests/test_users.py
index 8dadcbdb..90bc3d30 100644
--- a/api/tests/test_users.py
+++ b/api/tests/test_users.py
@@ -4,7 +4,7 @@ from .base import APISubdomainTestCase
from ..models import Role, User
-class UnauthedDocumentationLinkAPITests(APISubdomainTestCase):
+class UnauthedUserAPITests(APISubdomainTestCase):
def setUp(self):
super().setUp()
self.client.force_authenticate(user=None)
diff --git a/api/urls.py b/api/urls.py
index dca208d8..75980d75 100644
--- a/api/urls.py
+++ b/api/urls.py
@@ -1,13 +1,14 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
-from .views import HealthcheckView
+from .views import HealthcheckView, RulesView
from .viewsets import (
DeletedMessageViewSet, DocumentationLinkViewSet,
- OffTopicChannelNameViewSet, RoleViewSet,
- SnakeFactViewSet, SnakeIdiomViewSet,
- SnakeNameViewSet, SpecialSnakeViewSet,
- TagViewSet, UserViewSet
+ InfractionViewSet, OffTopicChannelNameViewSet,
+ RoleViewSet, SnakeFactViewSet,
+ SnakeIdiomViewSet, SnakeNameViewSet,
+ SpecialSnakeViewSet, TagViewSet,
+ UserViewSet
)
@@ -22,13 +23,17 @@ 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
+ 'roles',
+ RoleViewSet
)
bot_router.register(
'roles',
@@ -55,6 +60,10 @@ bot_router.register(
'tags',
TagViewSet
)
+bot_router.register(
+ 'users',
+ UserViewSet
+)
app_name = 'api'
urlpatterns = (
@@ -63,5 +72,6 @@ urlpatterns = (
# from django_hosts.resolvers import reverse
# snake_name_endpoint = reverse('bot:snakename-list', host='api') # `bot/` endpoints
path('bot/', include((bot_router.urls, 'api'), namespace='bot')),
- path('healthcheck', HealthcheckView.as_view(), name='healthcheck')
+ path('healthcheck', HealthcheckView.as_view(), name='healthcheck'),
+ path('rules', RulesView.as_view(), name='rules')
)
diff --git a/api/validators.py b/api/validators.py
index 2c4ffe4b..7673c3fe 100644
--- a/api/validators.py
+++ b/api/validators.py
@@ -115,7 +115,7 @@ def validate_tag_embed(embed):
'color', 'footer', 'image', 'thumbnail', 'video',
'provider', 'author', 'fields'
}
- one_required_of = {'content', 'fields', 'image', 'title', 'video'}
+ one_required_of = {'description', 'fields', 'image', 'title', 'video'}
field_validators = {
'title': (
MinLengthValidator(
diff --git a/api/views.py b/api/views.py
index c5582ec0..c529da0f 100644
--- a/api/views.py
+++ b/api/views.py
@@ -1,3 +1,4 @@
+from rest_framework.exceptions import ParseError
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -17,7 +18,7 @@ class HealthcheckView(APIView):
Seems to be.
## Authentication
- Does not require any authentication nor permissions..
+ Does not require any authentication nor permissions.
"""
authentication_classes = ()
@@ -25,3 +26,136 @@ class HealthcheckView(APIView):
def get(self, request, format=None): # noqa
return Response({'status': 'ok'})
+
+
+class RulesView(APIView):
+ """
+ Return a list of the server's rules.
+
+ ## Routes
+ ### GET /rules
+ Returns a JSON array containing the server's rules:
+
+ >>> [
+ ... "Eat candy.",
+ ... "Wake up at 4 AM.",
+ ... "Take your medicine."
+ ... ]
+
+ Since some of the the rules require links, this view
+ gives you the option to return rules in either Markdown
+ or HTML format by specifying the `link_format` query parameter
+ as either `md` or `html`. Specifying a different value than
+ `md` or `html` will return 400.
+
+ ## Authentication
+ Does not require any authentication nor permissions.
+ """
+
+ authentication_classes = ()
+ permission_classes = ()
+
+ @staticmethod
+ def _format_link(description, link, target):
+ """
+ Build the markup necessary to render `link` with `description`
+ as its description in the given `target` language.
+
+ Arguments:
+ description (str):
+ A textual description of the string. Represents the content
+ between the `<a>` tags in HTML, or the content between the
+ array brackets in Markdown.
+
+ link (str):
+ The resulting link that a user should be redirected to
+ upon clicking the generated element.
+
+ target (str):
+ One of `{'md', 'html'}`, denoting the target format that the
+ link should be rendered in.
+
+ Returns:
+ str:
+ The link, rendered appropriately for the given `target` format
+ using `description` as its textual description.
+
+ Raises:
+ ValueError:
+ If `target` is not `'md'` or `'html'`.
+ """
+
+ if target == 'html':
+ return f'<a href="{link}">{description}</a>'
+ elif target == 'md':
+ return f'[{description}]({link})'
+ else:
+ raise ValueError(
+ f"Can only template links to `html` or `md`, got `{target}`"
+ )
+
+ # `format` here is the result format, we have a link format here instead.
+ def get(self, request, format=None): # noqa
+ link_format = request.query_params.get('link_format', 'md')
+ if link_format not in ('html', 'md'):
+ raise ParseError(
+ f"`format` must be `html` or `md`, got `{format}`."
+ )
+
+ discord_community_guidelines_link = self._format_link(
+ 'Discord Community Guidelines',
+ 'https://discordapp.com/guidelines',
+ link_format
+ )
+ channels_page_link = self._format_link(
+ 'channels page',
+ 'https://pythondiscord.com/about/channels',
+ link_format
+ )
+ google_translate_link = self._format_link(
+ 'Google Translate',
+ 'https://translate.google.com/',
+ link_format
+ )
+
+ return Response([
+ "Be polite, and do not spam.",
+ f"Follow the {discord_community_guidelines_link}.",
+ (
+ "Don't intentionally make other people uncomfortable - if "
+ "someone asks you to stop discussing something, you should stop."
+ ),
+ (
+ "Be patient both with users asking "
+ "questions, and the users answering them."
+ ),
+ (
+ "We will not help you with anything that might break a law or the "
+ "terms of service of any other community, site, service, or "
+ "otherwise - No piracy, brute-forcing, captcha circumvention, "
+ "sneaker bots, or anything else of that nature."
+ ),
+ (
+ "Listen to and respect the staff members - we're "
+ "here to help, but we're all human beings."
+ ),
+ (
+ "All discussion should be kept within the relevant "
+ "channels for the subject - See the "
+ f"{channels_page_link} for more information."
+ ),
+ (
+ "This is an English-speaking server, so please speak English "
+ f"to the best of your ability - {google_translate_link} "
+ "should be fine if you're not sure."
+ ),
+ (
+ "Keep all discussions safe for work - No gore, nudity, sexual "
+ "soliciting, references to suicide, or anything else of that nature"
+ ),
+ (
+ "We do not allow advertisements for communities (including "
+ "other Discord servers) or commercial projects - Contact "
+ "us directly if you want to discuss a partnership!"
+ )
+ ])
diff --git a/api/viewsets.py b/api/viewsets.py
index e406e8c3..260d4b8a 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,17 @@ from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
from rest_framework_bulk import BulkCreateModelMixin
from .models import (
- DocumentationLink, MessageDeletionContext,
- OffTopicChannelName, Role,
- SnakeFact, SnakeIdiom,
- SnakeName, SpecialSnake,
- Tag, User
+ DocumentationLink, Infraction,
+ MessageDeletionContext, OffTopicChannelName,
+ Role, SnakeFact,
+ SnakeIdiom, SnakeName,
+ SpecialSnake, Tag,
+ User
)
from .serializers import (
- DocumentationLinkSerializer, MessageDeletionContextSerializer,
- OffTopicChannelNameSerializer, RoleSerializer,
+ DocumentationLinkSerializer, ExpandedInfractionSerializer,
+ InfractionSerializer, OffTopicChannelNameSerializer,
+ MessageDeletionContextSerializer, RoleSerializer,
SnakeFactSerializer, SnakeIdiomSerializer,
SnakeNameSerializer, SpecialSnakeSerializer,
TagSerializer, UserSerializer
@@ -124,6 +129,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
@@ -491,7 +634,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
@@ -509,7 +652,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
@@ -527,7 +670,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