diff options
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={ | 
