aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site
diff options
context:
space:
mode:
Diffstat (limited to 'pydis_site')
-rw-r--r--pydis_site/__init__.py0
-rw-r--r--pydis_site/apps/__init__.py0
-rw-r--r--pydis_site/apps/admin/__init__.py0
-rw-r--r--pydis_site/apps/admin/urls.py7
-rw-r--r--pydis_site/apps/api/__init__.py0
-rw-r--r--pydis_site/apps/api/admin.py26
-rw-r--r--pydis_site/apps/api/apps.py5
-rw-r--r--pydis_site/apps/api/migrations/0001_initial.py21
-rw-r--r--pydis_site/apps/api/migrations/0002_documentationlink.py21
-rw-r--r--pydis_site/apps/api/migrations/0003_offtopicchannelname.py20
-rw-r--r--pydis_site/apps/api/migrations/0004_role.py23
-rw-r--r--pydis_site/apps/api/migrations/0005_user.py38
-rw-r--r--pydis_site/apps/api/migrations/0006_add_help_texts.py44
-rw-r--r--pydis_site/apps/api/migrations/0007_tag.py23
-rw-r--r--pydis_site/apps/api/migrations/0008_tag_embed_validator.py23
-rw-r--r--pydis_site/apps/api/migrations/0009_snakefact.py21
-rw-r--r--pydis_site/apps/api/migrations/0010_snakeidiom.py21
-rw-r--r--pydis_site/apps/api/migrations/0011_auto_20181020_1904.py18
-rw-r--r--pydis_site/apps/api/migrations/0012_specialsnake.py22
-rw-r--r--pydis_site/apps/api/migrations/0013_specialsnake_image.py21
-rw-r--r--pydis_site/apps/api/migrations/0014_auto_20181025_1959.py23
-rw-r--r--pydis_site/apps/api/migrations/0015_auto_20181027_1617.py19
-rw-r--r--pydis_site/apps/api/migrations/0016_auto_20181027_1619.py18
-rw-r--r--pydis_site/apps/api/migrations/0017_auto_20181029_1921.py19
-rw-r--r--pydis_site/apps/api/migrations/0018_messagedeletioncontext.py24
-rw-r--r--pydis_site/apps/api/migrations/0018_user_rename.py17
-rw-r--r--pydis_site/apps/api/migrations/0019_deletedmessage.py34
-rw-r--r--pydis_site/apps/api/migrations/0019_user_in_guild.py18
-rw-r--r--pydis_site/apps/api/migrations/0020_add_snake_field_validators.py24
-rw-r--r--pydis_site/apps/api/migrations/0020_infraction.py30
-rw-r--r--pydis_site/apps/api/migrations/0021_add_special_snake_validator.py19
-rw-r--r--pydis_site/apps/api/migrations/0021_infraction_reason_null.py18
-rw-r--r--pydis_site/apps/api/migrations/0021_merge_20181125_1015.py14
-rw-r--r--pydis_site/apps/api/migrations/0022_infraction_remove_note.py18
-rw-r--r--pydis_site/apps/api/migrations/0023_merge_infractions_snake_validators.py14
-rw-r--r--pydis_site/apps/api/migrations/0024_add_note_infraction_type.py18
-rw-r--r--pydis_site/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py19
-rw-r--r--pydis_site/apps/api/migrations/0026_use_proper_default_for_infraction_insertion_date.py19
-rw-r--r--pydis_site/apps/api/migrations/0027_merge_20190120_0852.py14
-rw-r--r--pydis_site/apps/api/migrations/0028_allow_message_content_blank.py18
-rw-r--r--pydis_site/apps/api/migrations/0029_add_infraction_type_watch.py18
-rw-r--r--pydis_site/apps/api/migrations/0030_reminder.py27
-rw-r--r--pydis_site/apps/api/migrations/0031_nomination.py26
-rw-r--r--pydis_site/apps/api/migrations/0032_botsetting.py23
-rw-r--r--pydis_site/apps/api/migrations/0033_create_defcon_settings.py30
-rw-r--r--pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py20
-rw-r--r--pydis_site/apps/api/migrations/__init__.py0
-rw-r--r--pydis_site/apps/api/models.py452
-rw-r--r--pydis_site/apps/api/serializers.py174
-rw-r--r--pydis_site/apps/api/tests/__init__.py0
-rw-r--r--pydis_site/apps/api/tests/base.py69
-rw-r--r--pydis_site/apps/api/tests/test_deleted_messages.py43
-rw-r--r--pydis_site/apps/api/tests/test_documentation_links.py161
-rw-r--r--pydis_site/apps/api/tests/test_healthcheck.py16
-rw-r--r--pydis_site/apps/api/tests/test_infractions.py359
-rw-r--r--pydis_site/apps/api/tests/test_models.py113
-rw-r--r--pydis_site/apps/api/tests/test_nominations.py41
-rw-r--r--pydis_site/apps/api/tests/test_off_topic_channel_names.py152
-rw-r--r--pydis_site/apps/api/tests/test_rules.py35
-rw-r--r--pydis_site/apps/api/tests/test_snake_names.py67
-rw-r--r--pydis_site/apps/api/tests/test_users.py121
-rw-r--r--pydis_site/apps/api/tests/test_validators.py213
-rw-r--r--pydis_site/apps/api/urls.py86
-rw-r--r--pydis_site/apps/api/validators.py164
-rw-r--r--pydis_site/apps/api/views.py161
-rw-r--r--pydis_site/apps/api/viewsets.py890
-rw-r--r--pydis_site/apps/home/__init__.py0
-rw-r--r--pydis_site/apps/home/admin.py3
-rw-r--r--pydis_site/apps/home/apps.py5
-rw-r--r--pydis_site/apps/home/migrations/__init__.py0
-rw-r--r--pydis_site/apps/home/models.py3
-rw-r--r--pydis_site/apps/home/tests.py9
-rw-r--r--pydis_site/apps/home/urls.py10
-rw-r--r--pydis_site/apps/home/views.py3
-rw-r--r--pydis_site/apps/wiki/__init__.py0
-rw-r--r--pydis_site/apps/wiki/admin.py3
-rw-r--r--pydis_site/apps/wiki/apps.py5
-rw-r--r--pydis_site/apps/wiki/migrations/__init__.py0
-rw-r--r--pydis_site/apps/wiki/models.py3
-rw-r--r--pydis_site/apps/wiki/tests.py3
-rw-r--r--pydis_site/apps/wiki/views.py3
-rw-r--r--pydis_site/hosts.py13
-rw-r--r--pydis_site/settings.py259
-rw-r--r--pydis_site/static/assets/logo-banner.pngbin0 -> 34789 bytes
-rw-r--r--pydis_site/static/assets/logo-banner.svg55
-rw-r--r--pydis_site/static/assets/logo-discord.pngbin0 -> 63590 bytes
-rw-r--r--pydis_site/static/css/navbar.css4
-rw-r--r--pydis_site/static/home/css/index.css30
-rw-r--r--pydis_site/templates/base.html22
-rw-r--r--pydis_site/templates/home/index.html42
-rw-r--r--pydis_site/templates/navbar.html16
-rw-r--r--pydis_site/urls.py6
-rw-r--r--pydis_site/wsgi.py16
93 files changed, 4725 insertions, 0 deletions
diff --git a/pydis_site/__init__.py b/pydis_site/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/pydis_site/__init__.py
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..eecc0bc3
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py
@@ -0,0 +1,23 @@
+# 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..7a039675
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py
@@ -0,0 +1,34 @@
+# 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..0290fa69
--- /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',
+ 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 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 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 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..ac2fb739
--- /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 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..3160e8f7
--- /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, pydis_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..0471f79d
--- /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 pydis_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 pydis_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 pydis_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 pydis_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.
diff --git a/pydis_site/hosts.py b/pydis_site/hosts.py
new file mode 100644
index 00000000..86375173
--- /dev/null
+++ b/pydis_site/hosts.py
@@ -0,0 +1,13 @@
+from django.conf import settings
+from django_hosts import host, patterns
+
+host_patterns = patterns(
+ '',
+ # > | Subdomain | URL Module | Host entry name |
+ host(r'admin', 'pydis_site.apps.admin.urls', name="admin"),
+ host(r'api', 'pydis_site.apps.api.urls', name='api'),
+ # host(r"staff", "pydis_site.apps.staff", name="staff"),
+ # host(r"wiki", "pydis_site.apps.wiki", name="wiki"),
+ # host(r"ws", "pydis_site.apps. ws", name="ws"),
+ host(r'.*', 'pydis_site.apps.home.urls', name=settings.DEFAULT_HOST)
+)
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
new file mode 100644
index 00000000..e8355918
--- /dev/null
+++ b/pydis_site/settings.py
@@ -0,0 +1,259 @@
+"""
+Django settings for pydis_site project.
+
+Generated by 'django-admin startproject' using Django 2.1.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/2.1/topics/settings/
+
+For the full list of settings and their values, see
+https://docs.djangoproject.com/en/2.1/ref/settings/
+"""
+
+import os
+import sys
+
+import environ
+
+
+env = environ.Env(
+ DEBUG=(bool, False)
+)
+
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+
+DEBUG = env('DEBUG')
+
+# Quick-start development settings - unsuitable for production
+# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/
+
+# SECURITY WARNING: keep the secret key used in production secret!
+if DEBUG:
+ ALLOWED_HOSTS = [
+ 'pythondiscord.local',
+ 'admin.pythondiscord.local',
+ 'api.pythondiscord.local',
+ 'staff.pythondiscord.local',
+ 'wiki.pythondiscord.local'
+ ]
+ SECRET_KEY = "+_x00w3e94##2-qm-v(5&-x_@*l3t9zlir1etu+7$@4%!it2##"
+
+elif 'CI' in os.environ:
+ ALLOWED_HOSTS = ['*']
+ SECRET_KEY = "{©ø¬½.Þ7&Ñ`Q^Kº*~¢j<wxß¾±ðÛJ@q"
+
+else:
+ ALLOWED_HOSTS = env.list(
+ 'ALLOWED_HOSTS',
+ default=[
+ 'pythondiscord.com',
+ 'admin.pythondiscord.com',
+ 'api.pythondiscord.com',
+ 'staff.pythondiscord.local',
+ 'wiki.pythondiscord.local'
+ ]
+ )
+ SECRET_KEY = env('SECRET_KEY')
+
+
+# Application definition
+
+INSTALLED_APPS = [
+ 'pydis_site.apps.api',
+ 'pydis_site.apps.home',
+ 'pydis_site.apps.wiki',
+
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+
+ 'crispy_forms',
+ 'django_hosts',
+ 'django_filters',
+ 'django_crispy_bulma',
+ 'django_simple_bulma',
+ 'rest_framework',
+ 'rest_framework.authtoken'
+]
+
+MIDDLEWARE = [
+ 'django_hosts.middleware.HostsRequestMiddleware',
+
+ 'django.middleware.security.SecurityMiddleware',
+ 'django.contrib.sessions.middleware.SessionMiddleware',
+ 'django.middleware.common.CommonMiddleware',
+ 'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
+ 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+
+ 'django_hosts.middleware.HostsResponseMiddleware',
+]
+ROOT_URLCONF = 'pydis_site.urls'
+
+TEMPLATES = [
+ {
+ 'BACKEND': 'django.template.backends.django.DjangoTemplates',
+ 'DIRS': [os.path.join(BASE_DIR, 'pydis_site', 'templates')],
+ 'APP_DIRS': True,
+ 'OPTIONS': {
+ 'builtins': [
+ 'django_hosts.templatetags.hosts_override',
+ ],
+
+ 'context_processors': [
+ 'django.template.context_processors.debug',
+ 'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
+ 'django.contrib.messages.context_processors.messages',
+ ],
+ },
+ },
+]
+
+WSGI_APPLICATION = 'pydis_site.wsgi.application'
+
+
+# Database
+# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
+
+DATABASES = {
+ 'default': env.db()
+}
+
+
+# Password validation
+# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators
+
+AUTH_PASSWORD_VALIDATORS = [
+ {
+ 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ },
+ {
+ 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ },
+]
+
+
+# Internationalization
+# https://docs.djangoproject.com/en/2.1/topics/i18n/
+
+LANGUAGE_CODE = 'en-us'
+
+TIME_ZONE = 'UTC'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+# https://docs.djangoproject.com/en/2.1/howto/static-files/
+
+STATIC_URL = '/static/'
+STATICFILES_DIRS = [os.path.join(BASE_DIR, 'pydis_site', 'static')]
+STATIC_ROOT = env('STATIC_ROOT', default='staticfiles')
+
+STATICFILES_FINDERS = [
+ 'django.contrib.staticfiles.finders.FileSystemFinder',
+ 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+
+ 'django_simple_bulma.finders.SimpleBulmaFinder',
+]
+
+# django-hosts
+# https://django-hosts.readthedocs.io/en/latest/
+ROOT_HOSTCONF = 'pydis_site.hosts'
+DEFAULT_HOST = 'home'
+
+if DEBUG:
+ PARENT_HOST = 'pythondiscord.local:8000'
+else:
+ PARENT_HOST = env('PARENT_HOST', default='pythondiscord.com')
+
+# Django REST framework
+# http://www.django-rest-framework.org
+REST_FRAMEWORK = {
+ 'DEFAULT_AUTHENTICATION_CLASSES': (
+ 'rest_framework.authentication.TokenAuthentication',
+ ),
+ 'DEFAULT_PERMISSION_CLASSES': (
+ 'rest_framework.permissions.DjangoModelPermissions',
+ ),
+ 'TEST_REQUEST_DEFAULT_FORMAT': 'json'
+}
+
+
+# Logging
+# https://docs.djangoproject.com/en/2.1/topics/logging/
+LOGGING = {
+ 'version': 1,
+ 'disable_existing_loggers': False,
+ 'formatters': {
+ 'verbose': {
+ 'format': (
+ '%(asctime)s | %(process)d:%(thread)d | %(module)s | %(levelname)-8s | %(message)s'
+ )
+ }
+ },
+ 'handlers': {
+ 'console': {
+ 'class': 'logging.StreamHandler'
+ }
+ },
+ 'loggers': {
+ 'django': {
+ 'handlers': ['console'],
+ 'propagate': True,
+ 'level': env(
+ 'LOG_LEVEL',
+ default=(
+ # If there is no explicit `LOG_LEVEL` set,
+ # use `DEBUG` if we're running in debug mode but not
+ # testing. Use `ERROR` if we're running tests, else
+ # default to using `WARN`.
+ 'INFO'
+ if DEBUG and 'test' not in sys.argv
+ else (
+ 'ERROR'
+ if 'test' in sys.argv
+ else 'WARN'
+ )
+ )
+ )
+ }
+ }
+}
+
+# Custom settings for Crispyforms
+CRISPY_ALLOWED_TEMPLATE_PACKS = (
+ "bootstrap",
+ "uni_form",
+ "bootstrap3",
+ "bootstrap4",
+ "bulma",
+)
+
+CRISPY_TEMPLATE_PACK = "bulma"
+
+# Custom settings for django-simple-bulma
+BULMA_SETTINGS = {
+ "variables": {
+ "primary": "#7289DA",
+ "link": "$primary",
+ }
+}
diff --git a/pydis_site/static/assets/logo-banner.png b/pydis_site/static/assets/logo-banner.png
new file mode 100644
index 00000000..89aa9b5a
--- /dev/null
+++ b/pydis_site/static/assets/logo-banner.png
Binary files differ
diff --git a/pydis_site/static/assets/logo-banner.svg b/pydis_site/static/assets/logo-banner.svg
new file mode 100644
index 00000000..ac04d699
--- /dev/null
+++ b/pydis_site/static/assets/logo-banner.svg
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="539.62mm" height="188.69mm" version="1.1" viewBox="0 0 539.62 188.69" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
+ <metadata>
+ <rdf:RDF>
+ <cc:Work rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
+ <dc:title/>
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <g fill="#7289da">
+ <path class="st0" d="m145.76-3.5399e-7h-126.42c-10.661 0-19.341 8.6798-19.341 19.435v127.56c0 10.755 8.6798 19.435 19.341 19.435h106.99l-5.0003-17.454 12.076 11.227 11.416 10.567 20.284 17.926v-169.26c0-10.755-8.6799-19.435-19.341-19.435zm-36.418 123.22s-3.3965-4.0569-6.2268-7.642c12.359-3.4908 17.077-11.227 17.077-11.227-3.8682 2.5473-7.5477 4.3399-10.85 5.5664-4.7173 1.9812-9.2459 3.3021-13.68 4.0569-9.0572 1.6983-17.36 1.2265-24.436-0.0942-5.3777-1.0378-10.001-2.5474-13.869-4.0569-2.17-0.8491-4.5286-1.8869-6.8872-3.2078-0.28292-0.18904-0.56583-0.28292-0.84907-0.47196-0.18904-0.0945-0.28292-0.18903-0.37744-0.28291-1.6983-0.94346-2.6417-1.6039-2.6417-1.6039s4.5286 7.5477 16.511 11.133c-2.8303 3.5853-6.3212 7.8308-6.3212 7.8308-20.851-0.65959-28.776-14.34-28.776-14.34 0-30.379 13.586-55.004 13.586-55.004 13.586-10.189 26.511-9.9063 26.511-9.9063l0.94346 1.1322c-16.982 4.906-24.813 12.359-24.813 12.359s2.0756-1.1321 5.5664-2.7361c10.095-4.4343 18.114-5.6608 21.417-5.9438 0.56583-0.09452 1.0378-0.18904 1.6039-0.18904 5.7551-0.75474 12.265-0.94346 19.058-0.18904 8.9628 1.0378 18.586 3.6795 28.398 9.0572 0 0-7.4533-7.0759-23.492-11.982l1.3207-1.5095s12.925-0.28292 26.511 9.9063c0 0 13.586 24.624 13.586 55.004 0 0-8.0194 13.68-28.87 14.341z" stroke-width=".94346"/>
+ <path d="m154.42 178.89c-5.574-4.9347-15.262-13.738-21.528-19.563-6.2662-5.8252-11.484-10.501-11.595-10.39-0.11152 0.11152 0.87562 3.9535 2.1919 8.5392 1.3163 4.5858 2.3934 8.4694 2.3934 8.6303 0 0.56284-105.75 0.30516-109.27-0.26631-1.8664-0.3028-4.5072-1.115-5.8685-1.8048-3.5244-1.7859-7.6491-6.3569-9.1906-10.185l-1.2833-3.1869v-134.95l1.2869-3.2004c1.774-4.4122 6.6384-9.2677 10.868-10.848l3.0871-1.1535h66.825c57.549 0 67.19 0.10983 69.456 0.79174 3.4099 1.0263 7.5126 4.1657 9.7027 7.4244 3.5515 5.2843 3.3463-0.72307 3.1974 93.602l-0.13518 85.532zm-94.054-59.59 3.0253-3.8413-2.2252-0.9655c-4.5105-1.9571-6.7523-3.2408-9.6997-5.5541-1.6597-1.3026-2.9147-2.4714-2.7888-2.5973 0.12504-0.12588 2.4866 0.83897 5.2461 2.1441 19.104 9.0354 41.671 8.8487 60.231-0.49818 2.11-1.0626 3.9427-1.8257 4.0727-1.6957 0.87612 0.87616-12.37 8.9-14.693 8.9-0.9618 0-0.72828 0.41699 2.4056 4.2957 2.9306 3.6271 3.046 3.7052 5.4674 3.6961 8.7579-0.0321 20.999-5.8861 25.604-12.243 1.4155-1.9541 1.4888-2.3249 1.2874-6.5113-0.73706-15.32-4.8366-32.152-11.136-45.726-2.596-5.5932-3.5122-6.472-10.347-9.9252-6.7432-3.4067-17.724-5.9774-19.442-4.5516-0.45521 0.37774-0.82746 0.83958-0.82746 1.0263s1.6201 0.90448 3.6005 1.595c4.2047 1.4662 14.417 6.38 14.907 7.1727 0.21544 0.34862-0.0304 0.39298-0.68434 0.12318-3.9072-1.6128-11.844-4.1021-15.423-4.8375-13.59-2.7923-31.966-1.47-43.992 3.1658-7.2671 2.8012-6.8621 2.6799-5.652 1.6933 1.9562-1.5949 10.19-5.5547 15.114-7.2684 4.0261-1.4013 4.8292-1.8553 4.3352-2.4505-1.867-2.2496-15.185 1.5071-23.353 6.5873-3.7928 2.359-4.0192 2.6179-6.0149 6.8766-6.2821 13.406-10.451 30.045-11.44 45.663l-0.36211 5.7168 3.9446 3.9311c2.8384 2.8287 5.0665 4.4794 7.9451 5.8864 4.8558 2.3733 10.978 4.0254 14.935 4.0302l2.9337 4e-3 3.0253-3.8413z" stroke-width=".5334"/>
+ <path d="m21.518 27.983h123.75v106.68h-123.75z" stroke-width="16.897"/>
+ </g>
+ <g fill="#5b6dae">
+ <path d="m131.54 52.372 6.2582 5.3738 2.69 13.088-21.061-12.16z"/>
+ <path d="m87.366 25.158c-4.7785 0.02207-9.342 0.42919-13.357 1.1403-11.829 2.0897-13.976 6.4637-13.976 14.53v10.653h27.953v3.5511h-38.443c-8.1238 0-15.237 4.8829-17.462 14.172-2.5665 10.647-2.6803 17.291 0 28.409 1.9869 8.2754 6.7321 14.172 14.856 14.172h9.6107v-12.771c0-9.2262 7.9827-17.365 17.462-17.365h27.92c7.7719 0 13.976-6.3991 13.976-14.204v-26.617c0-7.5753-6.3906-13.266-13.976-14.53-4.8019-0.79938-9.7842-1.1625-14.563-1.1403zm-15.117 8.5683c2.8874 0 5.2451 2.3964 5.2451 5.3429-6e-6 2.9361-2.3579 5.3104-5.2451 5.3104-2.8977 0-5.2452-2.3742-5.2452-5.3104 0-2.9465 2.3475-5.3429 5.2452-5.3429z" stroke-width="1.0425"/>
+ <path d="m119.39 55.032v12.413c0 9.6233-8.1587 17.723-17.462 17.723h-27.92c-7.6478 0-13.976 6.5455-13.976 14.204v26.617c0 7.5753 6.5873 12.031 13.976 14.204 8.8482 2.6017 17.333 3.0719 27.92 0 7.0372-2.0375 13.976-6.138 13.976-14.204v-10.653h-27.92v-3.5511h41.896c8.1238 0 11.151-5.6666 13.976-14.172 2.9181-8.756 2.794-17.176 0-28.409-2.0076-8.0873-5.8421-14.172-13.976-14.172zm-15.703 67.406c2.8977 1e-5 5.2452 2.3742 5.2452 5.3103-1e-5 2.9465-2.3476 5.3429-5.2452 5.3429-2.8873 0-5.2451-2.3964-5.2451-5.3429 6e-6 -2.9361 2.3579-5.3103 5.2451-5.3103z" stroke-width="1.0425"/>
+ <g stroke-width="16.176">
+ <path d="m64.566 32.933h16.085v14.553h-16.085z"/>
+ <path d="m96.736 121.02h14.042v13.532h-14.042z"/>
+ <path d="m57.758 27.064h14.491v6.6617h-14.491z"/>
+ </g>
+ <path d="m109.67 109.98 6.2363 5.353v0.75275h-7.6321v-5.8264z"/>
+ <path d="m59.356 32.37h2.7229v4.116h-2.7229z"/>
+ <path d="m53.796 46.128 6.2363 5.353h6.6393v-9.2446h-12.089z"/>
+ <path d="m104.45 24.881 6 5.1716-2.3657 5.1311-11.622-8.0211z"/>
+ <path d="m32.239 103.49 6.0335 5.2612 7.1305 0.24626-12.38-12.38z"/>
+ </g>
+ <path d="m81.129 19.804c-4.7785 0.0221-9.342 0.42919-13.357 1.1403-11.829 2.0898-13.976 6.4637-13.976 14.53v10.653h27.953v3.5511h-38.443c-8.1238 0-15.237 4.8829-17.462 14.172-2.5665 10.647-2.6803 17.291 0 28.409 1.9869 8.2754 6.7321 14.172 14.856 14.172h9.6107v-12.771c0-9.2262 7.9827-17.364 17.462-17.364h27.92c7.7719 0 13.976-6.3992 13.976-14.204v-26.617c0-7.5753-6.3906-13.266-13.976-14.53-4.8019-0.79938-9.7842-1.1625-14.563-1.1403zm-15.117 8.5682c2.8874 0 5.2451 2.3964 5.2451 5.3429-6e-6 2.9361-2.3579 5.3104-5.2451 5.3104-2.8977 0-5.2452-2.3742-5.2452-5.3104 0-2.9465 2.3475-5.3429 5.2452-5.3429z" fill="#cad6ff"/>
+ <g fill="#fff">
+ <path d="m113.15 49.679v12.413c0 9.6233-8.1587 17.723-17.462 17.723h-27.92c-7.6478 0-13.976 6.5455-13.976 14.204v26.617c0 7.5753 6.5873 12.031 13.976 14.204 8.8482 2.6017 17.333 3.0719 27.92 0 7.0372-2.0375 13.976-6.138 13.976-14.204v-10.653h-27.92v-3.5511h41.896c8.1238 0 11.151-5.6666 13.976-14.172 2.9184-8.756 2.7942-17.176 0-28.409-2.0077-8.0873-5.8422-14.172-13.976-14.172zm-15.703 67.406c2.8977 0 5.2452 2.3742 5.2452 5.3103-1e-5 2.9465-2.3476 5.3429-5.2452 5.3429-2.8873 0-5.2451-2.3964-5.2451-5.3429 6e-6 -2.9361 2.3579-5.3103 5.2451-5.3103z"/>
+ <g stroke="#fff" stroke-width="2">
+ <path d="m193.51 78.047h15.115v-17.48h8.8665c14.44 0 21.786-10.555 21.786-21.195 0-10.555-7.2621-21.111-21.871-21.111h-23.897zm15.115-30.906v-14.778h8.7821c9.1198-0.08449 9.1198 14.862 0 14.778z"/>
+ <path d="m278.74 78.047v-23.56l20.942-36.226h-17.733l-10.809 21.955-10.809-21.955h-17.564l20.857 36.226v23.56z"/>
+ <path d="m355.84 32.532v-14.355h-47.035v14.355h15.875v45.515h15.284v-45.515z"/>
+ <path d="m381.89 55.5h17.226v22.546h15.2v-59.786h-15.2v22.631h-17.226v-22.631h-15.2v59.786h15.2z"/>
+ <path d="m426.02 58.371c0 13.68 12.413 20.52 24.826 20.52 12.413 0 24.742-6.8399 24.742-20.52v-20.435c0-13.68-12.413-20.52-24.826-20.52-12.413 0-24.742 6.7554-24.742 20.52zm15.115-20.435c0-4.391 4.7288-6.7554 9.4576-6.7554 4.8977 0 9.8798 2.1111 9.8798 6.7554v20.435c0 4.3066-4.8132 6.5021-9.6265 6.5021-4.8132 0-9.7109-2.1111-9.7109-6.5021z"/>
+ <path d="m487.23 78.047h15.115v-23.897l-1.9422-11.569 0.42221-0.08449 5.2355 11.991 13.511 23.56h15.284v-59.786h-15.031v27.106c0.0845 0 1.6044 10.302 1.6888 10.302l-0.4222 0.08449-5.0666-11.991-14.44-25.502h-14.355z"/>
+ </g>
+ </g>
+ <path d="m295.7 97.875c-10.963 0-21.589 6.1561-21.589 17.878 0 11.385 8.7707 17.457 18.384 18.975 4.8912 0.67466 10.373 2.6144 10.204 5.9876-0.42162 6.4092-13.577 6.0717-19.565-1.1808l-9.5295 8.9391c5.5659 7.1682 13.156 10.794 20.324 10.794 10.963 0 23.107-6.3247 23.613-17.878 0.67464-14.674-9.951-18.384-20.577-20.324-4.6382-1.012-7.7589-2.6987-7.9276-5.566 0.25302-6.9152 10.963-7.168 17.204-0.50575l9.867-7.59c-6.1562-7.5055-13.155-9.5295-20.408-9.5295zm53.073 0c-11.975 0-23.782 6.9155-23.782 20.409v20.577c0 13.577 11.807 20.408 23.529 20.408 7.5055 0 16.529-3.7102 21.505-13.408l-13.156-6.0722c-3.2889 8.2645-16.782 6.2408-16.782-0.9274v-20.577c0-7.4212 13.324-9.1083 17.372-1.6871l12.312-4.9756c-4.8069-10.626-13.662-13.746-20.998-13.746zm52.363 0c-12.397 0-24.709 6.7466-24.709 20.493v20.408c0 13.662 12.397 20.493 24.794 20.493 12.397 0 24.709-6.8309 24.709-20.493v-20.408c0-13.662-12.397-20.493-24.794-20.493zm-207.6 0.84327v22.721l15.18 13.823v-22.461h7.6742c5.0599 0 7.5054 2.4459 7.5054 6.3252v18.637c0 3.8793-2.3612 6.4934-7.5054 6.4934h-7.6597l-5.2e-4 5.2e-4h-15.194v14.167h22.348c11.975 0.0842 23.191-5.9033 23.191-19.649v-20.071c0-13.915-11.216-19.987-23.191-19.987zm56.047 0v59.707h15.095v-59.707zm186.64 0v59.707h15.264v-18.974h2.6986l13.83 18.974h18.806l-16.276-20.493c7.2526-2.277 11.722-8.5173 11.722-19.565-0.33736-13.999-9.8668-19.649-22.179-19.649zm56.871 0v22.727l15.18 13.823v-22.466h7.6742c5.0599 0 7.5054 2.4459 7.5054 6.3252v18.637c0 3.8793-2.3612 6.4934-7.5054 6.4934h-7.6659l-5.2e-4 5.2e-4h-15.187v14.167h22.348c11.975 0.0842 23.191-5.9033 23.191-19.649v-20.071c0-13.915-11.216-19.987-23.191-19.987zm-92.117 12.903c4.8913 0 9.867 2.1085 9.867 6.7468v20.408c0 4.3009-4.8067 6.4939-9.6136 6.4939-4.8069 0-9.6982-2.1086-9.6982-6.4939v-20.408c0-4.3853 4.7223-6.7468 9.4448-6.7468zm50.511 1.1808h8.8545c9.5295 0 9.5295 13.662 0 13.662h-8.8545z" fill="#cad6ff" stroke="#cad6ff" stroke-width="1.9974"/>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+ <g transform="translate(-3.7229 -3.7549)"></g>
+</svg>
diff --git a/pydis_site/static/assets/logo-discord.png b/pydis_site/static/assets/logo-discord.png
new file mode 100644
index 00000000..2bf74ffd
--- /dev/null
+++ b/pydis_site/static/assets/logo-discord.png
Binary files differ
diff --git a/pydis_site/static/css/navbar.css b/pydis_site/static/css/navbar.css
new file mode 100644
index 00000000..db4b85e7
--- /dev/null
+++ b/pydis_site/static/css/navbar.css
@@ -0,0 +1,4 @@
+.navbar-icon {
+ max-height: 3em;
+ margin: 0.5em 0 0.5em 2.5em;
+}
diff --git a/pydis_site/static/home/css/index.css b/pydis_site/static/home/css/index.css
new file mode 100644
index 00000000..76653320
--- /dev/null
+++ b/pydis_site/static/home/css/index.css
@@ -0,0 +1,30 @@
+html {
+ background-color: #7289DA;
+}
+
+.overview > h1 {
+ margin-top: 0.5em;
+ margin-bottom: -0.25em;
+}
+
+.overview > p.is-size-7 {
+ margin-bottom: 2em;
+}
+
+.overview > p.is-size-4 {
+ margin-bottom: 1em;
+}
+
+.overview > p.is-size-6 {
+ margin-bottom: 1em;
+}
+
+.overview > img {
+ border: 1px solid #6378BF;
+ margin-bottom: 1em;
+}
+
+.overview > .divider {
+ letter-spacing: -3px;
+ margin-bottom: 1em;
+}
diff --git a/pydis_site/templates/base.html b/pydis_site/templates/base.html
new file mode 100644
index 00000000..1dcdfdc4
--- /dev/null
+++ b/pydis_site/templates/base.html
@@ -0,0 +1,22 @@
+{# Base template, with a few basic style definitions. #}
+{% load django_simple_bulma %}
+{% load static %}
+
+<!DOCTYPE html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+ <title>Python Discord | {% block page_title %}Website{% endblock %}</title>
+ <meta name="description" content="{% block page_description %}We're a large, friendly community focused around the Python programming language. Our community is open to those who wish to learn the language, as well as those looking to help others.{% endblock %}">
+
+ {% bulma %}
+ {% font_awesome %}
+ {% block head %}{% endblock %}
+</head>
+<body>
+ {% block body %}
+ {% endblock %}
+</body>
+
+
+<!-- vim: set ft=htmldjango: -->
diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html
new file mode 100644
index 00000000..cc99763b
--- /dev/null
+++ b/pydis_site/templates/home/index.html
@@ -0,0 +1,42 @@
+{% extends 'navbar.html' %}
+{% load static %}
+
+{% block page_title %}Home{% endblock %}
+{% block head %}
+ {{ block.super }}
+ <link rel="stylesheet" href="{% static 'home/css/index.css' %}">
+{% endblock %}
+{% block body %}
+ {{ block.super }}
+ <div class="overview has-text-centered container">
+ <h1 class="has-text-white has-text-weight-semibold is-size-2">Python Discord</h1>
+ <p class="has-text-grey-lighter is-size-7">
+ The official Discord server of
+ <a class="has-text-white" href="https://reddit.com/r/Python">r/Python</a>
+ </p>
+
+ <p class="has-text-light is-size-4">
+ We're a large, friendly community focused around the Python programming language, open to those
+ who wish to learn the language or improve their skills, as well as those looking to help others.
+ </p>
+
+ <p class="has-text-light is-size-6">
+ We organise regular community events and have a dedicated staff of talented Python
+ developers available to assist around the clock. Whether you're looking to learn the
+ language or working on a complex project, we've got someone who can help you if you get stuck.
+ </p>
+
+ <img src="https://discordapp.com/api/guilds/267624335836053506/embed.png?style=banner3">
+
+ <p class="divider has-text-grey-light">
+ ------------------------------------------------------------------------------------------------------------&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;O&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;-------------------------------------------------------------------------------------------------------------
+ </p>
+
+ <p class="has-text-grey-lighter is-size-7">
+ Please note: this site is under construction. What you see now may be vastly different
+ from the final project state. Feel free to chat to us on Discord if you're curious!
+ </p>
+ </div>
+{% endblock %}
+
+<!-- vim: set ft=htmldjango: -->
diff --git a/pydis_site/templates/navbar.html b/pydis_site/templates/navbar.html
new file mode 100644
index 00000000..0efa51c2
--- /dev/null
+++ b/pydis_site/templates/navbar.html
@@ -0,0 +1,16 @@
+{% extends 'base.html' %}
+{% load static %}
+
+{% block head %}
+ <link rel="stylesheet" href="{% static 'css/navbar.css' %}">
+{% endblock %}
+{% block body %}
+ <nav class="navbar is-dark" role="navigation" aria-label="main navigation">
+ <div class="navbar-brand">
+ <img src="{% static 'assets/logo-banner.svg' %}" class="navbar-brand navbar-icon" alt="Python Discord">
+ </div>
+ </nav>
+ {{ block.super }}
+{% endblock %}
+
+<!-- vim: set ft=htmldjango: -->
diff --git a/pydis_site/urls.py b/pydis_site/urls.py
new file mode 100644
index 00000000..c68375da
--- /dev/null
+++ b/pydis_site/urls.py
@@ -0,0 +1,6 @@
+from django.urls import include, path
+
+
+urlpatterns = (
+ path('', include('pydis_site.apps.home.urls', namespace='home')),
+)
diff --git a/pydis_site/wsgi.py b/pydis_site/wsgi.py
new file mode 100644
index 00000000..853e56f1
--- /dev/null
+++ b/pydis_site/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for pydis_site project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/2.1/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'pydis_site.settings')
+
+application = get_wsgi_application()