diff options
| author | 2019-04-07 19:39:52 +0200 | |
|---|---|---|
| committer | 2019-04-07 19:39:52 +0200 | |
| commit | 7e9133d1ac2b4cb82a9ee0dead37f342933ce8d4 (patch) | |
| tree | 787f45a1ba5e1f16b9cd7d9e8cdf660573914151 /pydis_site/apps | |
| parent | Set `harakiri = 30`. (diff) | |
| parent | Address review by @jchristgit (diff) | |
Merge pull request #198 from gdude2002/django+176/project-layout
[Fixes #158, #160, #176, #193] Project layout changes
Diffstat (limited to 'pydis_site/apps')
80 files changed, 4256 insertions, 0 deletions
diff --git a/pydis_site/apps/__init__.py b/pydis_site/apps/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/__init__.py diff --git a/pydis_site/apps/admin/__init__.py b/pydis_site/apps/admin/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/admin/__init__.py diff --git a/pydis_site/apps/admin/urls.py b/pydis_site/apps/admin/urls.py new file mode 100644 index 00000000..146c6496 --- /dev/null +++ b/pydis_site/apps/admin/urls.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from django.urls import path + + +urlpatterns = ( +    path('', admin.site.urls), +) diff --git a/pydis_site/apps/api/__init__.py b/pydis_site/apps/api/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/api/__init__.py diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py new file mode 100644 index 00000000..3ae7f3c5 --- /dev/null +++ b/pydis_site/apps/api/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin + +from .models import ( +    BotSetting, DeletedMessage, +    DocumentationLink, Infraction, +    MessageDeletionContext, OffTopicChannelName, +    Role, SnakeFact, +    SnakeIdiom, SnakeName, +    SpecialSnake, Tag, +    User +) + + +admin.site.register(BotSetting) +admin.site.register(DeletedMessage) +admin.site.register(DocumentationLink) +admin.site.register(Infraction) +admin.site.register(MessageDeletionContext) +admin.site.register(OffTopicChannelName) +admin.site.register(Role) +admin.site.register(SnakeFact) +admin.site.register(SnakeIdiom) +admin.site.register(SnakeName) +admin.site.register(SpecialSnake) +admin.site.register(Tag) +admin.site.register(User) diff --git a/pydis_site/apps/api/apps.py b/pydis_site/apps/api/apps.py new file mode 100644 index 00000000..d87006dd --- /dev/null +++ b/pydis_site/apps/api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): +    name = 'api' diff --git a/pydis_site/apps/api/migrations/0001_initial.py b/pydis_site/apps/api/migrations/0001_initial.py new file mode 100644 index 00000000..dca6d17f --- /dev/null +++ b/pydis_site/apps/api/migrations/0001_initial.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1 on 2018-08-15 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    initial = True + +    dependencies = [ +    ] + +    operations = [ +        migrations.CreateModel( +            name='SnakeName', +            fields=[ +                ('name', models.CharField(max_length=100, primary_key=True, serialize=False)), +                ('scientific', models.CharField(max_length=150)), +            ], +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0002_documentationlink.py b/pydis_site/apps/api/migrations/0002_documentationlink.py new file mode 100644 index 00000000..5dee679a --- /dev/null +++ b/pydis_site/apps/api/migrations/0002_documentationlink.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1 on 2018-08-16 19:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0001_initial'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='DocumentationLink', +            fields=[ +                ('package', models.CharField(max_length=50, primary_key=True, serialize=False)), +                ('base_url', models.URLField()), +                ('inventory_url', models.URLField()), +            ], +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0003_offtopicchannelname.py b/pydis_site/apps/api/migrations/0003_offtopicchannelname.py new file mode 100644 index 00000000..2f19bfd8 --- /dev/null +++ b/pydis_site/apps/api/migrations/0003_offtopicchannelname.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1 on 2018-08-31 22:21 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0002_documentationlink'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='OffTopicChannelName', +            fields=[ +                ('name', models.CharField(max_length=96, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^[a-z0-9-]+$')])), +            ], +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0004_role.py b/pydis_site/apps/api/migrations/0004_role.py new file mode 100644 index 00000000..0a6b6c43 --- /dev/null +++ b/pydis_site/apps/api/migrations/0004_role.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1 on 2018-09-01 19:54 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0003_offtopicchannelname'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='Role', +            fields=[ +                ('id', models.BigIntegerField(help_text="The role's ID, taken from Discord.", primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')])), +                ('name', models.CharField(help_text="The role's name, taken from Discord.", max_length=100)), +                ('colour', models.IntegerField(help_text='The integer value of the colour of this role from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Colour hex cannot be negative.')])), +                ('permissions', models.IntegerField(help_text='The integer value of the permission bitset of this role from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role permissions cannot be negative.'), django.core.validators.MaxValueValidator(limit_value=8589934592, message='Role permission bitset exceeds value of having all permissions')])), +            ], +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0005_user.py b/pydis_site/apps/api/migrations/0005_user.py new file mode 100644 index 00000000..a771119c --- /dev/null +++ b/pydis_site/apps/api/migrations/0005_user.py @@ -0,0 +1,38 @@ +# Generated by Django 2.1 on 2018-09-01 20:02 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0004_role'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='Member', +            fields=[ +                ('id', models.BigIntegerField(help_text='The ID of this user, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='User IDs cannot be negative.')])), +                ('name', models.CharField(help_text='The username, taken from Discord.', max_length=32)), +                ('discriminator', models.PositiveSmallIntegerField(help_text='The discriminator of this user, taken from Discord.', validators=[django.core.validators.MaxValueValidator(limit_value=9999, message='Discriminators may not exceed `9999`.')])), +                ('avatar_hash', models.CharField(help_text="The user's avatar hash, taken from Discord. Null if the user does not have any custom avatar.", max_length=100, null=True)), +            ], +        ), +        migrations.AlterField( +            model_name='role', +            name='id', +            field=models.BigIntegerField(help_text='The role ID, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')]), +        ), +        migrations.AlterField( +            model_name='role', +            name='name', +            field=models.CharField(help_text='The role name, taken from Discord.', max_length=100), +        ), +        migrations.AddField( +            model_name='member', +            name='roles', +            field=models.ManyToManyField(help_text='Any roles this user has on our server.', to='api.Role'), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0006_add_help_texts.py b/pydis_site/apps/api/migrations/0006_add_help_texts.py new file mode 100644 index 00000000..a57d2289 --- /dev/null +++ b/pydis_site/apps/api/migrations/0006_add_help_texts.py @@ -0,0 +1,44 @@ +# Generated by Django 2.1.1 on 2018-09-21 20:26 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0005_user'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='documentationlink', +            name='base_url', +            field=models.URLField(help_text='The base URL from which documentation will be available for this project. Used to generate links to various symbols within this package.'), +        ), +        migrations.AlterField( +            model_name='documentationlink', +            name='inventory_url', +            field=models.URLField(help_text='The URL at which the Sphinx inventory is available for this package.'), +        ), +        migrations.AlterField( +            model_name='documentationlink', +            name='package', +            field=models.CharField(help_text='The Python package name that this documentation link belongs to.', max_length=50, primary_key=True, serialize=False), +        ), +        migrations.AlterField( +            model_name='offtopicchannelname', +            name='name', +            field=models.CharField(help_text='The actual channel name that will be used on our Discord server.', max_length=96, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^[a-z0-9-]+$')]), +        ), +        migrations.AlterField( +            model_name='snakename', +            name='name', +            field=models.CharField(help_text="The regular name for this snake, e.g. 'Python'.", max_length=100, primary_key=True, serialize=False), +        ), +        migrations.AlterField( +            model_name='snakename', +            name='scientific', +            field=models.CharField(help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", max_length=150), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0007_tag.py b/pydis_site/apps/api/migrations/0007_tag.py new file mode 100644 index 00000000..c22715f9 --- /dev/null +++ b/pydis_site/apps/api/migrations/0007_tag.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.1 on 2018-09-21 22:05 + +import pydis_site.apps.api.models +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0006_add_help_texts'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='Tag', +            fields=[ +                ('title', models.CharField(help_text='The title of this tag, shown in searches and providing a quick overview over what this embed contains.', max_length=100, primary_key=True, serialize=False)), +                ('embed', django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.')), +            ], +            bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py new file mode 100644 index 00000000..ea8f03d2 --- /dev/null +++ b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.1 on 2018-09-23 10:07 + +import pydis_site.apps.api.validators +import django.contrib.postgres.fields.jsonb +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0007_tag'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='tag', +            name='embed', +            field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.', validators=[pydis_site.apps.api.validators.validate_tag_embed]), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0009_snakefact.py b/pydis_site/apps/api/migrations/0009_snakefact.py new file mode 100644 index 00000000..4fc63bc9 --- /dev/null +++ b/pydis_site/apps/api/migrations/0009_snakefact.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.2 on 2018-10-11 14:25 + +import pydis_site.apps.api.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0008_tag_embed_validator'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='SnakeFact', +            fields=[ +                ('fact', models.CharField(help_text='A fact about snakes.', max_length=200, primary_key=True, serialize=False)), +            ], +            bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0010_snakeidiom.py b/pydis_site/apps/api/migrations/0010_snakeidiom.py new file mode 100644 index 00000000..be089cf4 --- /dev/null +++ b/pydis_site/apps/api/migrations/0010_snakeidiom.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.2 on 2018-10-19 16:27 + +import pydis_site.apps.api.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0009_snakefact'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='SnakeIdiom', +            fields=[ +                ('idiom', models.CharField(help_text='A snake idiom', max_length=140, primary_key=True, serialize=False)), +            ], +            bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0011_auto_20181020_1904.py b/pydis_site/apps/api/migrations/0011_auto_20181020_1904.py new file mode 100644 index 00000000..bb5a6325 --- /dev/null +++ b/pydis_site/apps/api/migrations/0011_auto_20181020_1904.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.2 on 2018-10-20 19:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0010_snakeidiom'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='snakeidiom', +            name='idiom', +            field=models.CharField(help_text='A saying about a snake.', max_length=140, primary_key=True, serialize=False), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0012_specialsnake.py b/pydis_site/apps/api/migrations/0012_specialsnake.py new file mode 100644 index 00000000..77072526 --- /dev/null +++ b/pydis_site/apps/api/migrations/0012_specialsnake.py @@ -0,0 +1,22 @@ +# Generated by Django 2.1.2 on 2018-10-22 09:53 + +import pydis_site.apps.api.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0011_auto_20181020_1904'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='SpecialSnake', +            fields=[ +                ('name', models.CharField(max_length=140, primary_key=True, serialize=False)), +                ('info', models.TextField()), +            ], +            bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0013_specialsnake_image.py b/pydis_site/apps/api/migrations/0013_specialsnake_image.py new file mode 100644 index 00000000..a0d0d318 --- /dev/null +++ b/pydis_site/apps/api/migrations/0013_specialsnake_image.py @@ -0,0 +1,21 @@ +# Generated by Django 2.1.2 on 2018-10-23 11:51 + +import datetime +from django.db import migrations, models +from django.utils.timezone import utc + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0012_specialsnake'), +    ] + +    operations = [ +        migrations.AddField( +            model_name='specialsnake', +            name='image', +            field=models.URLField(default=datetime.datetime(2018, 10, 23, 11, 51, 23, 703868, tzinfo=utc)), +            preserve_default=False, +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0014_auto_20181025_1959.py b/pydis_site/apps/api/migrations/0014_auto_20181025_1959.py new file mode 100644 index 00000000..3599d2cd --- /dev/null +++ b/pydis_site/apps/api/migrations/0014_auto_20181025_1959.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.2 on 2018-10-25 19:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0013_specialsnake_image'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='specialsnake', +            name='info', +            field=models.TextField(help_text='Info about a special snake.'), +        ), +        migrations.AlterField( +            model_name='specialsnake', +            name='name', +            field=models.CharField(help_text='A special snake name.', max_length=140, primary_key=True, serialize=False), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0015_auto_20181027_1617.py b/pydis_site/apps/api/migrations/0015_auto_20181027_1617.py new file mode 100644 index 00000000..8973ff6d --- /dev/null +++ b/pydis_site/apps/api/migrations/0015_auto_20181027_1617.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.2 on 2018-10-27 16:17 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0014_auto_20181025_1959'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='specialsnake', +            name='image', +            field=django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), size=None), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0016_auto_20181027_1619.py b/pydis_site/apps/api/migrations/0016_auto_20181027_1619.py new file mode 100644 index 00000000..b8bdfb16 --- /dev/null +++ b/pydis_site/apps/api/migrations/0016_auto_20181027_1619.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.2 on 2018-10-27 16:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0015_auto_20181027_1617'), +    ] + +    operations = [ +        migrations.RenameField( +            model_name='specialsnake', +            old_name='image', +            new_name='images', +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0017_auto_20181029_1921.py b/pydis_site/apps/api/migrations/0017_auto_20181029_1921.py new file mode 100644 index 00000000..012bda61 --- /dev/null +++ b/pydis_site/apps/api/migrations/0017_auto_20181029_1921.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.2 on 2018-10-29 19:21 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0016_auto_20181027_1619'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='specialsnake', +            name='images', +            field=django.contrib.postgres.fields.ArrayField(base_field=models.URLField(), help_text='Images displaying this special snake.', size=None), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py b/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py new file mode 100644 index 00000000..dced1288 --- /dev/null +++ b/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py @@ -0,0 +1,24 @@ +# Generated by Django 2.1.1 on 2018-11-18 20:12 + +import pydis_site.apps.api.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0017_auto_20181029_1921'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='MessageDeletionContext', +            fields=[ +                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), +                ('creation', models.DateTimeField(help_text='When this deletion took place.')), +                ('actor', models.ForeignKey(help_text='The original actor causing this deletion. Could be the author of a manual clean command invocation, the bot when executing automatic actions, or nothing to indicate that the bulk deletion was not issued by us.', null=True, on_delete=django.db.models.deletion.CASCADE, to='api.User')), +            ], +            bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0018_user_rename.py b/pydis_site/apps/api/migrations/0018_user_rename.py new file mode 100644 index 00000000..f88eb5bc --- /dev/null +++ b/pydis_site/apps/api/migrations/0018_user_rename.py @@ -0,0 +1,17 @@ +# Generated by Django 2.1.3 on 2018-11-19 20:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0017_auto_20181029_1921'), +    ] + +    operations = [ +        migrations.RenameModel( +            old_name='Member', +            new_name='User', +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py new file mode 100644 index 00000000..f451ecf4 --- /dev/null +++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py @@ -0,0 +1,31 @@ +# Generated by Django 2.1.1 on 2018-11-18 20:26 + +import pydis_site.apps.api.models +import pydis_site.apps.api.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0018_messagedeletioncontext'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='DeletedMessage', +            fields=[ +                ('id', models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')])), +                ('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), +                ('content', models.CharField(help_text='The content of this message, taken from Discord.', max_length=2000)), +                ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.validators.validate_tag_embed]), help_text='Embeds attached to this message.', size=None)), +                ('author', models.ForeignKey(help_text='The author of this message.', on_delete=django.db.models.deletion.CASCADE, to='api.User')), +                ('deletion_context', models.ForeignKey(help_text='The deletion context this message is part of.', on_delete=django.db.models.deletion.CASCADE, to='api.MessageDeletionContext')), +            ], +            options={ +                'abstract': False, +            }, +            bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0019_user_in_guild.py b/pydis_site/apps/api/migrations/0019_user_in_guild.py new file mode 100644 index 00000000..fda008c4 --- /dev/null +++ b/pydis_site/apps/api/migrations/0019_user_in_guild.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.3 on 2018-11-19 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0018_user_rename'), +    ] + +    operations = [ +        migrations.AddField( +            model_name='user', +            name='in_guild', +            field=models.BooleanField(default=True, help_text='Whether this user is in our server.'), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0020_add_snake_field_validators.py b/pydis_site/apps/api/migrations/0020_add_snake_field_validators.py new file mode 100644 index 00000000..3b625f9b --- /dev/null +++ b/pydis_site/apps/api/migrations/0020_add_snake_field_validators.py @@ -0,0 +1,24 @@ +# Generated by Django 2.1.2 on 2018-11-24 17:11 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0019_user_in_guild'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='snakename', +            name='name', +            field=models.CharField(help_text="The regular name for this snake, e.g. 'Python'.", max_length=100, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), +        ), +        migrations.AlterField( +            model_name='snakename', +            name='scientific', +            field=models.CharField(help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", max_length=150, validators=[django.core.validators.RegexValidator(regex='^([^0-9])+$')]), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0020_infraction.py b/pydis_site/apps/api/migrations/0020_infraction.py new file mode 100644 index 00000000..6bef6b77 --- /dev/null +++ b/pydis_site/apps/api/migrations/0020_infraction.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1.3 on 2018-11-19 22:02 + +import pydis_site.apps.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=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0021_add_special_snake_validator.py b/pydis_site/apps/api/migrations/0021_add_special_snake_validator.py new file mode 100644 index 00000000..d41b96e5 --- /dev/null +++ b/pydis_site/apps/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/pydis_site/apps/api/migrations/0021_infraction_reason_null.py b/pydis_site/apps/api/migrations/0021_infraction_reason_null.py new file mode 100644 index 00000000..6600f230 --- /dev/null +++ b/pydis_site/apps/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/pydis_site/apps/api/migrations/0021_merge_20181125_1015.py b/pydis_site/apps/api/migrations/0021_merge_20181125_1015.py new file mode 100644 index 00000000..d8eaa510 --- /dev/null +++ b/pydis_site/apps/api/migrations/0021_merge_20181125_1015.py @@ -0,0 +1,14 @@ +# Generated by Django 2.1.1 on 2018-11-25 10:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0020_add_snake_field_validators'), +        ('api', '0019_deletedmessage'), +    ] + +    operations = [ +    ] diff --git a/pydis_site/apps/api/migrations/0022_infraction_remove_note.py b/pydis_site/apps/api/migrations/0022_infraction_remove_note.py new file mode 100644 index 00000000..eba84610 --- /dev/null +++ b/pydis_site/apps/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/pydis_site/apps/api/migrations/0023_merge_infractions_snake_validators.py b/pydis_site/apps/api/migrations/0023_merge_infractions_snake_validators.py new file mode 100644 index 00000000..916f78f2 --- /dev/null +++ b/pydis_site/apps/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/pydis_site/apps/api/migrations/0024_add_note_infraction_type.py b/pydis_site/apps/api/migrations/0024_add_note_infraction_type.py new file mode 100644 index 00000000..4adb53b8 --- /dev/null +++ b/pydis_site/apps/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/pydis_site/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py b/pydis_site/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py new file mode 100644 index 00000000..0c02cb91 --- /dev/null +++ b/pydis_site/apps/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/pydis_site/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py b/pydis_site/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py new file mode 100644 index 00000000..56f3b2b8 --- /dev/null +++ b/pydis_site/apps/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/pydis_site/apps/api/migrations/0027_merge_20190120_0852.py b/pydis_site/apps/api/migrations/0027_merge_20190120_0852.py new file mode 100644 index 00000000..6fab4fd0 --- /dev/null +++ b/pydis_site/apps/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/pydis_site/apps/api/migrations/0028_allow_message_content_blank.py b/pydis_site/apps/api/migrations/0028_allow_message_content_blank.py new file mode 100644 index 00000000..6d57db27 --- /dev/null +++ b/pydis_site/apps/api/migrations/0028_allow_message_content_blank.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.5 on 2019-01-20 09:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0027_merge_20190120_0852'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='deletedmessage', +            name='content', +            field=models.CharField(blank=True, help_text='The content of this message, taken from Discord.', max_length=2000), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0029_add_infraction_type_watch.py b/pydis_site/apps/api/migrations/0029_add_infraction_type_watch.py new file mode 100644 index 00000000..c6f88a11 --- /dev/null +++ b/pydis_site/apps/api/migrations/0029_add_infraction_type_watch.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.5 on 2019-01-20 11:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0028_allow_message_content_blank'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='infraction', +            name='type', +            field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('mute', 'Mute'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar')], help_text='The type of the infraction.', max_length=9), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0030_reminder.py b/pydis_site/apps/api/migrations/0030_reminder.py new file mode 100644 index 00000000..8c42f6dc --- /dev/null +++ b/pydis_site/apps/api/migrations/0030_reminder.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.5 on 2019-01-22 22:17 + +import pydis_site.apps.api.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0029_add_infraction_type_watch'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='Reminder', +            fields=[ +                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), +                ('active', models.BooleanField(default=True, help_text='Whether this reminder is still active. If not, it has been sent out to the user.')), +                ('channel_id', models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), +                ('content', models.CharField(help_text='The content that the user wants to be reminded of.', max_length=1500)), +                ('expiration', models.DateTimeField(help_text='When this reminder should be sent.')), +                ('author', models.ForeignKey(help_text='The creator of this reminder.', on_delete=django.db.models.deletion.CASCADE, to='api.User')), +            ], +            bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0031_nomination.py b/pydis_site/apps/api/migrations/0031_nomination.py new file mode 100644 index 00000000..75e69701 --- /dev/null +++ b/pydis_site/apps/api/migrations/0031_nomination.py @@ -0,0 +1,26 @@ +# Generated by Django 2.1.5 on 2019-01-27 11:01 + +import pydis_site.apps.api.models +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0030_reminder'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='Nomination', +            fields=[ +                ('active', models.BooleanField(default=True, help_text='Whether this nomination is still relevant.')), +                ('reason', models.TextField(help_text='Why this user was nominated.')), +                ('user', models.OneToOneField(help_text='The nominated user.', on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='nomination', serialize=False, to='api.User')), +                ('inserted_at', models.DateTimeField(auto_now_add=True, help_text='The creation date of this nomination.')), +                ('author', models.ForeignKey(help_text='The staff member that nominated this user.', on_delete=django.db.models.deletion.CASCADE, related_name='nomination_set', to='api.User')), +            ], +            bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0032_botsetting.py b/pydis_site/apps/api/migrations/0032_botsetting.py new file mode 100644 index 00000000..25186a2b --- /dev/null +++ b/pydis_site/apps/api/migrations/0032_botsetting.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.5 on 2019-02-07 19:03 + +import pydis_site.apps.api.models +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0031_nomination'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='BotSetting', +            fields=[ +                ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), +                ('data', django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual settings of this setting.')), +            ], +            bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0033_create_defcon_settings.py b/pydis_site/apps/api/migrations/0033_create_defcon_settings.py new file mode 100644 index 00000000..830f3fb0 --- /dev/null +++ b/pydis_site/apps/api/migrations/0033_create_defcon_settings.py @@ -0,0 +1,30 @@ +# Generated by Django 2.1.5 on 2019-02-18 19:30 + +from django.db import migrations + + +def up(apps, schema_editor): +    BotSetting = apps.get_model('api', 'BotSetting') +    setting = BotSetting( +        name='defcon', +        data={ +            'enabled': False, +            'days': 0 +        } +    ).save() + + +def down(apps, schema_editor):  # pragma: no cover - not necessary to test +    BotSetting = apps.get_model('api', 'BotSetting') +    BotSetting.get(name='defcon').delete() + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0032_botsetting'), +    ] + +    operations = [ +        migrations.RunPython(up, down) +    ] diff --git a/pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py b/pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py new file mode 100644 index 00000000..bd370d8e --- /dev/null +++ b/pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.5 on 2019-02-18 19:41 + +import pydis_site.apps.api.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0033_create_defcon_settings'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='botsetting', +            name='name', +            field=models.CharField(max_length=50, primary_key=True, serialize=False, validators=[ +                pydis_site.apps.api.validators.validate_bot_setting_name]), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/__init__.py b/pydis_site/apps/api/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/api/migrations/__init__.py diff --git a/pydis_site/apps/api/models.py b/pydis_site/apps/api/models.py new file mode 100644 index 00000000..86c99f86 --- /dev/null +++ b/pydis_site/apps/api/models.py @@ -0,0 +1,452 @@ +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_bot_setting_name, validate_tag_embed + + +class ModelReprMixin: +    """ +    Adds a `__repr__` method to the model subclassing this +    mixin which will display the model's class name along +    with all parameters used to construct the object. +    """ + +    def __repr__(self): +        attributes = ' '.join( +            f'{attribute}={value!r}' +            for attribute, value in sorted( +                self.__dict__.items(), +                key=itemgetter(0) +            ) +            if not attribute.startswith('_') +        ) +        return f'<{self.__class__.__name__}({attributes})>' + + +class BotSetting(ModelReprMixin, models.Model): +    """A configuration entry for the bot.""" + +    name = models.CharField( +        primary_key=True, +        max_length=50, +        validators=(validate_bot_setting_name,) +    ) +    data = pgfields.JSONField( +        help_text="The actual settings of this setting." +    ) + + +class DocumentationLink(ModelReprMixin, models.Model): +    """A documentation link used by the `!docs` command of the bot.""" + +    package = models.CharField( +        primary_key=True, +        max_length=50, +        help_text="The Python package name that this documentation link belongs to." +    ) +    base_url = models.URLField( +        help_text=( +            "The base URL from which documentation will be available for this project. " +            "Used to generate links to various symbols within this package." +        ) +    ) +    inventory_url = models.URLField( +        help_text="The URL at which the Sphinx inventory is available for this package." +    ) + +    def __str__(self): +        return f"{self.package} - {self.base_url}" + + +class OffTopicChannelName(ModelReprMixin, models.Model): +    name = models.CharField( +        primary_key=True, +        max_length=96, +        validators=(RegexValidator(regex=r'^[a-z0-9-]+$'),), +        help_text="The actual channel name that will be used on our Discord server." +    ) + +    def __str__(self): +        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.""" + +    fact = models.CharField( +        primary_key=True, +        max_length=200, +        help_text="A fact about snakes." +    ) + +    def __str__(self): +        return self.fact + + +class SnakeIdiom(ModelReprMixin, models.Model): +    """A snake idiom used by the snake cog.""" + +    idiom = models.CharField( +        primary_key=True, +        max_length=140, +        help_text="A saying about a snake." +    ) + +    def __str__(self): +        return self.idiom + + +class SnakeName(ModelReprMixin, models.Model): +    """A snake name used by the bot's snake cog.""" + +    name = models.CharField( +        primary_key=True, +        max_length=100, +        help_text="The regular name for this snake, e.g. 'Python'.", +        validators=[RegexValidator(regex=r'^([^0-9])+$')] +    ) +    scientific = models.CharField( +        max_length=150, +        help_text="The scientific name for this snake, e.g. 'Python bivittatus'.", +        validators=[RegexValidator(regex=r'^([^0-9])+$')] +    ) + +    def __str__(self): +        return f"{self.name} ({self.scientific})" + + +class SpecialSnake(ModelReprMixin, models.Model): +    """A special snake's name, info and image from our database used by the bot's snake cog.""" + +    name = models.CharField( +        max_length=140, +        primary_key=True, +        help_text='A special snake name.', +        validators=[RegexValidator(regex=r'^([^0-9])+$')] +    ) +    info = models.TextField( +        help_text='Info about a special snake.' +    ) +    images = pgfields.ArrayField( +        models.URLField(), +        help_text='Images displaying this special snake.' +    ) + +    def __str__(self): +        return self.name + + +class Tag(ModelReprMixin, models.Model): +    """A tag providing (hopefully) useful information.""" + +    title = models.CharField( +        max_length=100, +        help_text=( +            "The title of this tag, shown in searches and providing " +            "a quick overview over what this embed contains." +        ), +        primary_key=True +    ) +    embed = pgfields.JSONField( +        help_text="The actual embed shown by this tag.", +        validators=(validate_tag_embed,) +    ) + +    def __str__(self): +        return self.title + + +class User(ModelReprMixin, models.Model): +    """A Discord user.""" + +    id = models.BigIntegerField(  # noqa +        primary_key=True, +        validators=( +            MinValueValidator( +                limit_value=0, +                message="User IDs cannot be negative." +            ), +        ), +        help_text="The ID of this user, taken from Discord." +    ) +    name = models.CharField( +        max_length=32, +        help_text="The username, taken from Discord." +    ) +    discriminator = models.PositiveSmallIntegerField( +        validators=( +            MaxValueValidator( +                limit_value=9999, +                message="Discriminators may not exceed `9999`." +            ), +        ), +        help_text="The discriminator of this user, taken from Discord." +    ) +    avatar_hash = models.CharField( +        max_length=100, +        help_text=( +            "The user's avatar hash, taken from Discord. " +            "Null if the user does not have any custom avatar." +        ), +        null=True +    ) +    roles = models.ManyToManyField( +        Role, +        help_text="Any roles this user has on our server." +    ) +    in_guild = models.BooleanField( +        default=True, +        help_text="Whether this user is in our server." +    ) + +    def __str__(self): +        return f"{self.name}#{self.discriminator}" + + +class Message(ModelReprMixin, models.Model): +    id = models.BigIntegerField( +        primary_key=True, +        help_text="The message ID as taken from Discord.", +        validators=( +            MinValueValidator( +                limit_value=0, +                message="Message IDs cannot be negative." +            ), +        ) +    ) +    author = models.ForeignKey( +        User, +        on_delete=models.CASCADE, +        help_text="The author of this message." +    ) +    channel_id = models.BigIntegerField( +        help_text=( +            "The channel ID that this message was " +            "sent in, taken from Discord." +        ), +        validators=( +            MinValueValidator( +                limit_value=0, +                message="Channel IDs cannot be negative." +            ), +        ) +    ) +    content = models.CharField( +        max_length=2_000, +        help_text="The content of this message, taken from Discord.", +        blank=True +    ) +    embeds = pgfields.ArrayField( +        pgfields.JSONField( +            validators=(validate_tag_embed,) +        ), +        help_text="Embeds attached to this message." +    ) + +    class Meta: +        abstract = True + + +class MessageDeletionContext(ModelReprMixin, models.Model): +    actor = models.ForeignKey( +        User, +        on_delete=models.CASCADE, +        help_text=( +            "The original actor causing this deletion. Could be the author " +            "of a manual clean command invocation, the bot when executing " +            "automatic actions, or nothing to indicate that the bulk " +            "deletion was not issued by us." +        ), +        null=True +    ) +    creation = models.DateTimeField( +        # Consider whether we want to add a validator here that ensures +        # the deletion context does not take place in the future. +        help_text="When this deletion took place." +    ) + + +class DeletedMessage(Message): +    deletion_context = models.ForeignKey( +        MessageDeletionContext, +        help_text="The deletion context this message is part of.", +        on_delete=models.CASCADE +    ) + + +class Infraction(ModelReprMixin, models.Model): +    """An infraction for a Discord user.""" + +    TYPE_CHOICES = ( +        ("note", "Note"), +        ("warning", "Warning"), +        ("watch", "Watch"), +        ("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 date and time of the expiration of this infraction. " +            "Null if the infraction is permanent or it can't expire." +        ) +    ) +    active = models.BooleanField( +        default=True, +        help_text="Whether the infraction is still active." +    ) +    user = models.ForeignKey( +        User, +        on_delete=models.CASCADE, +        related_name='infractions_received', +        help_text="The user to which the infraction was applied." +    ) +    actor = models.ForeignKey( +        User, +        on_delete=models.CASCADE, +        related_name='infractions_given', +        help_text="The user which applied the infraction." +    ) +    type = models.CharField( +        max_length=9, +        choices=TYPE_CHOICES, +        help_text="The type of the infraction." +    ) +    reason = models.TextField( +        null=True, +        help_text="The reason for the infraction." +    ) +    hidden = models.BooleanField( +        default=False, +        help_text="Whether the infraction is a shadow infraction." +    ) + +    def __str__(self): +        s = f"#{self.id}: {self.type} on {self.user_id}" +        if self.expires_at: +            s += f" until {self.expires_at}" +        if self.hidden: +            s += " (hidden)" +        return s + + +class Reminder(ModelReprMixin, models.Model): +    """A reminder created by a user.""" + +    active = models.BooleanField( +        default=True, +        help_text=( +            "Whether this reminder is still active. " +            "If not, it has been sent out to the user." +        ) +    ) +    author = models.ForeignKey( +        User, +        on_delete=models.CASCADE, +        help_text="The creator of this reminder." +    ) +    channel_id = models.BigIntegerField( +        help_text=( +            "The channel ID that this message was " +            "sent in, taken from Discord." +        ), +        validators=( +            MinValueValidator( +                limit_value=0, +                message="Channel IDs cannot be negative." +            ), +        ) +    ) +    content = models.CharField( +        max_length=1500, +        help_text="The content that the user wants to be reminded of." +    ) +    expiration = models.DateTimeField( +        help_text="When this reminder should be sent." +    ) + +    def __str__(self): +        return f"{self.content} on {self.expiration} by {self.author}" + + +class Nomination(ModelReprMixin, models.Model): +    """A helper nomination created by staff.""" + +    active = models.BooleanField( +        default=True, +        help_text="Whether this nomination is still relevant." +    ) +    author = models.ForeignKey( +        User, +        on_delete=models.CASCADE, +        help_text="The staff member that nominated this user.", +        related_name='nomination_set' +    ) +    reason = models.TextField( +        help_text="Why this user was nominated." +    ) +    user = models.OneToOneField( +        User, +        on_delete=models.CASCADE, +        help_text="The nominated user.", +        primary_key=True, +        related_name='nomination' +    ) +    inserted_at = models.DateTimeField( +        auto_now_add=True, +        help_text="The creation date of this nomination." +    ) diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py new file mode 100644 index 00000000..9a92313a --- /dev/null +++ b/pydis_site/apps/api/serializers.py @@ -0,0 +1,174 @@ +from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError +from rest_framework.validators import UniqueValidator +from rest_framework_bulk import BulkSerializerMixin + +from .models import ( +    BotSetting, DeletedMessage, +    DocumentationLink, Infraction, +    MessageDeletionContext, Nomination, +    OffTopicChannelName, Reminder, +    Role, SnakeFact, +    SnakeIdiom, SnakeName, +    SpecialSnake, Tag, +    User +) + + +class BotSettingSerializer(ModelSerializer): +    class Meta: +        model = BotSetting +        fields = ('name', 'data') + + +class DeletedMessageSerializer(ModelSerializer): +    author = PrimaryKeyRelatedField( +        queryset=User.objects.all() +    ) +    deletion_context = PrimaryKeyRelatedField( +        queryset=MessageDeletionContext.objects.all(), +        # This will be overriden in the `create` function +        # of the deletion context serializer. +        required=False +    ) + +    class Meta: +        model = DeletedMessage +        fields = ( +            'id', 'author', +            'channel_id', 'content', +            'embeds', 'deletion_context' +        ) + + +class MessageDeletionContextSerializer(ModelSerializer): +    deletedmessage_set = DeletedMessageSerializer(many=True) + +    class Meta: +        model = MessageDeletionContext +        fields = ('actor', 'creation', 'id', 'deletedmessage_set') +        depth = 1 + +    def create(self, validated_data): +        messages = validated_data.pop('deletedmessage_set') +        deletion_context = MessageDeletionContext.objects.create(**validated_data) +        for message in messages: +            DeletedMessage.objects.create( +                deletion_context=deletion_context, +                **message +            ) + +        return deletion_context + + +class DocumentationLinkSerializer(ModelSerializer): +    class Meta: +        model = DocumentationLink +        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 +        fields = ('name',) + +    def to_representation(self, obj): +        return obj.name + + +class SnakeFactSerializer(ModelSerializer): +    class Meta: +        model = SnakeFact +        fields = ('fact',) + + +class SnakeIdiomSerializer(ModelSerializer): +    class Meta: +        model = SnakeIdiom +        fields = ('idiom',) + + +class SnakeNameSerializer(ModelSerializer): +    class Meta: +        model = SnakeName +        fields = ('name', 'scientific') + + +class SpecialSnakeSerializer(ModelSerializer): +    class Meta: +        model = SpecialSnake +        fields = ('name', 'images', 'info') + + +class ReminderSerializer(ModelSerializer): +    author = PrimaryKeyRelatedField(queryset=User.objects.all()) + +    class Meta: +        model = Reminder +        fields = ('active', 'author', 'channel_id', 'content', 'expiration', 'id') + + +class RoleSerializer(ModelSerializer): +    class Meta: +        model = Role +        fields = ('id', 'name', 'colour', 'permissions') + + +class TagSerializer(ModelSerializer): +    class Meta: +        model = Tag +        fields = ('title', 'embed') + + +class UserSerializer(BulkSerializerMixin, ModelSerializer): +    roles = PrimaryKeyRelatedField(many=True, queryset=Role.objects.all(), required=False) + +    class Meta: +        model = User +        fields = ('id', 'avatar_hash', 'name', 'discriminator', 'roles', 'in_guild') +        depth = 1 + + +class NominationSerializer(ModelSerializer): +    author = PrimaryKeyRelatedField(queryset=User.objects.all()) +    user = PrimaryKeyRelatedField(queryset=User.objects.all()) + +    class Meta: +        model = Nomination +        fields = ('active', 'author', 'reason', 'user', 'inserted_at') +        depth = 1 diff --git a/pydis_site/apps/api/tests/__init__.py b/pydis_site/apps/api/tests/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/api/tests/__init__.py diff --git a/pydis_site/apps/api/tests/base.py b/pydis_site/apps/api/tests/base.py new file mode 100644 index 00000000..8f8ace56 --- /dev/null +++ b/pydis_site/apps/api/tests/base.py @@ -0,0 +1,69 @@ +from django.contrib.auth.models import User +from rest_framework.test import APITestCase + + +test_user, _created = User.objects.get_or_create( +    username='test', +    email='[email protected]', +    password='testpass',  # noqa: S106 +    is_superuser=True, +    is_staff=True +) + + +class APISubdomainTestCase(APITestCase): +    """ +    Configures the test client to use the proper subdomain +    for requests and forces authentication for the test user. + +    The test user is considered staff and superuser. +    If you want to test for a custom user (for example, to test model permissions), +    create the user, assign the relevant permissions, and use +    `self.client.force_authenticate(user=created_user)` to force authentication +    through the created user. + +    Using this performs the following niceties for you which ease writing tests: +    - setting the `HTTP_HOST` request header to `api.pythondiscord.local:8000`, and +    - forcing authentication for the test user. +    If you don't want to force authentication (for example, to test a route's response +    for an unauthenticated user), un-force authentication by using the following: + +    >>> from pydis_site.apps.api.tests.base import APISubdomainTestCase +    >>> class UnauthedUserTestCase(APISubdomainTestCase): +    ...     def setUp(self): +    ...         super().setUp() +    ...         self.client.force_authentication(user=None) +    ...     def test_can_read_objects_at_my_endpoint(self): +    ...         resp = self.client.get('/my-publicly-readable-endpoint') +    ...         self.assertEqual(resp.status_code, 200) +    ...     def test_cannot_delete_objects_at_my_endpoint(self): +    ...         resp = self.client.delete('/my-publicly-readable-endpoint/42') +    ...         self.assertEqual(resp.status_code, 401) + +    Make sure to include the `super().setUp(self)` call, otherwise, you may get +    status code 404 for some URLs due to the missing `HTTP_HOST` header. + +    ## Example +    Using this in a test case is rather straightforward: + +    >>> from pydis_site.apps.api.tests.base import APISubdomainTestCase +    >>> class MyAPITestCase(APISubdomainTestCase): +    ...     def test_that_it_works(self): +    ...         response = self.client.get('/my-endpoint') +    ...         self.assertEqual(response.status_code, 200) + +    To reverse URLs of the API host, you need to use `django_hosts`: + +    >>> from django_hosts.resolvers import reverse +    >>> from pydis_site.apps.api.tests.base import APISubdomainTestCase +    >>> class MyReversedTestCase(APISubdomainTestCase): +    ...     def test_my_endpoint(self): +    ...         url = reverse('user-detail', host='api') +    ...         response = self.client.get(url) +    ...         self.assertEqual(response.status_code, 200) +    """ + +    def setUp(self): +        super().setUp() +        self.client.defaults['HTTP_HOST'] = 'api.pythondiscord.local:8000' +        self.client.force_authenticate(test_user) diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py new file mode 100644 index 00000000..cd5acab0 --- /dev/null +++ b/pydis_site/apps/api/tests/test_deleted_messages.py @@ -0,0 +1,43 @@ +from datetime import datetime + +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import User + + +class DeletedMessagesTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        cls.author = User.objects.create( +            id=55, +            name='Robbie Rotten', +            discriminator=55, +            avatar_hash=None +        ) + +        cls.data = { +            'actor': None, +            'creation': datetime.utcnow().isoformat(), +            'deletedmessage_set': [ +                { +                    'author': cls.author.id, +                    'id': 55, +                    'channel_id': 5555, +                    'content': "Terror Billy is a meanie", +                    'embeds': [] +                }, +                { +                    'author': cls.author.id, +                    'id': 56, +                    'channel_id': 5555, +                    'content': "If you purge this, you're evil", +                    'embeds': [] +                } +            ] +        } + +    def test_accepts_valid_data(self): +        url = reverse('bot:messagedeletioncontext-list', host='api') +        response = self.client.post(url, data=self.data) +        self.assertEqual(response.status_code, 201) diff --git a/pydis_site/apps/api/tests/test_documentation_links.py b/pydis_site/apps/api/tests/test_documentation_links.py new file mode 100644 index 00000000..f6c78391 --- /dev/null +++ b/pydis_site/apps/api/tests/test_documentation_links.py @@ -0,0 +1,161 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import DocumentationLink + + +class UnauthedDocumentationLinkAPITests(APISubdomainTestCase): +    def setUp(self): +        super().setUp() +        self.client.force_authenticate(user=None) + +    def test_detail_lookup_returns_401(self): +        url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 401) + +    def test_list_returns_401(self): +        url = reverse('bot:documentationlink-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 401) + +    def test_create_returns_401(self): +        url = reverse('bot:documentationlink-list', host='api') +        response = self.client.post(url, data={'hi': 'there'}) + +        self.assertEqual(response.status_code, 401) + +    def test_delete_returns_401(self): +        url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') +        response = self.client.delete(url) + +        self.assertEqual(response.status_code, 401) + + +class EmptyDatabaseDocumentationLinkAPITests(APISubdomainTestCase): +    def test_detail_lookup_returns_404(self): +        url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 404) + +    def test_list_all_returns_empty_list(self): +        url = reverse('bot:documentationlink-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), []) + +    def test_delete_returns_404(self): +        url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') +        response = self.client.delete(url) + +        self.assertEqual(response.status_code, 404) + + +class DetailLookupDocumentationLinkAPITests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        cls.doc_link = DocumentationLink.objects.create( +            package='testpackage', +            base_url='https://example.com', +            inventory_url='https://example.com' +        ) + +        cls.doc_json = { +            'package': cls.doc_link.package, +            'base_url': cls.doc_link.base_url, +            'inventory_url': cls.doc_link.inventory_url +        } + +    def test_detail_lookup_unknown_package_returns_404(self): +        url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 404) + +    def test_detail_lookup_created_package_returns_package(self): +        url = reverse('bot:documentationlink-detail', args=(self.doc_link.package,), host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), self.doc_json) + +    def test_list_all_packages_shows_created_package(self): +        url = reverse('bot:documentationlink-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), [self.doc_json]) + +    def test_create_invalid_body_returns_400(self): +        url = reverse('bot:documentationlink-list', host='api') +        response = self.client.post(url, data={'i': 'am', 'totally': 'valid'}) + +        self.assertEqual(response.status_code, 400) + +    def test_create_invalid_url_returns_400(self): +        body = { +            'package': 'example', +            'base_url': 'https://example.com', +            'inventory_url': 'totally an url' +        } + +        url = reverse('bot:documentationlink-list', host='api') +        response = self.client.post(url, data=body) + +        self.assertEqual(response.status_code, 400) + + +class DocumentationLinkCreationTests(APISubdomainTestCase): +    def setUp(self): +        super().setUp() + +        self.body = { +            'package': 'example', +            'base_url': 'https://example.com', +            'inventory_url': 'https://docs.example.com' +        } + +        url = reverse('bot:documentationlink-list', host='api') +        response = self.client.post(url, data=self.body) + +        self.assertEqual(response.status_code, 201) + +    def test_package_in_full_list(self): +        url = reverse('bot:documentationlink-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), [self.body]) + +    def test_detail_lookup_works_with_package(self): +        url = reverse('bot:documentationlink-detail', args=(self.body['package'],), host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), self.body) + + +class DocumentationLinkDeletionTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        cls.doc_link = DocumentationLink.objects.create( +            package='example', +            base_url='https://example.com', +            inventory_url='https://docs.example.com' +        ) + +    def test_unknown_package_returns_404(self): +        url = reverse('bot:documentationlink-detail', args=('whatever',), host='api') +        response = self.client.delete(url) + +        self.assertEqual(response.status_code, 404) + +    def test_delete_known_package_returns_204(self): +        url = reverse('bot:documentationlink-detail', args=(self.doc_link.package,), host='api') +        response = self.client.delete(url) + +        self.assertEqual(response.status_code, 204) diff --git a/pydis_site/apps/api/tests/test_healthcheck.py b/pydis_site/apps/api/tests/test_healthcheck.py new file mode 100644 index 00000000..b0fd71bf --- /dev/null +++ b/pydis_site/apps/api/tests/test_healthcheck.py @@ -0,0 +1,16 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase + + +class UnauthedHealthcheckAPITests(APISubdomainTestCase): +    def setUp(self): +        super().setUp() +        self.client.force_authenticate(user=None) + +    def test_can_access_healthcheck_view(self): +        url = reverse('healthcheck', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), {'status': 'ok'}) diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py new file mode 100644 index 00000000..7c370c17 --- /dev/null +++ b/pydis_site/apps/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=response.json()['id']) +        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/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py new file mode 100644 index 00000000..43d1eb41 --- /dev/null +++ b/pydis_site/apps/api/tests/test_models.py @@ -0,0 +1,113 @@ +from datetime import datetime as dt, timezone + +from django.test import SimpleTestCase + +from ..models import ( +    BotSetting, DeletedMessage, +    DocumentationLink, Infraction, +    Message, MessageDeletionContext, +    ModelReprMixin, OffTopicChannelName, +    Reminder, Role, +    SnakeFact, SnakeIdiom, +    SnakeName, SpecialSnake, +    Tag, User +) + + +class SimpleClass(ModelReprMixin): +    def __init__(self, is_what): +        self.the_cake = is_what + + +class ReprMixinTests(SimpleTestCase): +    def setUp(self): +        self.klass = SimpleClass('is a lie') + +    def test_shows_attributes(self): +        expected = "<SimpleClass(the_cake='is a lie')>" +        self.assertEqual(repr(self.klass), expected) + + +class StringDunderMethodTests(SimpleTestCase): +    def setUp(self): +        self.objects = ( +            DeletedMessage( +                id=45, +                author=User( +                    id=444, name='bill', +                    discriminator=5, avatar_hash=None +                ), +                channel_id=666, +                content="wooey", +                deletion_context=MessageDeletionContext( +                    actor=User( +                        id=5555, name='shawn', +                        discriminator=555, avatar_hash=None +                    ), +                    creation=dt.utcnow() +                ), +                embeds=[] +            ), +            DocumentationLink( +                'test', 'http://example.com', 'http://example.com' +            ), +            OffTopicChannelName(name='bob-the-builders-playground'), +            SnakeFact(fact='snakes are cute'), +            SnakeIdiom(idiom='snake snacks'), +            SnakeName(name='python', scientific='3'), +            SpecialSnake( +                name='Pythagoras Pythonista', +                info='The only python snake that is born a triangle' +            ), +            Role( +                id=5, name='test role', +                colour=0x5, permissions=0 +            ), +            Message( +                id=45, +                author=User( +                    id=444, name='bill', +                    discriminator=5, avatar_hash=None +                ), +                channel_id=666, +                content="wooey", +                embeds=[] +            ), +            MessageDeletionContext( +                actor=User( +                    id=5555, name='shawn', +                    discriminator=555, avatar_hash=None +                ), +                creation=dt.utcnow() +            ), +            Tag( +                title='bob', +                embed={'content': "the builder"} +            ), +            User( +                id=5, name='bob', +                discriminator=1, avatar_hash=None +            ), +            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) +            ), +            Reminder( +                author=User( +                    id=452, name='billy', +                    discriminator=5, avatar_hash=None +                ), +                channel_id=555, +                content="oh no", +                expiration=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc) +            ) +        ) + +    def test_returns_string(self): +        for instance in self.objects: +            self.assertIsInstance(str(instance), str) diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py new file mode 100644 index 00000000..1f03d1b0 --- /dev/null +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -0,0 +1,41 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import Nomination, User + + +class NominationTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        cls.author = User.objects.create( +            id=5152, +            name='Ro Bert', +            discriminator=256, +            avatar_hash=None +        ) +        cls.user = cls.author + +        cls.nomination = Nomination.objects.create( +            author=cls.author, +            reason="he's good", +            user=cls.author +        ) + +    def test_returns_400_on_attempt_to_update_frozen_field(self): +        url = reverse('bot:nomination-detail', args=(self.user.id,), host='api') +        response = self.client.put( +            url, +            data={'inserted_at': 'something bad'} +        ) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'inserted_at': ['This field cannot be updated.'] +        }) + +    def test_returns_200_on_successful_update(self): +        url = reverse('bot:nomination-detail', args=(self.user.id,), host='api') +        response = self.client.patch( +            url, +            data={'reason': 'there are many like it, but this test is mine'} +        ) +        self.assertEqual(response.status_code, 200) diff --git a/pydis_site/apps/api/tests/test_off_topic_channel_names.py b/pydis_site/apps/api/tests/test_off_topic_channel_names.py new file mode 100644 index 00000000..60af1f62 --- /dev/null +++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py @@ -0,0 +1,152 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import OffTopicChannelName + + +class UnauthenticatedTests(APISubdomainTestCase): +    def setUp(self): +        super().setUp() +        self.client.force_authenticate(user=None) + +    def test_cannot_read_off_topic_channel_name_list(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 401) + +    def test_cannot_read_off_topic_channel_name_list_with_random_item_param(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(f'{url}?random_items=no') + +        self.assertEqual(response.status_code, 401) + + +class EmptyDatabaseTests(APISubdomainTestCase): +    def test_returns_empty_object(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), []) + +    def test_returns_empty_list_with_get_all_param(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(f'{url}?random_items=5') + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), []) + +    def test_returns_400_for_bad_random_items_param(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(f'{url}?random_items=totally-a-valid-integer') + +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'random_items': ["Must be a valid integer."] +        }) + +    def test_returns_400_for_negative_random_items_param(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(f'{url}?random_items=-5') + +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'random_items': ["Must be a positive integer."] +        }) + + +class ListTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand') +        cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk') + +    def test_returns_name_in_list(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual( +            response.json(), +            [ +                self.test_name.name, +                self.test_name_2.name +            ] +        ) + +    def test_returns_single_item_with_random_items_param_set_to_1(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(f'{url}?random_items=1') + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(len(response.json()), 1) + + +class CreationTests(APISubdomainTestCase): +    def setUp(self): +        super().setUp() + +        url = reverse('bot:offtopicchannelname-list', host='api') +        self.name = "lemonade-shop" +        response = self.client.post(f'{url}?name={self.name}') +        self.assertEqual(response.status_code, 201) + +    def test_name_in_full_list(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), [self.name]) + +    def test_returns_400_for_missing_name_param(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.post(url) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'name': ["This query parameter is required."] +        }) + +    def test_returns_400_for_bad_name_param(self): +        url = reverse('bot:offtopicchannelname-list', host='api') +        invalid_names = ( +            'space between words', +            'UPPERCASE', +            '$$$$$$$$' +        ) + +        for name in invalid_names: +            response = self.client.post(f'{url}?name={name}') +            self.assertEqual(response.status_code, 400) +            self.assertEqual(response.json(), { +                'name': ["Enter a valid value."] +            }) + + +class DeletionTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand') +        cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk') + +    def test_deleting_unknown_name_returns_404(self): +        url = reverse('bot:offtopicchannelname-detail', args=('unknown-name',), host='api') +        response = self.client.delete(url) + +        self.assertEqual(response.status_code, 404) + +    def test_deleting_known_name_returns_204(self): +        url = reverse('bot:offtopicchannelname-detail', args=(self.test_name.name,), host='api') +        response = self.client.delete(url) + +        self.assertEqual(response.status_code, 204) + +    def test_name_gets_deleted(self): +        url = reverse('bot:offtopicchannelname-detail', args=(self.test_name_2.name,), host='api') +        response = self.client.delete(url) + +        self.assertEqual(response.status_code, 204) + +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(url) +        self.assertNotIn(self.test_name_2.name, response.json()) diff --git a/pydis_site/apps/api/tests/test_rules.py b/pydis_site/apps/api/tests/test_rules.py new file mode 100644 index 00000000..c94f89cc --- /dev/null +++ b/pydis_site/apps/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/pydis_site/apps/api/tests/test_snake_names.py b/pydis_site/apps/api/tests/test_snake_names.py new file mode 100644 index 00000000..41dfae63 --- /dev/null +++ b/pydis_site/apps/api/tests/test_snake_names.py @@ -0,0 +1,67 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import SnakeName + + +class StatusTests(APISubdomainTestCase): +    def setUp(self): +        super().setUp() +        self.client.force_authenticate(user=None) + +    def test_cannot_read_snake_name_list(self): +        url = reverse('bot:snakename-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 401) + +    def test_cannot_read_snake_names_with_get_all_param(self): +        url = reverse('bot:snakename-list', host='api') +        response = self.client.get(f'{url}?get_all=True') + +        self.assertEqual(response.status_code, 401) + + +class EmptyDatabaseSnakeNameTests(APISubdomainTestCase): +    def test_endpoint_returns_empty_object(self): +        url = reverse('bot:snakename-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), {}) + +    def test_endpoint_returns_empty_list_with_get_all_param(self): +        url = reverse('bot:snakename-list', host='api') +        response = self.client.get(f'{url}?get_all=True') + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), []) + + +class SnakeNameListTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        cls.snake_python = SnakeName.objects.create(name='Python', scientific='Totally.') + +    def test_endpoint_returns_all_snakes_with_get_all_param(self): +        url = reverse('bot:snakename-list', host='api') +        response = self.client.get(f'{url}?get_all=True') + +        self.assertEqual(response.status_code, 200) +        self.assertEqual( +            response.json(), +            [ +                { +                    'name': self.snake_python.name, +                    'scientific': self.snake_python.scientific +                } +            ] +        ) + +    def test_endpoint_returns_single_snake_without_get_all_param(self): +        url = reverse('bot:snakename-list', host='api') +        response = self.client.get(url) +        self.assertEqual(response.json(), { +            'name': self.snake_python.name, +            'scientific': self.snake_python.scientific +        }) diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py new file mode 100644 index 00000000..90bc3d30 --- /dev/null +++ b/pydis_site/apps/api/tests/test_users.py @@ -0,0 +1,121 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import Role, User + + +class UnauthedUserAPITests(APISubdomainTestCase): +    def setUp(self): +        super().setUp() +        self.client.force_authenticate(user=None) + +    def test_detail_lookup_returns_401(self): +        url = reverse('bot:user-detail', args=('whatever',), host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 401) + +    def test_list_returns_401(self): +        url = reverse('bot:user-list', host='api') +        response = self.client.get(url) + +        self.assertEqual(response.status_code, 401) + +    def test_create_returns_401(self): +        url = reverse('bot:user-list', host='api') +        response = self.client.post(url, data={'hi': 'there'}) + +        self.assertEqual(response.status_code, 401) + +    def test_delete_returns_401(self): +        url = reverse('bot:user-detail', args=('whatever',), host='api') +        response = self.client.delete(url) + +        self.assertEqual(response.status_code, 401) + + +class CreationTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        cls.role = Role.objects.create( +            id=5, +            name="Test role pls ignore", +            colour=2, +            permissions=0b01010010101 +        ) + +    def test_accepts_valid_data(self): +        url = reverse('bot:user-list', host='api') +        data = { +            'id': 42, +            'avatar_hash': "validavatarhashiswear", +            'name': "Test", +            'discriminator': 42, +            'roles': [ +                self.role.id +            ], +            'in_guild': True +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 201) +        self.assertEqual(response.json(), data) + +        user = User.objects.get(id=42) +        self.assertEqual(user.avatar_hash, data['avatar_hash']) +        self.assertEqual(user.name, data['name']) +        self.assertEqual(user.discriminator, data['discriminator']) +        self.assertEqual(user.in_guild, data['in_guild']) + +    def test_supports_multi_creation(self): +        url = reverse('bot:user-list', host='api') +        data = [ +            { +                'id': 5, +                'avatar_hash': "hahayes", +                'name': "test man", +                'discriminator': 42, +                'roles': [ +                    self.role.id +                ], +                'in_guild': True +            }, +            { +                'id': 8, +                'avatar_hash': "maybenot", +                'name': "another test man", +                'discriminator': 555, +                'roles': [], +                'in_guild': False +            } +        ] + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 201) +        self.assertEqual(response.json(), data) + +    def test_returns_400_for_unknown_role_id(self): +        url = reverse('bot:user-list', host='api') +        data = { +            'id': 5, +            'avatar_hash': "hahayes", +            'name': "test man", +            'discriminator': 42, +            'roles': [ +                190810291 +            ] +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) + +    def test_returns_400_for_bad_data(self): +        url = reverse('bot:user-list', host='api') +        data = { +            'id': True, +            'avatar_hash': 1902831, +            'discriminator': "totally!" +        } + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py new file mode 100644 index 00000000..d2c0a136 --- /dev/null +++ b/pydis_site/apps/api/tests/test_validators.py @@ -0,0 +1,213 @@ +from django.core.exceptions import ValidationError +from django.test import TestCase + +from ..validators import ( +    validate_bot_setting_name, +    validate_tag_embed +) + + +REQUIRED_KEYS = ( +    'content', 'fields', 'image', 'title', 'video' +) + + +class BotSettingValidatorTests(TestCase): +    def test_accepts_valid_names(self): +        validate_bot_setting_name('defcon') + +    def test_rejects_bad_names(self): +        with self.assertRaises(ValidationError): +            validate_bot_setting_name('bad name') + +class TagEmbedValidatorTests(TestCase): +    def test_rejects_non_mapping(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed('non-empty non-mapping') + +    def test_rejects_missing_required_keys(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'unknown': "key" +            }) + +    def test_rejects_one_correct_one_incorrect(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'provider': "??", +                'title': "" +            }) + +    def test_rejects_empty_required_key(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': '' +            }) + +    def test_rejects_list_as_embed(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed([]) + +    def test_rejects_required_keys_and_unknown_keys(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': "the duck walked up to the lemonade stand", +                'and': "he said to the man running the stand" +            }) + +    def test_rejects_too_long_title(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': 'a' * 257 +            }) + +    def test_rejects_too_many_fields(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'fields': [{} for _ in range(26)] +            }) + +    def test_rejects_too_long_description(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'description': 'd' * 2049 +            }) + +    def test_allows_valid_embed(self): +        validate_tag_embed({ +            'title': "My embed", +            'description': "look at my embed, my embed is amazing" +        }) + +    def test_allows_unvalidated_fields(self): +        validate_tag_embed({ +            'title': "My embed", +            'provider': "what am I??" +        }) + +    def test_rejects_fields_as_list_of_non_mappings(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'fields': ['abc'] +            }) + +    def test_rejects_fields_with_unknown_fields(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'fields': [ +                    { +                        'what': "is this field" +                    } +                ] +            }) + +    def test_rejects_fields_with_too_long_name(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'fields': [ +                    { +                        'name': "a" * 257 +                    } +                ] +            }) + +    def test_rejects_one_correct_one_incorrect_field(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'fields': [ +                    { +                        'name': "Totally valid", +                        'value': "LOOK AT ME" +                    }, +                    { +                        'oh': "what is this key?" +                    } +                ] +            }) + +    def test_allows_valid_fields(self): +        validate_tag_embed({ +            'fields': [ +                { +                    'name': "valid", +                    'value': "field" +                } +            ] +        }) + +    def test_rejects_footer_as_non_mapping(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': "whatever", +                'footer': [] +            }) + +    def test_rejects_footer_with_unknown_fields(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': "whatever", +                'footer': { +                    'duck': "quack" +                } +            }) + +    def test_rejects_footer_with_empty_text(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': "whatever", +                'footer': { +                    'text': "" +                } +            }) + +    def test_allows_footer_with_proper_values(self): +        validate_tag_embed({ +            'title': "whatever", +            'footer': { +                'text': "django good" +            } +        }) + +    def test_rejects_author_as_non_mapping(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': "whatever", +                'author': [] +            }) + +    def test_rejects_author_with_unknown_field(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': "whatever", +                'author': { +                    'field': "that is unknown" +                } +            }) + +    def test_rejects_author_with_empty_name(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': "whatever", +                'author': { +                    'name': "" +                } +            }) + +    def test_rejects_author_with_one_correct_one_incorrect(self): +        with self.assertRaises(ValidationError): +            validate_tag_embed({ +                'title': "whatever", +                'author': { +                    # Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour +                    'url': "bobswebsite.com", +                    'name': "" +                } +            }) + +    def test_allows_author_with_proper_values(self): +        validate_tag_embed({ +            'title': "whatever", +            'author': { +                'name': "Bob" +            } +        }) diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py new file mode 100644 index 00000000..6c89a52e --- /dev/null +++ b/pydis_site/apps/api/urls.py @@ -0,0 +1,86 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import HealthcheckView, RulesView +from .viewsets import ( +    BotSettingViewSet, DeletedMessageViewSet, +    DocumentationLinkViewSet, InfractionViewSet, +    NominationViewSet, OffTopicChannelNameViewSet, +    ReminderViewSet, RoleViewSet, +    SnakeFactViewSet, SnakeIdiomViewSet, +    SnakeNameViewSet, SpecialSnakeViewSet, +    TagViewSet, UserViewSet +) + + +# http://www.django-rest-framework.org/api-guide/routers/#defaultrouter +bot_router = DefaultRouter(trailing_slash=False) +bot_router.register( +    'bot-settings', +    BotSettingViewSet +) +bot_router.register( +    'deleted-messages', +    DeletedMessageViewSet +) +bot_router.register( +    'documentation-links', +    DocumentationLinkViewSet +) +bot_router.register( +    'infractions', +    InfractionViewSet +) +bot_router.register( +    'nominations', +    NominationViewSet +) +bot_router.register( +    'off-topic-channel-names', +    OffTopicChannelNameViewSet, +    base_name='offtopicchannelname' +) +bot_router.register( +    'reminders', +    ReminderViewSet +) +bot_router.register( +    'roles', +    RoleViewSet +) +bot_router.register( +    'snake-facts', +    SnakeFactViewSet +) +bot_router.register( +    'snake-idioms', +    SnakeIdiomViewSet +) +bot_router.register( +    'snake-names', +    SnakeNameViewSet, +    base_name='snakename' +) +bot_router.register( +    'special-snakes', +    SpecialSnakeViewSet +) +bot_router.register( +    'tags', +    TagViewSet +) +bot_router.register( +    'users', +    UserViewSet +) + +app_name = 'api' +urlpatterns = ( +    # Build URLs using something like... +    # +    # 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('rules', RulesView.as_view(), name='rules') +) diff --git a/pydis_site/apps/api/validators.py b/pydis_site/apps/api/validators.py new file mode 100644 index 00000000..69a8d1ef --- /dev/null +++ b/pydis_site/apps/api/validators.py @@ -0,0 +1,164 @@ +from collections.abc import Mapping + +from django.core.exceptions import ValidationError +from django.core.validators import MaxLengthValidator, MinLengthValidator + + +def validate_tag_embed_fields(fields): +    field_validators = { +        'name': (MaxLengthValidator(limit_value=256),), +        'value': (MaxLengthValidator(limit_value=1024),) +    } + +    for field in fields: +        if not isinstance(field, Mapping): +            raise ValidationError("Embed fields must be a mapping.") + +        for field_name, value in field.items(): +            if field_name not in field_validators: +                raise ValidationError(f"Unknown embed field field: {field_name!r}.") + +            for validator in field_validators[field_name]: +                validator(value) + + +def validate_tag_embed_footer(footer): +    field_validators = { +        'text': ( +            MinLengthValidator( +                limit_value=1, +                message="Footer text must not be empty." +            ), +            MaxLengthValidator(limit_value=2048) +        ), +        'icon_url': (), +        'proxy_icon_url': () +    } + +    if not isinstance(footer, Mapping): +        raise ValidationError("Embed footer must be a mapping.") + +    for field_name, value in footer.items(): +        if field_name not in field_validators: +            raise ValidationError(f"Unknown embed footer field: {field_name!r}.") + +        for validator in field_validators[field_name]: +            validator(value) + + +def validate_tag_embed_author(author): +    field_validators = { +        'name': ( +            MinLengthValidator( +                limit_value=1, +                message="Embed author name must not be empty." +            ), +            MaxLengthValidator(limit_value=256) +        ), +        'url': (), +        'icon_url': (), +        'proxy_icon_url': () +    } + +    if not isinstance(author, Mapping): +        raise ValidationError("Embed author must be a mapping.") + +    for field_name, value in author.items(): +        if field_name not in field_validators: +            raise ValidationError(f"Unknown embed author field: {field_name!r}.") + +        for validator in field_validators[field_name]: +            validator(value) + + +def validate_tag_embed(embed): +    """ +    Validate a JSON document containing an embed as possible to send +    on Discord. This attempts to rebuild the validation used by Discord +    as well as possible by checking for various embed limits so we can +    ensure that any embed we store here will also be accepted as a +    valid embed by the Discord API. + +    Using this directly is possible, although not intended - you usually +    stick this onto the `validators` keyword argument of model fields. + +    Example: + +        >>> from django.contrib.postgres import fields as pgfields +        >>> from django.db import models +        >>> from pydis_site.apps.api.validators import validate_tag_embed +        >>> class MyMessage(models.Model): +        ...     embed = pgfields.JSONField( +        ...         validators=( +        ...             validate_tag_embed, +        ...         ) +        ...     ) +        ...     # ... +        ... + +    Args: +        embed (Dict[str, Union[str, List[dict], dict]]): +            A dictionary describing the contents of this embed. +            See the official documentation for a full reference +            of accepted keys by this dictionary: +                https://discordapp.com/developers/docs/resources/channel#embed-object + +    Raises: +        ValidationError: +            In case the given embed is deemed invalid, a `ValidationError` +            is raised which in turn will allow Django to display errors +            as appropriate. +    """ + +    all_keys = { +        'title', 'type', 'description', 'url', 'timestamp', +        'color', 'footer', 'image', 'thumbnail', 'video', +        'provider', 'author', 'fields' +    } +    one_required_of = {'description', 'fields', 'image', 'title', 'video'} +    field_validators = { +        'title': ( +            MinLengthValidator( +                limit_value=1, +                message="Embed title must not be empty." +            ), +            MaxLengthValidator(limit_value=256) +        ), +        'description': (MaxLengthValidator(limit_value=2048),), +        'fields': ( +            MaxLengthValidator(limit_value=25), +            validate_tag_embed_fields +        ), +        'footer': (validate_tag_embed_footer,), +        'author': (validate_tag_embed_author,) +    } + +    if not embed: +        raise ValidationError("Tag embed must not be empty.") + +    elif not isinstance(embed, Mapping): +        raise ValidationError("Tag embed must be a mapping.") + +    elif not any(field in embed for field in one_required_of): +        raise ValidationError(f"Tag embed must contain one of the fields {one_required_of}.") + +    for required_key in one_required_of: +        if required_key in embed and not embed[required_key]: +            raise ValidationError(f"Key {required_key!r} must not be empty.") + +    for field_name, value in embed.items(): +        if field_name not in all_keys: +            raise ValidationError(f"Unknown field name: {field_name!r}") + +        if field_name in field_validators: +            for validator in field_validators[field_name]: +                validator(value) + + +def validate_bot_setting_name(name): +    KNOWN_SETTINGS = ( +        'defcon', +    ) + +    if name not in KNOWN_SETTINGS: +        raise ValidationError(f"`{name}` is not a known setting name.") diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py new file mode 100644 index 00000000..c529da0f --- /dev/null +++ b/pydis_site/apps/api/views.py @@ -0,0 +1,161 @@ +from rest_framework.exceptions import ParseError +from rest_framework.response import Response +from rest_framework.views import APIView + + +class HealthcheckView(APIView): +    """ +    Provides a simple view to check that the website is alive and well. + +    ## Routes +    ### GET /healthcheck +    Returns a simple JSON document showcasing whether the system is working: + +    >>> { +    ...     'status': 'ok' +    ... } + +    Seems to be. + +    ## Authentication +    Does not require any authentication nor permissions. +    """ + +    authentication_classes = () +    permission_classes = () + +    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/pydis_site/apps/api/viewsets.py b/pydis_site/apps/api/viewsets.py new file mode 100644 index 00000000..949ffaaa --- /dev/null +++ b/pydis_site/apps/api/viewsets.py @@ -0,0 +1,890 @@ +from django.shortcuts import get_object_or_404 +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, +    UpdateModelMixin +) +from rest_framework.response import Response +from rest_framework.status import HTTP_201_CREATED +from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet +from rest_framework_bulk import BulkCreateModelMixin + +from .models import ( +    BotSetting, DocumentationLink, +    Infraction, MessageDeletionContext, +    Nomination, OffTopicChannelName, +    Reminder, Role, +    SnakeFact, SnakeIdiom, +    SnakeName, SpecialSnake, +    Tag, User +) +from .serializers import ( +    BotSettingSerializer, DocumentationLinkSerializer, +    ExpandedInfractionSerializer, InfractionSerializer, +    MessageDeletionContextSerializer, NominationSerializer, +    OffTopicChannelNameSerializer, ReminderSerializer, +    RoleSerializer, SnakeFactSerializer, +    SnakeIdiomSerializer, SnakeNameSerializer, +    SpecialSnakeSerializer, TagSerializer, +    UserSerializer +) + + +class BotSettingViewSet(RetrieveModelMixin, UpdateModelMixin, GenericViewSet): +    """ +    View providing update operations on bot setting routes. +    """ + +    serializer_class = BotSettingSerializer +    queryset = BotSetting.objects.all() + + +class DeletedMessageViewSet(CreateModelMixin, GenericViewSet): +    """ +    View providing support for posting bulk deletion logs generated by the bot. + +    ## Routes +    ### POST /bot/deleted-messages +    Post messages from bulk deletion logs. + +    #### Body schema +    >>> { +    ...     # The member ID of the original actor, if applicable. +    ...     # If a member ID is given, it must be present on the site. +    ...     'actor': Optional[int] +    ...     'creation': datetime, +    ...     'messages': [ +    ...         { +    ...             'id': int, +    ...             'author': int, +    ...             'channel_id': int, +    ...             'content': str, +    ...             'embeds': [ +    ...                 # Discord embed objects +    ...             ] +    ...         } +    ...     ] +    ... } + +    #### Status codes +    - 204: returned on success +    """ + +    queryset = MessageDeletionContext.objects.all() +    serializer_class = MessageDeletionContextSerializer + + +class DocumentationLinkViewSet( +    CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet +): +    """ +    View providing management of documentation links used in the bot's `Doc` cog. + +    ## Routes +    ### GET /bot/documentation-links +    Retrieve all currently stored entries from the database. + +    #### Response format +    >>> [ +    ...     { +    ...         'package': 'flask', +    ...         'base_url': 'https://flask.pocoo.org/docs/dev', +    ...         'inventory_url': 'https://flask.pocoo.org/docs/objects.inv' +    ...     }, +    ...     # ... +    ... ] + +    #### Status codes +    - 200: returned on success + +    ### GET /bot/documentation-links/<package:str> +    Look up the documentation object for the given `package`. + +    #### Response format +    >>> { +    ...     'package': 'flask', +    ...     'base_url': 'https://flask.pocoo.org/docs/dev', +    ...     'inventory_url': 'https://flask.pocoo.org/docs/objects.inv' +    ... } + +    #### Status codes +    - 200: returned on success +    - 404: if no entry for the given `package` exists + +    ### POST /bot/documentation-links +    Create a new documentation link object. + +    #### Body schema +    >>> { +    ...     'package': str, +    ...     'base_url': URL, +    ...     'inventory_url': URL +    ... } + +    #### Status codes +    - 201: returned on success +    - 400: if the request body has invalid fields, see the response for details + +    ### DELETE /bot/documentation-links/<package:str> +    Delete the entry for the given `package`. + +    #### Status codes +    - 204: returned on success +    - 404: if the given `package` could not be found +    """ + +    queryset = DocumentationLink.objects.all() +    serializer_class = DocumentationLinkSerializer +    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 +    to rotate our off-topic names on a daily basis. + +    ## Routes +    ### GET /bot/off-topic-channel-names +    Return all known off-topic channel names from the database. +    If the `random_items` query parameter is given, for example using... +        $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?random_items=5 +    ... then the API will return `5` random items from the database. + +    #### Response format +    Return a list of off-topic-channel names: +    >>> [ +    ...     "lemons-lemonade-stand", +    ...     "bbq-with-bisk" +    ... ] + +    #### Status codes +    - 200: returned on success +    - 400: returned when `random_items` is not a positive integer + +    ### POST /bot/off-topic-channel-names +    Create a new off-topic-channel name in the database. +    The name must be given as a query parameter, for example: +        $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?name=lemons-lemonade-shop + +    #### Status codes +    - 201: returned on success +    - 400: if the request body has invalid fields, see the response for details + +    ### DELETE /bot/off-topic-channel-names/<name:str> +    Delete the off-topic-channel name with the given `name`. + +    #### Status codes +    - 204: returned on success +    - 404: returned when the given `name` was not found + +    ## Authentication +    Requires a API token. +    """ + +    lookup_field = 'name' +    serializer_class = OffTopicChannelNameSerializer + +    def get_object(self): +        queryset = self.get_queryset() +        name = self.kwargs[self.lookup_field] +        return get_object_or_404(queryset, name=name) + +    def get_queryset(self): +        return OffTopicChannelName.objects.all() + +    def create(self, request): +        if 'name' in request.query_params: +            create_data = {'name': request.query_params['name']} +            serializer = OffTopicChannelNameSerializer(data=create_data) +            serializer.is_valid(raise_exception=True) +            serializer.save() +            return Response(create_data, status=HTTP_201_CREATED) + +        else: +            raise ParseError(detail={ +                'name': ["This query parameter is required."] +            }) + +    def list(self, request):  # noqa +        if 'random_items' in request.query_params: +            param = request.query_params['random_items'] +            try: +                random_count = int(param) +            except ValueError: +                raise ParseError(detail={'random_items': ["Must be a valid integer."]}) + +            if random_count <= 0: +                raise ParseError(detail={ +                    'random_items': ["Must be a positive integer."] +                }) + +            queryset = self.get_queryset().order_by('?')[:random_count] +            serialized = self.serializer_class(queryset, many=True) +            return Response(serialized.data) + +        queryset = self.get_queryset() +        serialized = self.serializer_class(queryset, many=True) +        return Response(serialized.data) + + +class ReminderViewSet(CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet): +    """ +    View providing CRUD access to reminders. + +    ## Routes +    ### GET /bot/reminders +    Returns all reminders in the database. + +    #### Response format +    >>> [ +    ...     { +    ...         'active': True, +    ...         'author': 1020103901030, +    ...         'content': "Make dinner", +    ...         'expiration': '5018-11-20T15:52:00Z', +    ...         'id': 11 +    ...     }, +    ...     ... +    ... ] + +    #### Status codes +    - 200: returned on success + +    ### POST /bot/reminders +    Create a new reminder. + +    #### Request body +    >>> { +    ...     'author': int, +    ...     'content': str, +    ...     'expiration': str  # ISO-formatted datetime +    ... } + +    #### Status codes +    - 201: returned on success +    - 400: if the body format is invalid +    - 404: if no user with the given ID could be found + +    ### DELETE /bot/reminders/<id:int> +    Delete the reminder with the given `id`. + +    #### Status codes +    - 204: returned on success +    - 404: if a reminder with the given `id` does not exist + +    ## Authentication +    Requires an API token. +    """ + +    serializer_class = ReminderSerializer +    queryset = Reminder.objects.prefetch_related('author') +    filter_backends = (DjangoFilterBackend, SearchFilter) +    filter_fields = ('active', 'author__id') + + +class RoleViewSet(ModelViewSet): +    """ +    View providing CRUD access to the roles on our server, used +    by the bot to keep a mirror of our server's roles on the site. + +    ## Routes +    ### GET /bot/roles +    Returns all roles in the database. + +    #### Response format +    >>> [ +    ...     { +    ...         'id': 267628507062992896, +    ...         'name': "Admins", +    ...         'colour': 1337, +    ...         'permissions': 8 +    ...     } +    ... ] + +    #### Status codes +    - 200: returned on success + +    ### GET /bot/roles/<snowflake:int> +    Gets a single role by ID. + +    #### Response format +    >>> { +    ...     'id': 267628507062992896, +    ...     'name': "Admins", +    ...     'colour': 1337, +    ...     'permissions': 8 +    ... } + +    #### Status codes +    - 200: returned on success +    - 404: if a role with the given `snowflake` could not be found + +    ### POST /bot/roles +    Adds a single new role. + +    #### Request body +    >>> { +    ...     'id': int, +    ...     'name': str, +    ...     'colour': int, +    ...     'permissions': int, +    ... } + +    #### Status codes +    - 201: returned on success +    - 400: if the body format is invalid + +    ### PUT /bot/roles/<snowflake:int> +    Update the role with the given `snowflake`. +    All fields in the request body are required. + +    #### Request body +    >>> { +    ...     'id': int, +    ...     'name': str, +    ...     'colour': int, +    ...     'permissions': int +    ... } + +    #### Status codes +    - 200: returned on success +    - 400: if the request body was invalid + +    ### PATCH /bot/roles/<snowflake:int> +    Update the role with the given `snowflake`. +    All fields in the request body are required. + +    >>> { +    ...     'id': int, +    ...     'name': str, +    ...     'colour': int, +    ...     'permissions': int +    ... } + +    ### DELETE /bot/roles/<snowflake:int> +    Deletes the role with the given `snowflake`. + +    #### Status codes +    - 204: returned on success +    - 404: if a role with the given `snowflake` does not exist +    """ + +    queryset = Role.objects.all() +    serializer_class = RoleSerializer + + +class SnakeFactViewSet(ListModelMixin, GenericViewSet): +    """ +    View providing snake facts created by the Pydis community in the first code jam. + +    ## Routes +    ### GET /bot/snake-facts +    Returns snake facts from the database. + +    #### Response format +    >>> [ +    ...     {'fact': 'Snakes are dangerous'}, +    ...     {'fact': 'Except for Python, we all love it'} +    ... ] + +    #### Status codes +    - 200: returned on success + +    ## Authentication +    Requires an API token. +    """ + +    serializer_class = SnakeFactSerializer +    queryset = SnakeFact.objects.all() + + +class SnakeIdiomViewSet(ListModelMixin, GenericViewSet): +    """ +    View providing snake idioms for the snake cog. + +    ## Routes +    ### GET /bot/snake-idioms +    Returns snake idioms from the database. + +    #### Response format +    >>> [ +    ...    {'idiom': 'Sneky snek'}, +    ...    {'idiom': 'Snooky Snake'} +    ... ] + +    #### Status codes +    - 200: returned on success + +    ## Authentication +    Requires an API token +    """ + +    serializer_class = SnakeIdiomSerializer +    queryset = SnakeIdiom.objects.all() + + +class SnakeNameViewSet(ViewSet): +    """ +    View providing snake names for the bot's snake cog from our first code jam's winners. + +    ## Routes +    ### GET /bot/snake-names +    By default, return a single random snake name along with its name and scientific name. +    If the `get_all` query parameter is given, for example using... +        $ curl api.pythondiscord.local:8000/bot/snake-names?get_all=yes +    ... then the API will return all snake names and scientific names in the database. + +    #### Response format +    Without `get_all` query parameter: +    >>> { +    ...     'name': "Python", +    ...     'scientific': "Langus greatus" +    ... } + +    If the database is empty for whatever reason, this will return an empty dictionary. + +    With `get_all` query parameter: +    >>> [ +    ...     {'name': "Python 3", 'scientific': "Langus greatus"}, +    ...     {'name': "Python 2", 'scientific': "Langus decentus"} +    ... ] + +    #### Status codes +    - 200: returned on success + +    ## Authentication +    Requires a API token. +    """ + +    serializer_class = SnakeNameSerializer + +    def get_queryset(self): +        return SnakeName.objects.all() + +    def list(self, request):  # noqa +        if request.query_params.get('get_all'): +            queryset = self.get_queryset() +            serialized = self.serializer_class(queryset, many=True) +            return Response(serialized.data) + +        single_snake = SnakeName.objects.order_by('?').first() +        if single_snake is not None: +            body = { +                'name': single_snake.name, +                'scientific': single_snake.scientific +            } + +            return Response(body) + +        return Response({}) + + +class SpecialSnakeViewSet(ListModelMixin, GenericViewSet): +    """ +    View providing special snake names for our bot's snake cog. + +    ## Routes +    ### GET /bot/special-snakes +    Returns a list of special snake names. + +    #### Response Format +    >>> [ +    ...   { +    ...     'name': 'Snakky sneakatus', +    ...     'info': 'Scary snek', +    ...     'image': 'https://discordapp.com/assets/53ef346458017da2062aca5c7955946b.svg' +    ...   } +    ... ] + +    #### Status codes +    - 200: returned on success + +    ## Authentication +    Requires an API token. +    """ + +    serializer_class = SpecialSnakeSerializer +    queryset = SpecialSnake.objects.all() + + +class TagViewSet(ModelViewSet): +    """ +    View providing CRUD operations on tags shown by our bot. + +    ## Routes +    ### GET /bot/tags +    Returns all tags in the database. + +    #### Response format +    >>> [ +    ...     { +    ...         'title': "resources", +    ...         'embed': { +    ...             'content': "Did you really think I'd put something useful here?" +    ...         } +    ...     } +    ... ] + +    #### Status codes +    - 200: returned on success + +    ### GET /bot/tags/<title:str> +    Gets a single tag by its title. + +    #### Response format +    >>> { +    ...     'title': "My awesome tag", +    ...     'embed': { +    ...         'content': "totally not filler words" +    ...     } +    ... } + +    #### Status codes +    - 200: returned on success +    - 404: if a tag with the given `title` could not be found + +    ### POST /bot/tags +    Adds a single tag to the database. + +    #### Request body +    >>> { +    ...     'title': str, +    ...     'embed': dict +    ... } + +    The embed structure is the same as the embed structure that the Discord API +    expects. You can view the documentation for it here: +        https://discordapp.com/developers/docs/resources/channel#embed-object + +    #### Status codes +    - 201: returned on success +    - 400: if one of the given fields is invalid + +    ### PUT /bot/tags/<title:str> +    Update the tag with the given `title`. + +    #### Request body +    >>> { +    ...     'title': str, +    ...     'embed': dict +    ... } + +    The embed structure is the same as the embed structure that the Discord API +    expects. You can view the documentation for it here: +        https://discordapp.com/developers/docs/resources/channel#embed-object + +    #### Status codes +    - 200: returned on success +    - 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/tags/<title:str> +    Update the tag with the given `title`. + +    #### Request body +    >>> { +    ...     'title': str, +    ...     'embed': dict +    ... } + +    The embed structure is the same as the embed structure that the Discord API +    expects. You can view the documentation for it here: +        https://discordapp.com/developers/docs/resources/channel#embed-object + +    #### Status codes +    - 200: returned on success +    - 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/tags/<title:str> +    Deletes the tag with the given `title`. + +    #### Status codes +    - 204: returned on success +    - 404: if a tag with the given `title` does not exist +    """ + +    serializer_class = TagSerializer +    queryset = Tag.objects.all() + + +class UserViewSet(BulkCreateModelMixin, ModelViewSet): +    """ +    View providing CRUD operations on Discord users through the bot. + +    ## Routes +    ### GET /bot/users +    Returns all users currently known. + +    #### Response format +    >>> [ +    ...     { +    ...         'id': 409107086526644234, +    ...         'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb", +    ...         'name': "Python", +    ...         'discriminator': 4329, +    ...         'roles': [ +    ...             352427296948486144, +    ...             270988689419665409, +    ...             277546923144249364, +    ...             458226699344019457 +    ...         ], +    ...         'in_guild': True +    ...     } +    ... ] + +    #### Status codes +    - 200: returned on success + +    ### GET /bot/users/<snowflake:int> +    Gets a single user by ID. + +    #### Response format +    >>> { +    ...     'id': 409107086526644234, +    ...     'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb", +    ...     'name': "Python", +    ...     'discriminator': 4329, +    ...     'roles': [ +    ...         352427296948486144, +    ...         270988689419665409, +    ...         277546923144249364, +    ...         458226699344019457 +    ...     ], +    ...     'in_guild': True +    ... } + +    #### Status codes +    - 200: returned on success +    - 404: if a user with the given `snowflake` could not be found + +    ### POST /bot/users +    Adds a single or multiple new users. +    The roles attached to the user(s) must be roles known by the site. + +    #### Request body +    >>> { +    ...     'id': int, +    ...     'avatar': str, +    ...     'name': str, +    ...     'discriminator': int, +    ...     'roles': List[int], +    ...     'in_guild': bool +    ... } + +    Alternatively, request users can be POSTed as a list of above objects, +    in which case multiple users will be created at once. + +    #### Status codes +    - 201: returned on success +    - 400: if one of the given roles does not exist, or one of the given fields is invalid + +    ### PUT /bot/users/<snowflake:int> +    Update the user with the given `snowflake`. +    All fields in the request body are required. + +    #### Request body +    >>> { +    ...     'id': int, +    ...     'avatar': str, +    ...     'name': str, +    ...     'discriminator': int, +    ...     'roles': List[int], +    ...     'in_guild': bool +    ... } + +    #### Status codes +    - 200: returned on success +    - 400: if the request body was invalid, see response body for details +    - 404: if the user with the given `snowflake` could not be found + +    ### PATCH /bot/users/<snowflake:int> +    Update the user with the given `snowflake`. +    All fields in the request body are optional. + +    #### Request body +    >>> { +    ...     'id': int, +    ...     'avatar': str, +    ...     'name': str, +    ...     'discriminator': int, +    ...     'roles': List[int], +    ...     'in_guild': bool +    ... } + +    #### Status codes +    - 200: returned on success +    - 400: if the request body was invalid, see response body for details +    - 404: if the user with the given `snowflake` could not be found + +    ### DELETE /bot/users/<snowflake:int> +    Deletes the user with the given `snowflake`. + +    #### Status codes +    - 204: returned on success +    - 404: if a user with the given `snowflake` does not exist +    """ + +    serializer_class = UserSerializer +    queryset = User.objects.prefetch_related('roles') + + +class NominationViewSet(ModelViewSet): +    # TODO: doc me +    serializer_class = NominationSerializer +    queryset = Nomination.objects.prefetch_related('author', 'user') +    frozen_fields = ('author', 'inserted_at', 'user') + +    def 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) diff --git a/pydis_site/apps/home/__init__.py b/pydis_site/apps/home/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/home/__init__.py diff --git a/pydis_site/apps/home/admin.py b/pydis_site/apps/home/admin.py new file mode 100644 index 00000000..4185d360 --- /dev/null +++ b/pydis_site/apps/home/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/pydis_site/apps/home/apps.py b/pydis_site/apps/home/apps.py new file mode 100644 index 00000000..90dc7137 --- /dev/null +++ b/pydis_site/apps/home/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class HomeConfig(AppConfig): +    name = 'home' diff --git a/pydis_site/apps/home/migrations/__init__.py b/pydis_site/apps/home/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/home/migrations/__init__.py diff --git a/pydis_site/apps/home/models.py b/pydis_site/apps/home/models.py new file mode 100644 index 00000000..0b4331b3 --- /dev/null +++ b/pydis_site/apps/home/models.py @@ -0,0 +1,3 @@ +# from django.db import models + +# Create your models here. diff --git a/pydis_site/apps/home/tests.py b/pydis_site/apps/home/tests.py new file mode 100644 index 00000000..54fac6e8 --- /dev/null +++ b/pydis_site/apps/home/tests.py @@ -0,0 +1,9 @@ +from django.test import TestCase +from django_hosts.resolvers import reverse + + +class TestIndexReturns200(TestCase): +    def test_index_returns_200(self): +        url = reverse('index') +        resp = self.client.get(url) +        self.assertEqual(resp.status_code, 200) diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py new file mode 100644 index 00000000..a01e019e --- /dev/null +++ b/pydis_site/apps/home/urls.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from django.urls import path +from django.views.generic import TemplateView + + +app_name = 'home' +urlpatterns = [ +    path('', TemplateView.as_view(template_name='home/index.html'), name='index'), +    path('admin/', admin.site.urls) +] diff --git a/pydis_site/apps/home/views.py b/pydis_site/apps/home/views.py new file mode 100644 index 00000000..fd0e0449 --- /dev/null +++ b/pydis_site/apps/home/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here. diff --git a/pydis_site/apps/wiki/__init__.py b/pydis_site/apps/wiki/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/wiki/__init__.py diff --git a/pydis_site/apps/wiki/admin.py b/pydis_site/apps/wiki/admin.py new file mode 100644 index 00000000..4185d360 --- /dev/null +++ b/pydis_site/apps/wiki/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/pydis_site/apps/wiki/apps.py b/pydis_site/apps/wiki/apps.py new file mode 100644 index 00000000..fce4708e --- /dev/null +++ b/pydis_site/apps/wiki/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WikiConfig(AppConfig): +    name = 'wiki' diff --git a/pydis_site/apps/wiki/migrations/__init__.py b/pydis_site/apps/wiki/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/wiki/migrations/__init__.py diff --git a/pydis_site/apps/wiki/models.py b/pydis_site/apps/wiki/models.py new file mode 100644 index 00000000..0b4331b3 --- /dev/null +++ b/pydis_site/apps/wiki/models.py @@ -0,0 +1,3 @@ +# from django.db import models + +# Create your models here. diff --git a/pydis_site/apps/wiki/tests.py b/pydis_site/apps/wiki/tests.py new file mode 100644 index 00000000..a79ca8be --- /dev/null +++ b/pydis_site/apps/wiki/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/pydis_site/apps/wiki/views.py b/pydis_site/apps/wiki/views.py new file mode 100644 index 00000000..fd0e0449 --- /dev/null +++ b/pydis_site/apps/wiki/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here.  |