aboutsummaryrefslogtreecommitdiffstats
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
parentMerge branch 'django' into django+add-logs-api. (diff)
parentAdd an example `docker-compose.yml`. (diff)
Merge branch 'django' into django+add-logs-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
-rw-r--r--azure-pipelines.yml2
-rw-r--r--docker-compose.yml23
-rw-r--r--docker/app/alpine/3.7/Dockerfile8
-rw-r--r--docs/configuration.md25
-rw-r--r--docs/deployment.md147
-rw-r--r--pysite/settings.py4
-rw-r--r--setup.py4
27 files changed, 1247 insertions, 103 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
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 1c6a746a..1cb4846e 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -20,7 +20,7 @@ jobs:
- script: |
echo 'set -ex' > script.sh
echo 'for dockerfile in docker/**/**/**/Dockerfile; do' >> script.sh
- echo ' docker run -i hadolint/hadolint hadolint --ignore DL3008 --ignore DL3018 - < $dockerfile' >> script.sh
+ echo ' docker run -i hadolint/hadolint hadolint --ignore DL3008 --ignore DL3018 --ignore DL3019 - < $dockerfile' >> script.sh
echo 'done' >> script.sh
sh script.sh
displayName: run hadolint
diff --git a/docker-compose.yml b/docker-compose.yml
index ee302d4e..1f101c0b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,7 +10,21 @@
version: "3.6"
services:
- django:
+ bot:
+ image: pythondiscord/bot:django
+ environment:
+ BOT_TOKEN: ${BOT_TOKEN?bot token needs to be set}
+ # Set me once you configured me in the Django Admin!
+ BOT_API_KEY: ${BOT_API_KEY}
+
+ postgres:
+ image: postgres:11-alpine
+ environment:
+ POSTGRES_DB: pysite
+ POSTGRES_PASSWORD: supersecretpassword
+ POSTGRES_USER: pysite
+
+ web:
build:
context: .
dockerfile: docker/app/alpine/3.7/Dockerfile
@@ -26,9 +40,4 @@ services:
DEBUG: "true"
SECRET_KEY: suitable-for-development-only
- postgres:
- image: postgres:11-alpine
- environment:
- POSTGRES_DB: pysite
- POSTGRES_PASSWORD: supersecretpassword
- POSTGRES_USER: pysite
+# vim: sw=2 ts=2:
diff --git a/docker/app/alpine/3.7/Dockerfile b/docker/app/alpine/3.7/Dockerfile
index 4a8b5b34..a2023977 100644
--- a/docker/app/alpine/3.7/Dockerfile
+++ b/docker/app/alpine/3.7/Dockerfile
@@ -9,19 +9,19 @@ RUN adduser \
-u 1500 \
pysite
-RUN apk add --no-cache --virtual build \
+RUN apk add --no-cache --update --virtual build \
gcc \
linux-headers \
musl-dev \
&& \
- apk add --no-cache \
+ apk add \
curl \
postgresql-dev
WORKDIR /app
COPY setup.py /app/setup.py
-RUN python3 -m pip install .[$EXTRAS]
-RUN apk del --purge build
+RUN python3 -m pip install .[$EXTRAS] \
+ && apk del --purge build
COPY . .
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 00000000..037f029f
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,25 @@
+# Configuration
+The website is configured through the following environment variables:
+
+## Essential
+- **`DATABASE_URL`**: A string specifying the PostgreSQL database to connect to,
+ in the form `postgresql://user:password@host/database`, such as
+ `postgresql://joethedestroyer:ihavemnesia33@localhost/pysite_dev`
+
+- **`DEBUG`**: Controls Django's internal debugging setup. Enable this when
+ you're developing locally. Optional, defaults to `False`.
+
+- **`LOG_LEVEL`**: Any valid Python `logging` module log level - one of `DEBUG`,
+ `INFO`, `WARN`, `ERROR` or `CRITICAL`. When using debug mode, this defaults to
+ `INFO`. When testing, defaults to `ERROR`. Otherwise, defaults to `WARN`.
+
+## Deployment
+- **`ALLOWED_HOSTS`**: A comma-separated lists of alternative hosts to allow to
+ host the website on, when `DEBUG` is not set. Optional, defaults to the
+ `pythondiscord.com` family of domains.
+
+- **`SECRET_KEY`**: The secret key used in various parts of Django. Keep this
+ secret as the name suggests! This is managed for you in debug setups.
+
+- **`STATIC_ROOT`**: The root in which `python manage.py collectstatic` collects
+ static files. Optional, defaults to `/var/www/pythondiscord.com`.
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 00000000..841e08c7
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,147 @@
+# Deployment
+The default Dockerfile should do a good job at running a solid web server that
+automatically adjusts its worker count based on traffic. This is managed by
+uWSGI. You need to configure the `DATABASE_URL` and `SECRET_KEY` variables. If
+you want to deploy to a different host than the default, configure the
+`ALLOWED_HOSTS` variable.
+
+## Static file hosting
+You can either collect the static files in the container and use uWSGI to host
+them, or put them on your host and manage them through a web server running on
+the host like nginx.
+
+## Database migrations
+To bring the schema up-to-date, first stop an existing database container, then
+start a container that just runs the migrations and exits, and then starts the
+main container off the new container again.
+
+## Ansible task
+An example Ansible task to deploy the site is shown below, it should read fairly
+humanly and give you a rough idea of steps needed to deploy the site.
+
+```yml
+---
+- name: ensure the `{{ pysite_pg_username }}` postgres user exists
+ become: yes
+ become_user: postgres
+ postgresql_user:
+ name: "{{ pysite_pg_username }}"
+ password: "{{ pysite_pg_password }}"
+ when: pysite_pg_host == 'localhost'
+
+- name: ensure the `{{ pysite_pg_database }}` postgres database exists
+ become: yes
+ become_user: postgres
+ postgresql_db:
+ name: "{{ pysite_pg_database }}"
+ owner: "{{ pysite_pg_username }}"
+ when: pysite_pg_host == 'localhost'
+
+- name: ensure the `{{ pysite_hub_repository }}` image is up-to-date
+ become: yes
+ docker_image:
+ name: "{{ pysite_hub_repository }}"
+ force: yes
+
+- name: ensure the nginx HTTP vhosts are up-to-date
+ become: yes
+ template:
+ src: "nginx/{{ item.key }}.http.conf.j2"
+ dest: "/etc/nginx/sites-available/{{ item.value }}.http.conf"
+ with_dict: "{{ pysite_domains }}"
+ notify: reload nginx
+
+- name: ensure the nginx HTTPS vhosts are up-to-date
+ become: yes
+ template:
+ src: "nginx/{{ item.key }}.https.conf.j2"
+ dest: "/etc/nginx/sites-available/{{ item.value }}.https.conf"
+ with_dict: "{{ pysite_domains }}"
+ notify: reload nginx
+
+- name: ensure the nginx HTTP vhosts are symlinked to `/etc/nginx/sites-enabled`
+ become: yes
+ file:
+ src: /etc/nginx/sites-available/{{ item.value }}.http.conf
+ dest: /etc/nginx/sites-enabled/{{ item.value }}.http.conf
+ state: link
+ with_dict: "{{ pysite_domains }}"
+ notify: reload nginx
+
+- name: ensure we have HTTPS certificates
+ include_role:
+ name: thefinn93.letsencrypt
+ vars:
+ letsencrypt_cert_domains: "{{ pysite_domains | dict2items | map(attribute='value') | list }}"
+ letsencrypt_email: "[email protected]"
+ letsencrypt_renewal_command_args: '--renew-hook "systemctl restart nginx"'
+ letsencrypt_webroot_path: /var/www/_letsencrypt
+
+- name: ensure the nginx HTTPS vhosts are symlinked to `/etc/nginx/sites-enabled`
+ become: yes
+ file:
+ src: /etc/nginx/sites-available/{{ item.value }}.https.conf
+ dest: /etc/nginx/sites-enabled/{{ item.value }}.https.conf
+ state: link
+ with_dict: "{{ pysite_domains }}"
+ notify: reload nginx
+
+- name: ensure the web container is absent
+ become: yes
+ docker_container:
+ name: pysite
+ state: absent
+
+- name: ensure the `{{ pysite_static_file_dir }}` directory exists
+ become: yes
+ file:
+ path: "{{ pysite_static_file_dir }}"
+ state: directory
+ owner: root
+ group: root
+
+- name: collect static files
+ become: yes
+ docker_container:
+ image: "{{ pysite_hub_repository }}"
+ name: pysite-static-file-writer
+ command: python manage.py collectstatic --noinput
+ detach: no
+ cleanup: yes
+ network_mode: host
+ env:
+ DATABASE_URL: "{{ pysite_pg_database_url }}"
+ SECRET_KEY: "me-dont-need-no-secret-key"
+ STATIC_ROOT: "/html"
+ volumes:
+ - "/var/www/pythondiscord.com:/html"
+
+- name: ensure the database schema is up-to-date
+ become: yes
+ docker_container:
+ image: "{{ pysite_hub_repository }}"
+ name: pysite-migrator
+ detach: no
+ cleanup: yes
+ command: python manage.py migrate
+ network_mode: host
+ env:
+ DATABASE_URL: "postgres://{{ pysite_pg_username }}:{{ pysite_pg_password }}@{{ pysite_pg_host }}/{{ pysite_pg_database }}"
+ SECRET_KEY: "me-dont-need-no-secret-key"
+
+- name: ensure the website container is started
+ become: yes
+ docker_container:
+ image: "{{ pysite_hub_repository }}"
+ name: pysite
+ network_mode: host
+ restart: yes
+ restart_policy: unless-stopped
+ ports:
+ - "127.0.0.1:4000:4000"
+ env:
+ ALLOWED_HOSTS: "{{ pysite_domains | dict2items | map(attribute='value') | join(',') }}"
+ DATABASE_URL: "postgres://{{ pysite_pg_username }}:{{ pysite_pg_password }}@{{ pysite_pg_host }}/{{ pysite_pg_database }}"
+ PARENT_HOST: pysite.example.com
+ SECRET_KEY: "{{ pysite_secret_key }}"
+```
diff --git a/pysite/settings.py b/pysite/settings.py
index c3373250..b27e2a41 100644
--- a/pysite/settings.py
+++ b/pysite/settings.py
@@ -72,7 +72,9 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
+ 'crispy_forms',
'django_hosts',
+ 'django_filters',
'rest_framework',
'rest_framework.authtoken'
]
@@ -214,7 +216,7 @@ LOGGING = {
# use `DEBUG` if we're running in debug mode but not
# testing. Use `ERROR` if we're running tests, else
# default to using `WARN`.
- 'DEBUG'
+ 'INFO'
if DEBUG and 'test' not in sys.argv
else (
'ERROR'
diff --git a/setup.py b/setup.py
index ab4a61a2..a5eb9c38 100644
--- a/setup.py
+++ b/setup.py
@@ -15,10 +15,12 @@ setup(
python_requires='>= 3.6',
install_requires=[
'django>=2.1.1',
- 'djangorestframework>=3.8.2',
+ 'djangorestframework>=3.9.0',
'djangorestframework-bulk>=0.2.1',
+ 'django-crispy-forms>=1.7.2',
'django-hosts>=3.0',
'django-environ>=0.4.5',
+ 'django-filter>=2.0.0',
'psycopg2-binary>=2.7.5'
],
extras_require={