diff options
author | 2019-01-20 09:52:57 +0100 | |
---|---|---|
committer | 2019-01-20 09:52:57 +0100 | |
commit | bdc337d70a386cae954399e55bb128119f601128 (patch) | |
tree | f1b90e9a2c028478e1b82325704ffdd943c4b3b6 | |
parent | Merge branch 'django' into django+add-logs-api. (diff) | |
parent | Add an example `docker-compose.yml`. (diff) |
Merge branch 'django' into django+add-logs-api
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' @@ -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={ |