diff options
42 files changed, 600 insertions, 90 deletions
diff --git a/Pipfile.lock b/Pipfile.lock index 097c4f81..02d81d76 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -610,11 +610,11 @@ }, "identify": { "hashes": [ - "sha256:882c4b08b4569517b5f2257ecca180e01f38400a17f429f5d0edff55530c41c7", - "sha256:f89add935982d5bc62913ceee16c9297d8ff14b226e9d3072383a4e38136b656" + "sha256:06b4373546ae55eaaefdac54f006951dbd968fe2912846c00e565b09cfaed101", + "sha256:5519601b70c831011fb425ffd214101df7639ba3980f24dc283f7675b19127b3" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.23" + "version": "==1.4.24" }, "importlib-metadata": { "hashes": [ @@ -727,11 +727,11 @@ }, "stevedore": { "hashes": [ - "sha256:79270bd5fb4a052e76932e9fef6e19afa77090c4000f2680eb8c2e887d2e6e36", - "sha256:9fb12884b510fdc25f8a883bb390b8ff82f67863fb360891a33135bcb2ce8c54" + "sha256:38791aa5bed922b0a844513c5f9ed37774b68edc609e5ab8ab8d8fe0ce4315e5", + "sha256:c8f4f0ebbc394e52ddf49de8bcc3cf8ad2b4425ebac494106bbc5e3661ac7633" ], "markers": "python_version >= '3.6'", - "version": "==3.1.0" + "version": "==3.2.0" }, "toml": { "hashes": [ @@ -777,11 +777,11 @@ }, "virtualenv": { "hashes": [ - "sha256:c11a475400e98450403c0364eb3a2d25d42f71cf1493da64390487b666de4324", - "sha256:e10cc66f40cbda459720dfe1d334c4dc15add0d80f09108224f171006a97a172" + "sha256:26cdd725a57fef4c7c22060dba4647ebd8ca377e30d1c1cf547b30a0b79c43b4", + "sha256:c51f1ba727d1614ce8fd62457748b469fbedfdab2c7e5dd480c9ae3fbe1233f1" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.0.26" + "version": "==20.0.27" }, "zipp": { "hashes": [ diff --git a/pydis_site/apps/api/migrations/0007_tag.py b/pydis_site/apps/api/migrations/0007_tag.py index c22715f9..b6d146fe 100644 --- a/pydis_site/apps/api/migrations/0007_tag.py +++ b/pydis_site/apps/api/migrations/0007_tag.py @@ -18,6 +18,6 @@ class Migration(migrations.Migration): ('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), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0009_snakefact.py b/pydis_site/apps/api/migrations/0009_snakefact.py index 4fc63bc9..fd583846 100644 --- a/pydis_site/apps/api/migrations/0009_snakefact.py +++ b/pydis_site/apps/api/migrations/0009_snakefact.py @@ -16,6 +16,6 @@ class Migration(migrations.Migration): 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), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0010_snakeidiom.py b/pydis_site/apps/api/migrations/0010_snakeidiom.py index be089cf4..7d06ce5f 100644 --- a/pydis_site/apps/api/migrations/0010_snakeidiom.py +++ b/pydis_site/apps/api/migrations/0010_snakeidiom.py @@ -16,6 +16,6 @@ class Migration(migrations.Migration): 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), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0012_specialsnake.py b/pydis_site/apps/api/migrations/0012_specialsnake.py index 77072526..ed0c1563 100644 --- a/pydis_site/apps/api/migrations/0012_specialsnake.py +++ b/pydis_site/apps/api/migrations/0012_specialsnake.py @@ -17,6 +17,6 @@ class Migration(migrations.Migration): ('name', models.CharField(max_length=140, primary_key=True, serialize=False)), ('info', models.TextField()), ], - bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py b/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py index dced1288..7e372d04 100644 --- a/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py +++ b/pydis_site/apps/api/migrations/0018_messagedeletioncontext.py @@ -19,6 +19,6 @@ class Migration(migrations.Migration): ('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), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py index 4b028f0c..33746253 100644 --- a/pydis_site/apps/api/migrations/0019_deletedmessage.py +++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py @@ -25,6 +25,6 @@ class Migration(migrations.Migration): options={ 'abstract': False, }, - bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0020_infraction.py b/pydis_site/apps/api/migrations/0020_infraction.py index 6bef6b77..96c71687 100644 --- a/pydis_site/apps/api/migrations/0020_infraction.py +++ b/pydis_site/apps/api/migrations/0020_infraction.py @@ -25,6 +25,6 @@ class Migration(migrations.Migration): ('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), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] 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 index 0c02cb91..c7fac012 100644 --- 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 @@ -1,7 +1,7 @@ # Generated by Django 2.1.4 on 2019-01-06 16:01 -import datetime from django.db import migrations, models +from django.utils import timezone class Migration(migrations.Migration): @@ -14,6 +14,6 @@ class Migration(migrations.Migration): 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.'), + field=models.DateTimeField(default=timezone.now, help_text='The date and time of the creation of this infraction.'), ), ] diff --git a/pydis_site/apps/api/migrations/0030_reminder.py b/pydis_site/apps/api/migrations/0030_reminder.py index 8c42f6dc..e1f1afc3 100644 --- a/pydis_site/apps/api/migrations/0030_reminder.py +++ b/pydis_site/apps/api/migrations/0030_reminder.py @@ -22,6 +22,6 @@ class Migration(migrations.Migration): ('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), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0031_nomination.py b/pydis_site/apps/api/migrations/0031_nomination.py index 75e69701..f39436c1 100644 --- a/pydis_site/apps/api/migrations/0031_nomination.py +++ b/pydis_site/apps/api/migrations/0031_nomination.py @@ -21,6 +21,6 @@ class Migration(migrations.Migration): ('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), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0032_botsetting.py b/pydis_site/apps/api/migrations/0032_botsetting.py index 25186a2b..3304edef 100644 --- a/pydis_site/apps/api/migrations/0032_botsetting.py +++ b/pydis_site/apps/api/migrations/0032_botsetting.py @@ -18,6 +18,6 @@ class Migration(migrations.Migration): ('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), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0035_create_table_log_entry.py b/pydis_site/apps/api/migrations/0035_create_table_log_entry.py index a8256a0e..c9a1ad19 100644 --- a/pydis_site/apps/api/migrations/0035_create_table_log_entry.py +++ b/pydis_site/apps/api/migrations/0035_create_table_log_entry.py @@ -24,6 +24,6 @@ class Migration(migrations.Migration): ('line', models.PositiveSmallIntegerField(help_text='The line at which the log line was emitted.')), ('message', models.TextField(help_text='The textual content of the log line.')), ], - bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0049_offensivemessage.py b/pydis_site/apps/api/migrations/0049_offensivemessage.py index fe4a1961..f342cec3 100644 --- a/pydis_site/apps/api/migrations/0049_offensivemessage.py +++ b/pydis_site/apps/api/migrations/0049_offensivemessage.py @@ -3,7 +3,7 @@ import django.core.validators from django.db import migrations, models import pydis_site.apps.api.models.bot.offensive_message -import pydis_site.apps.api.models.utils +import pydis_site.apps.api.models.mixins class Migration(migrations.Migration): @@ -20,6 +20,6 @@ class Migration(migrations.Migration): ('channel_id', models.BigIntegerField(help_text='The channel ID that the message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), ('delete_date', models.DateTimeField(help_text='The date on which the message will be auto-deleted.', validators=[pydis_site.apps.api.models.bot.offensive_message.future_date_validator])), ], - bases=(pydis_site.apps.api.models.utils.ModelReprMixin, models.Model), + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), ), ] diff --git a/pydis_site/apps/api/migrations/0058_create_new_filterlist_model.py b/pydis_site/apps/api/migrations/0058_create_new_filterlist_model.py new file mode 100644 index 00000000..aecfdad7 --- /dev/null +++ b/pydis_site/apps/api/migrations/0058_create_new_filterlist_model.py @@ -0,0 +1,33 @@ +# Generated by Django 3.0.8 on 2020-07-15 11:23 + +from django.db import migrations, models +import pydis_site.apps.api.models.mixins + + +class Migration(migrations.Migration): + dependencies = [ + ('api', '0057_merge_20200716_0751'), + ] + + operations = [ + migrations.CreateModel( + name='FilterList', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('type', models.CharField( + choices=[('GUILD_INVITE', 'Guild Invite'), ('FILE_FORMAT', 'File Format'), + ('DOMAIN_NAME', 'Domain Name'), ('FILTER_TOKEN', 'Filter Token')], + help_text='The type of allowlist this is on.', max_length=50)), + ('allowed', models.BooleanField(help_text='Whether this item is on the allowlist or the denylist.')), + ('content', models.TextField(help_text='The data to add to the allow or denylist.')), + ('comment', models.TextField(help_text="Optional comment on this entry.", null=True)), + ], + bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model), + ), + migrations.AddConstraint( + model_name='filterlist', + constraint=models.UniqueConstraint(fields=('content', 'type'), name='unique_filter_list') + ) + ] diff --git a/pydis_site/apps/api/migrations/0059_populate_filterlists.py b/pydis_site/apps/api/migrations/0059_populate_filterlists.py new file mode 100644 index 00000000..8c550191 --- /dev/null +++ b/pydis_site/apps/api/migrations/0059_populate_filterlists.py @@ -0,0 +1,153 @@ +from django.db import migrations + +guild_invite_whitelist = [ + ("discord.gg/python", "Python Discord", True), + ("discord.gg/4JJdJKb", "RLBot", True), + ("discord.gg/djPtTRJ", "Kivy", True), + ("discord.gg/QXyegWe", "Pyglet", True), + ("discord.gg/9XsucTT", "Panda3D", True), + ("discord.gg/AP3rq2k", "PyWeek", True), + ("discord.gg/vSPsP9t", "Microsoft Python", True), + ("discord.gg/bRCvFy9", "Discord.js Official", True), + ("discord.gg/9zT7NHP", "Programming Discussions", True), + ("discord.gg/ysd6M4r", "JetBrains Community", True), + ("discord.gg/4xJeCgy", "Raspberry Pie", True), + ("discord.gg/AStb3kZ", "Ren'Py", True), + ("discord.gg/t655QNV", "Python Discord: Emojis 1", True), + ("discord.gg/vRZPkqC", "Python Discord: Emojis 2", True), + ("discord.gg/jTtgWuy", "Django", True), + ("discord.gg/W9BypZF", "STEM", True), + ("discord.gg/dpy", "discord.py", True), + ("discord.gg/programming", "Programmers Hangout", True), + ("discord.gg/qhGUjGD", "SpeakJS", True), + ("discord.gg/eTbWSZj", "Functional Programming", True), + ("discord.gg/r8yreB6", "PyGame", True), + ("discord.gg/5UBnR3P", "Python Atlanta", True), + ("discord.gg/ccyrDKv", "C#", True), +] + +domain_name_blacklist = [ + ("pornhub.com", None, False), + ("liveleak.com", None, False), + ("grabify.link", None, False), + ("bmwforum.co", None, False), + ("leancoding.co", None, False), + ("spottyfly.com", None, False), + ("stopify.co", None, False), + ("yoütu.be", None, False), + ("discörd.com", None, False), + ("minecräft.com", None, False), + ("freegiftcards.co", None, False), + ("disçordapp.com", None, False), + ("fortnight.space", None, False), + ("fortnitechat.site", None, False), + ("joinmy.site", None, False), + ("curiouscat.club", None, False), + ("catsnthings.fun", None, False), + ("yourtube.site", None, False), + ("youtubeshort.watch", None, False), + ("catsnthing.com", None, False), + ("youtubeshort.pro", None, False), + ("canadianlumberjacks.online", None, False), + ("poweredbydialup.club", None, False), + ("poweredbydialup.online", None, False), + ("poweredbysecurity.org", None, False), + ("poweredbysecurity.online", None, False), + ("ssteam.site", None, False), + ("steamwalletgift.com", None, False), + ("discord.gift", None, False), + ("lmgtfy.com", None, False), +] + +filter_token_blacklist = [ + ("\bgoo+ks*\b", None, False), + ("\bky+s+\b", None, False), + ("\bki+ke+s*\b", None, False), + ("\bbeaner+s?\b", None, False), + ("\bcoo+ns*\b", None, False), + ("\bnig+lets*\b", None, False), + ("\bslant-eyes*\b", None, False), + ("\btowe?l-?head+s*\b", None, False), + ("\bchi*n+k+s*\b", None, False), + ("\bspick*s*\b", None, False), + ("\bkill* +(?:yo)?urself+\b", None, False), + ("\bjew+s*\b", None, False), + ("\bsuicide\b", None, False), + ("\brape\b", None, False), + ("\b(re+)tar+(d+|t+)(ed)?\b", None, False), + ("\bta+r+d+\b", None, False), + ("\bcunts*\b", None, False), + ("\btrann*y\b", None, False), + ("\bshemale\b", None, False), + ("fa+g+s*", None, False), + ("卐", None, False), + ("卍", None, False), + ("࿖", None, False), + ("࿕", None, False), + ("࿘", None, False), + ("࿗", None, False), + ("cuck(?!oo+)", None, False), + ("nigg+(?:e*r+|a+h*?|u+h+)s?", None, False), + ("fag+o+t+s*", None, False), +] + +file_format_whitelist = [ + (".3gp", None, True), + (".3g2", None, True), + (".avi", None, True), + (".bmp", None, True), + (".gif", None, True), + (".h264", None, True), + (".jpg", None, True), + (".jpeg", None, True), + (".m4v", None, True), + (".mkv", None, True), + (".mov", None, True), + (".mp4", None, True), + (".mpeg", None, True), + (".mpg", None, True), + (".png", None, True), + (".tiff", None, True), + (".wmv", None, True), + (".svg", None, True), + (".psd", "Photoshop", True), + (".ai", "Illustrator", True), + (".aep", "After Effects", True), + (".xcf", "GIMP", True), + (".mp3", None, True), + (".wav", None, True), + (".ogg", None, True), + (".webm", None, True), + (".webp", None, True), +] + +populate_data = { + "FILTER_TOKEN": filter_token_blacklist, + "DOMAIN_NAME": domain_name_blacklist, + "FILE_FORMAT": file_format_whitelist, + "GUILD_INVITE": guild_invite_whitelist, +} + + +class Migration(migrations.Migration): + dependencies = [("api", "0058_create_new_filterlist_model")] + + def populate_filterlists(app, _): + FilterList = app.get_model("api", "FilterList") + + for filterlist_type, metadata in populate_data.items(): + for content, comment, allowed in metadata: + FilterList.objects.create( + type=filterlist_type, + allowed=allowed, + content=content, + comment=comment, + ) + + def clear_filterlists(app, _): + FilterList = app.get_model("api", "FilterList") + FilterList.objects.all().delete() + + operations = [ + migrations.RunPython(populate_filterlists, clear_filterlists) + ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index 450d18cd..1d0ab7ea 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa from .bot import ( + FilterList, BotSetting, DocumentationLink, DeletedMessage, @@ -15,4 +16,3 @@ from .bot import ( User ) from .log_entry import LogEntry -from .utils import ModelReprMixin diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index 8ae47746..efd98184 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -1,4 +1,5 @@ # flake8: noqa +from .filter_list import FilterList from .bot_setting import BotSetting from .deleted_message import DeletedMessage from .documentation_link import DocumentationLink diff --git a/pydis_site/apps/api/models/bot/bot_setting.py b/pydis_site/apps/api/models/bot/bot_setting.py index 8d48eac7..2a3944f8 100644 --- a/pydis_site/apps/api/models/bot/bot_setting.py +++ b/pydis_site/apps/api/models/bot/bot_setting.py @@ -2,7 +2,7 @@ from django.contrib.postgres import fields as pgfields from django.core.exceptions import ValidationError from django.db import models -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin def validate_bot_setting_name(name: str) -> None: diff --git a/pydis_site/apps/api/models/bot/documentation_link.py b/pydis_site/apps/api/models/bot/documentation_link.py index f844ae04..5a46460b 100644 --- a/pydis_site/apps/api/models/bot/documentation_link.py +++ b/pydis_site/apps/api/models/bot/documentation_link.py @@ -1,6 +1,6 @@ from django.db import models -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin class DocumentationLink(ModelReprMixin, models.Model): diff --git a/pydis_site/apps/api/models/bot/filter_list.py b/pydis_site/apps/api/models/bot/filter_list.py new file mode 100644 index 00000000..d279e137 --- /dev/null +++ b/pydis_site/apps/api/models/bot/filter_list.py @@ -0,0 +1,41 @@ +from django.db import models + +from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin + + +class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model): + """An item that is either allowed or denied.""" + + FilterListType = models.TextChoices( + 'FilterListType', + 'GUILD_INVITE ' + 'FILE_FORMAT ' + 'DOMAIN_NAME ' + 'FILTER_TOKEN ' + ) + type = models.CharField( + max_length=50, + help_text="The type of allowlist this is on.", + choices=FilterListType.choices, + ) + allowed = models.BooleanField( + help_text="Whether this item is on the allowlist or the denylist." + ) + content = models.TextField( + help_text="The data to add to the allow or denylist." + ) + comment = models.TextField( + help_text="Optional comment on this entry.", + null=True + ) + + class Meta: + """Metaconfig for this model.""" + + # This constraint ensures only one filterlist with the + # same content can exist. This means that we cannot have both an allow + # and a deny for the same item, and we cannot have duplicates of the + # same item. + constraints = [ + models.UniqueConstraint(fields=['content', 'type'], name='unique_filter_list'), + ] diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py index f58e89a3..7660cbba 100644 --- a/pydis_site/apps/api/models/bot/infraction.py +++ b/pydis_site/apps/api/models/bot/infraction.py @@ -2,7 +2,7 @@ from django.db import models from django.utils import timezone from pydis_site.apps.api.models.bot.user import User -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin class Infraction(ModelReprMixin, models.Model): diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index 0b279580..78dcbf1d 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -7,7 +7,7 @@ from django.utils import timezone from pydis_site.apps.api.models.bot.tag import validate_tag_embed from pydis_site.apps.api.models.bot.user import User -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin class Message(ModelReprMixin, models.Model): diff --git a/pydis_site/apps/api/models/bot/message_deletion_context.py b/pydis_site/apps/api/models/bot/message_deletion_context.py index 44a0c8ae..04ae8d34 100644 --- a/pydis_site/apps/api/models/bot/message_deletion_context.py +++ b/pydis_site/apps/api/models/bot/message_deletion_context.py @@ -1,7 +1,7 @@ from django.db import models from pydis_site.apps.api.models.bot.user import User -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin class MessageDeletionContext(ModelReprMixin, models.Model): diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py index cd9951aa..21e34e87 100644 --- a/pydis_site/apps/api/models/bot/nomination.py +++ b/pydis_site/apps/api/models/bot/nomination.py @@ -1,7 +1,7 @@ from django.db import models from pydis_site.apps.api.models.bot.user import User -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin class Nomination(ModelReprMixin, models.Model): diff --git a/pydis_site/apps/api/models/bot/off_topic_channel_name.py b/pydis_site/apps/api/models/bot/off_topic_channel_name.py index 29280c27..20e77b9f 100644 --- a/pydis_site/apps/api/models/bot/off_topic_channel_name.py +++ b/pydis_site/apps/api/models/bot/off_topic_channel_name.py @@ -1,7 +1,7 @@ from django.core.validators import RegexValidator from django.db import models -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin class OffTopicChannelName(ModelReprMixin, models.Model): diff --git a/pydis_site/apps/api/models/bot/offensive_message.py b/pydis_site/apps/api/models/bot/offensive_message.py index b466d9c2..6c0e5ffb 100644 --- a/pydis_site/apps/api/models/bot/offensive_message.py +++ b/pydis_site/apps/api/models/bot/offensive_message.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin def future_date_validator(date: datetime.date) -> None: diff --git a/pydis_site/apps/api/models/bot/reminder.py b/pydis_site/apps/api/models/bot/reminder.py index 4b5d15ca..7d968a0e 100644 --- a/pydis_site/apps/api/models/bot/reminder.py +++ b/pydis_site/apps/api/models/bot/reminder.py @@ -3,7 +3,7 @@ from django.core.validators import MinValueValidator from django.db import models from pydis_site.apps.api.models.bot.user import User -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin class Reminder(ModelReprMixin, models.Model): diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py index 58bbf8b4..721e4815 100644 --- a/pydis_site/apps/api/models/bot/role.py +++ b/pydis_site/apps/api/models/bot/role.py @@ -3,7 +3,7 @@ from __future__ import annotations from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin class Role(ModelReprMixin, models.Model): diff --git a/pydis_site/apps/api/models/bot/tag.py b/pydis_site/apps/api/models/bot/tag.py index 5d4cc393..5e53582f 100644 --- a/pydis_site/apps/api/models/bot/tag.py +++ b/pydis_site/apps/api/models/bot/tag.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxLengthValidator, MinLengthValidator from django.db import models -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin def is_bool_validator(value: Any) -> None: diff --git a/pydis_site/apps/api/models/bot/user.py b/pydis_site/apps/api/models/bot/user.py index 0d8c574a..cd2d58b9 100644 --- a/pydis_site/apps/api/models/bot/user.py +++ b/pydis_site/apps/api/models/bot/user.py @@ -4,7 +4,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from pydis_site.apps.api.models.bot.role import Role -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin def _validate_existing_role(value: int) -> None: diff --git a/pydis_site/apps/api/models/log_entry.py b/pydis_site/apps/api/models/log_entry.py index 488af48e..752cd2ca 100644 --- a/pydis_site/apps/api/models/log_entry.py +++ b/pydis_site/apps/api/models/log_entry.py @@ -1,7 +1,7 @@ from django.db import models from django.utils import timezone -from pydis_site.apps.api.models.utils import ModelReprMixin +from pydis_site.apps.api.models.mixins import ModelReprMixin class LogEntry(ModelReprMixin, models.Model): diff --git a/pydis_site/apps/api/models/utils.py b/pydis_site/apps/api/models/mixins.py index 0540c4de..5d75b78b 100644 --- a/pydis_site/apps/api/models/utils.py +++ b/pydis_site/apps/api/models/mixins.py @@ -1,5 +1,7 @@ from operator import itemgetter +from django.db import models + class ModelReprMixin: """Mixin providing a `__repr__()` to display model class name and initialisation parameters.""" @@ -15,3 +17,15 @@ class ModelReprMixin: if not attribute.startswith('_') ) return f'<{self.__class__.__name__}({attributes})>' + + +class ModelTimestampMixin(models.Model): + """Mixin providing created_at and updated_at fields.""" + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + """Metaconfig for the mixin.""" + + abstract = True diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 80e552a6..52e0d972 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -4,13 +4,20 @@ from rest_framework.validators import UniqueTogetherValidator from rest_framework_bulk import BulkSerializerMixin from .models import ( - BotSetting, DeletedMessage, - DocumentationLink, Infraction, - LogEntry, MessageDeletionContext, - Nomination, OffTopicChannelName, + BotSetting, + DeletedMessage, + DocumentationLink, + FilterList, + Infraction, + LogEntry, + MessageDeletionContext, + Nomination, + OffTopicChannelName, OffensiveMessage, - Reminder, Role, - Tag, User + Reminder, + Role, + Tag, + User ) @@ -97,6 +104,31 @@ class DocumentationLinkSerializer(ModelSerializer): fields = ('package', 'base_url', 'inventory_url') +class FilterListSerializer(ModelSerializer): + """A class providing (de-)serialization of `FilterList` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = FilterList + fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment') + + # This validator ensures only one filterlist with the + # same content can exist. This means that we cannot have both an allow + # and a deny for the same item, and we cannot have duplicates of the + # same item. + validators = [ + UniqueTogetherValidator( + queryset=FilterList.objects.all(), + fields=['content', 'type'], + message=( + "A filterlist for this item already exists. " + "Please note that you cannot add the same item to both allow and deny." + ) + ), + ] + + class InfractionSerializer(ModelSerializer): """A class providing (de-)serialization of `Infraction` instances.""" diff --git a/pydis_site/apps/api/tests/test_filterlists.py b/pydis_site/apps/api/tests/test_filterlists.py new file mode 100644 index 00000000..188c0fff --- /dev/null +++ b/pydis_site/apps/api/tests/test_filterlists.py @@ -0,0 +1,122 @@ +from django_hosts.resolvers import reverse + +from pydis_site.apps.api.models import FilterList +from pydis_site.apps.api.tests.base import APISubdomainTestCase + +URL = reverse('bot:filterlist-list', host='api') +JPEG_ALLOWLIST = { + "type": 'FILE_FORMAT', + "allowed": True, + "content": ".jpeg", +} +PNG_ALLOWLIST = { + "type": 'FILE_FORMAT', + "allowed": True, + "content": ".png", +} + + +class UnauthenticatedTests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_cannot_read_allowedlist_list(self): + response = self.client.get(URL) + + self.assertEqual(response.status_code, 401) + + +class EmptyDatabaseTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): + FilterList.objects.all().delete() + + def test_returns_empty_object(self): + response = self.client.get(URL) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + +class FetchTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): + FilterList.objects.all().delete() + cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) + cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) + + def test_returns_name_in_list(self): + response = self.client.get(URL) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()[0]["content"], self.jpeg_format.content) + self.assertEqual(response.json()[1]["content"], self.png_format.content) + + def test_returns_single_item_by_id(self): + response = self.client.get(f'{URL}/{self.jpeg_format.id}') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json().get("content"), self.jpeg_format.content) + + def test_returns_filter_list_types(self): + response = self.client.get(f'{URL}/get-types') + + self.assertEqual(response.status_code, 200) + for api_type, model_type in zip(response.json(), FilterList.FilterListType.choices): + self.assertEquals(api_type[0], model_type[0]) + self.assertEquals(api_type[1], model_type[1]) + + +class CreationTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): + FilterList.objects.all().delete() + + def test_returns_400_for_missing_params(self): + no_type_json = { + "allowed": True, + "content": ".jpeg" + } + no_allowed_json = { + "type": "FILE_FORMAT", + "content": ".jpeg" + } + no_content_json = { + "allowed": True, + "type": "FILE_FORMAT" + } + cases = [{}, no_type_json, no_allowed_json, no_content_json] + + for case in cases: + with self.subTest(case=case): + response = self.client.post(URL, data=case) + self.assertEqual(response.status_code, 400) + + def test_returns_201_for_successful_creation(self): + response = self.client.post(URL, data=JPEG_ALLOWLIST) + self.assertEqual(response.status_code, 201) + + def test_returns_400_for_duplicate_creation(self): + self.client.post(URL, data=JPEG_ALLOWLIST) + response = self.client.post(URL, data=JPEG_ALLOWLIST) + self.assertEqual(response.status_code, 400) + + +class DeletionTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): + FilterList.objects.all().delete() + cls.jpeg_format = FilterList.objects.create(**JPEG_ALLOWLIST) + cls.png_format = FilterList.objects.create(**PNG_ALLOWLIST) + + def test_deleting_unknown_id_returns_404(self): + response = self.client.delete(f"{URL}/200") + self.assertEqual(response.status_code, 404) + + def test_deleting_known_id_returns_204(self): + response = self.client.delete(f"{URL}/{self.jpeg_format.id}") + self.assertEqual(response.status_code, 204) + + response = self.client.get(f"{URL}/{self.jpeg_format.id}") + self.assertNotIn(self.png_format.content, response.json()) diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index b4754484..e0e347bb 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -3,13 +3,12 @@ from datetime import datetime as dt from django.test import SimpleTestCase from django.utils import timezone -from ..models import ( +from pydis_site.apps.api.models import ( DeletedMessage, DocumentationLink, Infraction, Message, MessageDeletionContext, - ModelReprMixin, Nomination, OffTopicChannelName, OffensiveMessage, @@ -18,6 +17,7 @@ from ..models import ( Tag, User ) +from pydis_site.apps.api.models.mixins import ModelReprMixin class SimpleClass(ModelReprMixin): diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index 3bb5198e..a4fd5b2e 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -3,18 +3,28 @@ from rest_framework.routers import DefaultRouter from .views import HealthcheckView, RulesView from .viewsets import ( - BotSettingViewSet, DeletedMessageViewSet, - DocumentationLinkViewSet, InfractionViewSet, - LogEntryViewSet, NominationViewSet, + BotSettingViewSet, + DeletedMessageViewSet, + DocumentationLinkViewSet, + FilterListViewSet, + InfractionViewSet, + LogEntryViewSet, + NominationViewSet, OffTopicChannelNameViewSet, - OffensiveMessageViewSet, ReminderViewSet, - RoleViewSet, TagViewSet, UserViewSet + OffensiveMessageViewSet, + ReminderViewSet, + RoleViewSet, + TagViewSet, + UserViewSet ) - # http://www.django-rest-framework.org/api-guide/routers/#defaultrouter bot_router = DefaultRouter(trailing_slash=False) bot_router.register( + 'filter-lists', + FilterListViewSet +) +bot_router.register( 'bot-settings', BotSettingViewSet ) diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index 3cf9f641..8699517e 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa from .bot import ( + FilterListViewSet, BotSettingViewSet, DeletedMessageViewSet, DocumentationLinkViewSet, diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index b3e0fa4d..e64e3988 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -1,4 +1,5 @@ # flake8: noqa +from .filter_list import FilterListViewSet from .bot_setting import BotSettingViewSet from .deleted_message import DeletedMessageViewSet from .documentation_link import DocumentationLinkViewSet diff --git a/pydis_site/apps/api/viewsets/bot/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py new file mode 100644 index 00000000..2cb21ab9 --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/filter_list.py @@ -0,0 +1,97 @@ +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + +from pydis_site.apps.api.models.bot.filter_list import FilterList +from pydis_site.apps.api.serializers import FilterListSerializer + + +class FilterListViewSet(ModelViewSet): + """ + View providing CRUD operations on items allowed or denied by our bot. + + ## Routes + ### GET /bot/filter-lists + Returns all filterlist items in the database. + + #### Response format + >>> [ + ... { + ... 'id': "2309268224", + ... 'created_at': "01-01-2020 ...", + ... 'updated_at': "01-01-2020 ...", + ... 'type': "file_format", + ... 'allowed': 'true', + ... 'content': ".jpeg", + ... 'comment': "Popular image format.", + ... }, + ... ... + ... ] + + #### Status codes + - 200: returned on success + - 401: returned if unauthenticated + + ### GET /bot/filter-lists/<id:int> + Returns a specific FilterList item from the database. + + #### Response format + >>> { + ... 'id': "2309268224", + ... 'created_at': "01-01-2020 ...", + ... 'updated_at': "01-01-2020 ...", + ... 'type': "file_format", + ... 'allowed': 'true', + ... 'content': ".jpeg", + ... 'comment': "Popular image format.", + ... } + + #### Status codes + - 200: returned on success + - 404: returned if the id was not found. + + ### GET /bot/filter-lists/get-types + Returns a list of valid list types that can be used in POST requests. + + #### Response format + >>> [ + ... ["GUILD_INVITE","Guild Invite"], + ... ["FILE_FORMAT","File Format"], + ... ["DOMAIN_NAME","Domain Name"], + ... ["FILTER_TOKEN","Filter Token"] + ... ] + + #### Status codes + - 200: returned on success + + ### POST /bot/filter-lists + Adds a single FilterList item to the database. + + #### Request body + >>> { + ... 'type': str, + ... 'allowed': bool, + ... 'content': str, + ... 'comment': Optional[str], + ... } + + #### Status codes + - 201: returned on success + - 400: if one of the given fields is invalid + + ### DELETE /bot/filter-lists/<id:int> + Deletes the FilterList item with the given `id`. + + #### Status codes + - 204: returned on success + - 404: if a tag with the given `id` does not exist + """ + + serializer_class = FilterListSerializer + queryset = FilterList.objects.all() + + @action(detail=False, url_path='get-types', methods=["get"]) + def get_types(self, _: Request) -> Response: + """Get a list of all the types of FilterLists we support.""" + return Response(FilterList.FilterListType.choices) diff --git a/pydis_site/tests/__init__.py b/pydis_site/tests/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/tests/__init__.py diff --git a/pydis_site/tests/test_utils_account.py b/pydis_site/tests/test_utils_account.py index e8db7b64..6f8338b4 100644 --- a/pydis_site/tests/test_utils_account.py +++ b/pydis_site/tests/test_utils_account.py @@ -5,7 +5,7 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin from django.contrib.auth.models import User from django.contrib.messages.storage.base import BaseStorage from django.http import HttpRequest -from django.test import TestCase +from django.test import RequestFactory, TestCase from pydis_site.apps.api.models import Role, User as DiscordUser from pydis_site.utils.account import AccountAdapter, SocialAccountAdapter @@ -13,28 +13,43 @@ from pydis_site.utils.account import AccountAdapter, SocialAccountAdapter class AccountUtilsTests(TestCase): def setUp(self): + # Create the user self.django_user = User.objects.create(username="user") + # Create the roles + developers_role = Role.objects.create( + id=1, + name="Developers", + colour=0, + permissions=0, + position=1 + ) + everyone_role = Role.objects.create( + id=0, + name="@everyone", + colour=0, + permissions=0, + position=0 + ) + + # Create the social accounts self.discord_account = SocialAccount.objects.create( user=self.django_user, provider="discord", uid=0 ) - - self.discord_account_role = SocialAccount.objects.create( + self.discord_account_one_role = SocialAccount.objects.create( user=self.django_user, provider="discord", uid=1 ) - self.discord_account_two_roles = SocialAccount.objects.create( user=self.django_user, provider="discord", uid=2 ) - self.discord_account_not_present = SocialAccount.objects.create( user=self.django_user, provider="discord", uid=3 ) - self.github_account = SocialAccount.objects.create( user=self.django_user, provider="github", uid=0 ) + # Create DiscordUsers self.discord_user = DiscordUser.objects.create( id=0, name="user", @@ -44,35 +59,18 @@ class AccountUtilsTests(TestCase): self.discord_user_role = DiscordUser.objects.create( id=1, name="user present", - discriminator=0 + discriminator=0, + roles=[everyone_role.id] ) self.discord_user_two_roles = DiscordUser.objects.create( id=2, name="user with both roles", - discriminator=0 + discriminator=0, + roles=[everyone_role.id, developers_role.id] ) - everyone_role = Role.objects.create( - id=0, - name="@everyone", - colour=0, - permissions=0, - position=0 - ) - - self.discord_user_role.roles.append(everyone_role.id) - self.discord_user_two_roles.roles.append(everyone_role.id) - - developers_role = Role.objects.create( - id=1, - name="Developers", - colour=0, - permissions=0, - position=1 - ) - - self.discord_user_two_roles.roles.append(developers_role.id) + self.request_factory = RequestFactory() def test_account_adapter(self): """Test that our Allauth account adapter functions correctly.""" @@ -85,13 +83,13 @@ class AccountUtilsTests(TestCase): adapter = SocialAccountAdapter() discord_login = SocialLogin(account=self.discord_account) - discord_login_role = SocialLogin(account=self.discord_account_role) - discord_login_two_roles = SocialLogin(account=self.discord_account_two_roles) + discord_login_role = SocialLogin(account=self.discord_account_one_role) discord_login_not_present = SocialLogin(account=self.discord_account_not_present) + discord_login_two_roles = SocialLogin(account=self.discord_account_two_roles) github_login = SocialLogin(account=self.github_account) - messages_request = HttpRequest() + messages_request = self.request_factory.get("/") messages_request._messages = BaseStorage(messages_request) with patch("pydis_site.utils.account.reverse") as mock_reverse: @@ -108,12 +106,14 @@ class AccountUtilsTests(TestCase): with self.assertRaises(ImmediateHttpResponse): adapter.is_open_for_signup(messages_request, discord_login_not_present) + self.assertTrue( + adapter.is_open_for_signup(messages_request, discord_login_two_roles) + ) + self.assertEqual(len(messages_request._messages._queued_messages), 4) self.assertEqual(mock_redirect.call_count, 4) self.assertEqual(mock_reverse.call_count, 4) - self.assertTrue(adapter.is_open_for_signup(HttpRequest(), discord_login_two_roles)) - def test_social_account_adapter_populate(self): """Test that our Allauth social account adapter correctly handles data population.""" adapter = SocialAccountAdapter() @@ -122,13 +122,18 @@ class AccountUtilsTests(TestCase): account=self.discord_account, user=self.django_user ) - discord_login.account.extra_data["discriminator"] = "0000" - user = adapter.populate_user( - HttpRequest(), discord_login, + discord_user = adapter.populate_user( + self.request_factory.get("/"), discord_login, {"username": "user"} ) + self.assertEqual(discord_user.username, "user#0000") + self.assertEqual(discord_user.first_name, "user#0000") - self.assertEqual(user.username, "user#0000") - self.assertEqual(user.first_name, "user#0000") + discord_login.account.provider = "not_discord" + not_discord_user = adapter.populate_user( + self.request_factory.get("/"), discord_login, + {"username": "user"} + ) + self.assertEqual(not_discord_user.username, "user") |