aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site
diff options
context:
space:
mode:
Diffstat (limited to 'pydis_site')
-rw-r--r--pydis_site/__init__.py5
-rw-r--r--pydis_site/apps/api/admin.py4
-rw-r--r--pydis_site/apps/api/migrations/0007_tag.py2
-rw-r--r--pydis_site/apps/api/migrations/0008_tag_embed_validator.py7
-rw-r--r--pydis_site/apps/api/migrations/0009_snakefact.py2
-rw-r--r--pydis_site/apps/api/migrations/0010_snakeidiom.py2
-rw-r--r--pydis_site/apps/api/migrations/0012_specialsnake.py2
-rw-r--r--pydis_site/apps/api/migrations/0018_messagedeletioncontext.py2
-rw-r--r--pydis_site/apps/api/migrations/0019_deletedmessage.py4
-rw-r--r--pydis_site/apps/api/migrations/0020_infraction.py2
-rw-r--r--pydis_site/apps/api/migrations/0025_allow_custom_inserted_at_infraction_field.py4
-rw-r--r--pydis_site/apps/api/migrations/0030_reminder.py2
-rw-r--r--pydis_site/apps/api/migrations/0031_nomination.py2
-rw-r--r--pydis_site/apps/api/migrations/0032_botsetting.py2
-rw-r--r--pydis_site/apps/api/migrations/0035_create_table_log_entry.py2
-rw-r--r--pydis_site/apps/api/migrations/0046_reminder_jump_url.py19
-rw-r--r--pydis_site/apps/api/migrations/0047_active_infractions_migration.py105
-rw-r--r--pydis_site/apps/api/migrations/0048_add_infractions_unique_constraints_active.py17
-rw-r--r--pydis_site/apps/api/migrations/0049_deletedmessage_attachments.py20
-rw-r--r--pydis_site/apps/api/migrations/0049_offensivemessage.py25
-rw-r--r--pydis_site/apps/api/migrations/0050_remove_infractions_active_default_value.py18
-rw-r--r--pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py21
-rw-r--r--pydis_site/apps/api/migrations/0051_create_news_setting.py25
-rw-r--r--pydis_site/apps/api/migrations/0051_delete_tag.py16
-rw-r--r--pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py18
-rw-r--r--pydis_site/apps/api/migrations/0052_remove_user_avatar_hash.py17
-rw-r--r--pydis_site/apps/api/migrations/0053_user_roles_to_array.py24
-rw-r--r--pydis_site/apps/api/migrations/0054_user_invalidate_unknown_role.py21
-rw-r--r--pydis_site/apps/api/migrations/0055_merge_20200714_2027.py14
-rw-r--r--pydis_site/apps/api/migrations/0055_reminder_mentions.py20
-rw-r--r--pydis_site/apps/api/migrations/0056_allow_blank_user_roles.py21
-rw-r--r--pydis_site/apps/api/migrations/0057_merge_20200716_0751.py14
-rw-r--r--pydis_site/apps/api/migrations/0058_create_new_filterlist_model.py33
-rw-r--r--pydis_site/apps/api/migrations/0059_populate_filterlists.py153
-rw-r--r--pydis_site/apps/api/migrations/0060_populate_filterlists_fix.py85
-rw-r--r--pydis_site/apps/api/migrations/0061_merge_20200830_0526.py14
-rw-r--r--pydis_site/apps/api/migrations/0062_merge_20200901_1459.py14
-rw-r--r--pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py18
-rw-r--r--pydis_site/apps/api/models/__init__.py4
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py3
-rw-r--r--pydis_site/apps/api/models/bot/bot_setting.py3
-rw-r--r--pydis_site/apps/api/models/bot/documentation_link.py2
-rw-r--r--pydis_site/apps/api/models/bot/filter_list.py41
-rw-r--r--pydis_site/apps/api/models/bot/infraction.py10
-rw-r--r--pydis_site/apps/api/models/bot/message.py14
-rw-r--r--pydis_site/apps/api/models/bot/message_deletion_context.py2
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py6
-rw-r--r--pydis_site/apps/api/models/bot/off_topic_channel_name.py7
-rw-r--r--pydis_site/apps/api/models/bot/offensive_message.py48
-rw-r--r--pydis_site/apps/api/models/bot/reminder.py22
-rw-r--r--pydis_site/apps/api/models/bot/role.py2
-rw-r--r--pydis_site/apps/api/models/bot/tag.py198
-rw-r--r--pydis_site/apps/api/models/bot/user.py40
-rw-r--r--pydis_site/apps/api/models/log_entry.py2
-rw-r--r--pydis_site/apps/api/models/mixins.py31
-rw-r--r--pydis_site/apps/api/models/utils.py182
-rw-r--r--pydis_site/apps/api/serializers.py88
-rw-r--r--pydis_site/apps/api/tests/base.py2
-rw-r--r--pydis_site/apps/api/tests/migrations/__init__.py1
-rw-r--r--pydis_site/apps/api/tests/migrations/base.py102
-rw-r--r--pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py496
-rw-r--r--pydis_site/apps/api/tests/migrations/test_base.py135
-rw-r--r--pydis_site/apps/api/tests/test_deleted_messages.py15
-rw-r--r--pydis_site/apps/api/tests/test_documentation_links.py4
-rw-r--r--pydis_site/apps/api/tests/test_filterlists.py122
-rw-r--r--pydis_site/apps/api/tests/test_infractions.py321
-rw-r--r--pydis_site/apps/api/tests/test_models.py72
-rw-r--r--pydis_site/apps/api/tests/test_nominations.py13
-rw-r--r--pydis_site/apps/api/tests/test_off_topic_channel_names.py31
-rw-r--r--pydis_site/apps/api/tests/test_offensive_message.py155
-rw-r--r--pydis_site/apps/api/tests/test_reminders.py221
-rw-r--r--pydis_site/apps/api/tests/test_roles.py2
-rw-r--r--pydis_site/apps/api/tests/test_users.py14
-rw-r--r--pydis_site/apps/api/tests/test_validators.py68
-rw-r--r--pydis_site/apps/api/urls.py32
-rw-r--r--pydis_site/apps/api/views.py55
-rw-r--r--pydis_site/apps/api/viewsets/__init__.py3
-rw-r--r--pydis_site/apps/api/viewsets/bot/__init__.py3
-rw-r--r--pydis_site/apps/api/viewsets/bot/filter_list.py97
-rw-r--r--pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py27
-rw-r--r--pydis_site/apps/api/viewsets/bot/offensive_message.py61
-rw-r--r--pydis_site/apps/api/viewsets/bot/reminder.py62
-rw-r--r--pydis_site/apps/api/viewsets/bot/tag.py105
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py7
-rw-r--r--pydis_site/apps/home/forms/__init__.py0
-rw-r--r--pydis_site/apps/home/forms/account_deletion.py10
-rw-r--r--pydis_site/apps/home/signals.py88
-rw-r--r--pydis_site/apps/home/tests/mock_github_api_response.json2
-rw-r--r--pydis_site/apps/home/tests/test_repodata_helpers.py2
-rw-r--r--pydis_site/apps/home/tests/test_signal_listener.py83
-rw-r--r--pydis_site/apps/home/tests/test_views.py197
-rw-r--r--pydis_site/apps/home/urls.py11
-rw-r--r--pydis_site/apps/home/views/__init__.py3
-rw-r--r--pydis_site/apps/home/views/account/__init__.py4
-rw-r--r--pydis_site/apps/home/views/account/delete.py37
-rw-r--r--pydis_site/apps/home/views/account/settings.py59
-rw-r--r--pydis_site/apps/home/views/home.py4
-rw-r--r--pydis_site/apps/staff/migrations/0002_add_is_staff_to_role_mappings.py18
-rw-r--r--pydis_site/apps/staff/models/role_mapping.py5
-rw-r--r--pydis_site/apps/staff/templatetags/deletedmessage_filters.py15
-rw-r--r--pydis_site/apps/staff/tests/test_deletedmessage_filters.py43
-rw-r--r--pydis_site/apps/staff/tests/test_logs_view.py20
-rw-r--r--pydis_site/constants.py5
-rw-r--r--pydis_site/context_processors.py8
-rw-r--r--pydis_site/settings.py80
-rw-r--r--pydis_site/static/css/base/base.css25
-rw-r--r--pydis_site/static/css/home/index.css1
-rw-r--r--pydis_site/static/css/staff/logs.css1
-rw-r--r--pydis_site/static/favicons/safari-pinned-tab.svg2
-rw-r--r--pydis_site/static/images/events/summer_code_jam_2020.pngbin0 -> 271282 bytes
-rw-r--r--pydis_site/static/images/sponsors/adafruit.pngbin7605 -> 11705 bytes
-rw-r--r--pydis_site/static/images/sponsors/jetbrains.pngbin53742 -> 177467 bytes
-rw-r--r--pydis_site/static/images/sponsors/sentry.pngbin0 -> 13895 bytes
-rw-r--r--pydis_site/static/js/base/modal.js100
-rw-r--r--pydis_site/templates/base/base.html2
-rw-r--r--pydis_site/templates/base/navbar.html45
-rw-r--r--pydis_site/templates/home/account/delete.html47
-rw-r--r--pydis_site/templates/home/account/settings.html136
-rw-r--r--pydis_site/templates/home/index.html16
-rw-r--r--pydis_site/templates/staff/logs.html9
-rw-r--r--pydis_site/templates/wiki/base.html2
-rw-r--r--pydis_site/templates/wiki/history.html2
-rw-r--r--pydis_site/templates/wiki/includes/breadcrumbs.html4
-rw-r--r--pydis_site/tests/__init__.py0
-rw-r--r--pydis_site/tests/test_utils_account.py139
-rw-r--r--pydis_site/utils/account.py79
126 files changed, 4338 insertions, 673 deletions
diff --git a/pydis_site/__init__.py b/pydis_site/__init__.py
index c6146450..df67cf71 100644
--- a/pydis_site/__init__.py
+++ b/pydis_site/__init__.py
@@ -2,3 +2,8 @@ from wiki.plugins.macros.mdx import toc
# Remove the toc header prefix. There's no option for this, so we gotta monkey patch it.
toc.HEADER_ID_PREFIX = ''
+
+# Empty list of validators for Allauth to ponder over. This is referred to in settings.py
+# by a string because Allauth won't let us just give it a list _there_, we have to point
+# at a list _somewhere else_ instead.
+VALIDATORS = []
diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py
index 010541a6..271ff119 100644
--- a/pydis_site/apps/api/admin.py
+++ b/pydis_site/apps/api/admin.py
@@ -16,8 +16,8 @@ from .models import (
MessageDeletionContext,
Nomination,
OffTopicChannelName,
+ OffensiveMessage,
Role,
- Tag,
User
)
@@ -312,7 +312,7 @@ admin.site.register(Infraction, InfractionAdmin)
admin.site.register(LogEntry, LogEntryAdmin)
admin.site.register(MessageDeletionContext, MessageDeletionContextAdmin)
admin.site.register(Nomination, NominationAdmin)
+admin.site.register(OffensiveMessage)
admin.site.register(OffTopicChannelName, OffTopicChannelNameAdmin)
admin.site.register(Role, RoleAdmin)
-admin.site.register(Tag, TagAdmin)
admin.site.register(User, UserAdmin)
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/0008_tag_embed_validator.py b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py
index d53ddb90..d92042d2 100644
--- a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py
+++ b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py
@@ -1,7 +1,5 @@
# Generated by Django 2.1.1 on 2018-09-23 10:07
-import pydis_site.apps.api.models.bot.tag
-import django.contrib.postgres.fields.jsonb
from django.db import migrations
@@ -12,9 +10,4 @@ class Migration(migrations.Migration):
]
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.models.bot.tag.validate_tag_embed]),
- ),
]
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..6b848d64 100644
--- a/pydis_site/apps/api/migrations/0019_deletedmessage.py
+++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py
@@ -18,13 +18,13 @@ class Migration(migrations.Migration):
('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.models.bot.tag.validate_tag_embed]), help_text='Embeds attached to this message.', size=None)),
+ ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_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),
+ 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/0046_reminder_jump_url.py b/pydis_site/apps/api/migrations/0046_reminder_jump_url.py
new file mode 100644
index 00000000..b145f0dd
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0046_reminder_jump_url.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.2.6 on 2019-10-21 14:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0045_add_plural_name_for_log_entry'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='reminder',
+ name='jump_url',
+ field=models.URLField(default='', help_text='The jump url to the message that created the reminder', max_length=88),
+ preserve_default=False,
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0047_active_infractions_migration.py b/pydis_site/apps/api/migrations/0047_active_infractions_migration.py
new file mode 100644
index 00000000..9ac791dc
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0047_active_infractions_migration.py
@@ -0,0 +1,105 @@
+# Generated by Django 2.2.6 on 2019-10-07 15:59
+
+from django.db import migrations
+from django.db.models import Count, Prefetch, QuerySet
+
+
+class ExpirationWrapper:
+ """Wraps an expiration date to properly compare permanent and temporary infractions."""
+
+ def __init__(self, infraction):
+ self.expiration_date = infraction.expires_at
+
+ def __lt__(self, other):
+ """An `expiration_date` is considered smaller when it comes earlier than the `other`."""
+ if self.expiration_date is None:
+ # A permanent infraction can never end sooner than another infraction
+ return False
+ elif other.expiration_date is None:
+ # If `self` is temporary, but `other` is permanent, `self` is smaller
+ return True
+ else:
+ return self.expiration_date < other.expiration_date
+
+ def __eq__(self, other):
+ """If both expiration dates are permanent they're equal, otherwise compare dates."""
+ if self.expiration_date is None and other.expiration_date is None:
+ return True
+ elif self.expiration_date is None or other.expiration_date is None:
+ return False
+ else:
+ return self.expiration_date == other.expiration_date
+
+
+def migrate_inactive_types_to_inactive(apps, schema_editor):
+ """Migrates infractions of non-active types to inactive."""
+ infraction_model = apps.get_model('api', 'Infraction')
+ infraction_model.objects.filter(type__in=('note', 'warning', 'kick')).update(active=False)
+
+
+def get_query(user_model, infraction_model, infr_type: str) -> QuerySet:
+ """
+ Creates QuerySet to fetch users with multiple active infractions of the given `type`.
+
+ The QuerySet will prefetch the infractions and attach them as an `.infractions` attribute to the
+ `User` instances.
+ """
+ active_infractions = infraction_model.objects.filter(type=infr_type, active=True)
+
+ # Build an SQL query by chaining methods together
+
+ # Get users with active infraction(s) of the provided `infr_type`
+ query = user_model.objects.filter(
+ infractions_received__type=infr_type, infractions_received__active=True
+ )
+
+ # Prefetch their active received infractions of `infr_type` and attach `.infractions` attribute
+ query = query.prefetch_related(
+ Prefetch('infractions_received', queryset=active_infractions, to_attr='infractions')
+ )
+
+ # Count and only include them if they have at least 2 active infractions of the `type`
+ query = query.annotate(num_infractions=Count('infractions_received'))
+ query = query.filter(num_infractions__gte=2)
+
+ # Make sure we return each individual only once
+ query = query.distinct()
+
+ return query
+
+
+def migrate_multiple_active_infractions_per_user_to_one(apps, schema_editor):
+ """
+ Make sure a user only has one active infraction of a given "active" infraction type.
+
+ If a user has multiple active infraction, we keep the one with longest expiration date active
+ and migrate the others to inactive.
+ """
+ infraction_model = apps.get_model('api', 'Infraction')
+ user_model = apps.get_model('api', 'User')
+
+ for infraction_type in ('ban', 'mute', 'superstar', 'watch'):
+ query = get_query(user_model, infraction_model, infraction_type)
+ for user in query:
+ infractions = sorted(user.infractions, key=ExpirationWrapper, reverse=True)
+ for infraction in infractions[1:]:
+ infraction.active = False
+ infraction.save()
+
+
+def reverse_migration(apps, schema_editor):
+ """There's no need to do anything special to reverse these migrations."""
+ return
+
+
+class Migration(migrations.Migration):
+ """Data migration to get the database consistent with the new infraction validation rules."""
+
+ dependencies = [
+ ('api', '0046_reminder_jump_url'),
+ ]
+
+ operations = [
+ migrations.RunPython(migrate_inactive_types_to_inactive, reverse_migration),
+ migrations.RunPython(migrate_multiple_active_infractions_per_user_to_one, reverse_migration)
+ ]
diff --git a/pydis_site/apps/api/migrations/0048_add_infractions_unique_constraints_active.py b/pydis_site/apps/api/migrations/0048_add_infractions_unique_constraints_active.py
new file mode 100644
index 00000000..4ea1fb90
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0048_add_infractions_unique_constraints_active.py
@@ -0,0 +1,17 @@
+# Generated by Django 2.2.6 on 2019-10-07 18:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0047_active_infractions_migration'),
+ ]
+
+ operations = [
+ migrations.AddConstraint(
+ model_name='infraction',
+ constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('user', 'type'), name='unique_active_infraction_per_type_per_user'),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0049_deletedmessage_attachments.py b/pydis_site/apps/api/migrations/0049_deletedmessage_attachments.py
new file mode 100644
index 00000000..31ac239a
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0049_deletedmessage_attachments.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.2.6 on 2019-10-28 17:12
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0049_offensivemessage'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='deletedmessage',
+ name='attachments',
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.URLField(max_length=512), default=[], blank=True, help_text='Attachments attached to this message.', size=None),
+ preserve_default=False,
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0049_offensivemessage.py b/pydis_site/apps/api/migrations/0049_offensivemessage.py
new file mode 100644
index 00000000..f342cec3
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0049_offensivemessage.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.2.6 on 2019-11-07 18:08
+
+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.mixins
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0048_add_infractions_unique_constraints_active'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='OffensiveMessage',
+ 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 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.mixins.ModelReprMixin, models.Model),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0050_remove_infractions_active_default_value.py b/pydis_site/apps/api/migrations/0050_remove_infractions_active_default_value.py
new file mode 100644
index 00000000..90c91d63
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0050_remove_infractions_active_default_value.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.6 on 2020-02-08 19:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0049_deletedmessage_attachments'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='infraction',
+ name='active',
+ field=models.BooleanField(help_text='Whether the infraction is still active.'),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
new file mode 100644
index 00000000..124c6a57
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.0.4 on 2020-03-21 17:05
+
+import django.contrib.postgres.fields
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+import pydis_site.apps.api.models.utils
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0050_remove_infractions_active_default_value'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='deletedmessage',
+ name='embeds',
+ field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0051_create_news_setting.py b/pydis_site/apps/api/migrations/0051_create_news_setting.py
new file mode 100644
index 00000000..f18fdfb1
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0051_create_news_setting.py
@@ -0,0 +1,25 @@
+from django.db import migrations
+
+
+def up(apps, schema_editor):
+ BotSetting = apps.get_model('api', 'BotSetting')
+ setting = BotSetting(
+ name='news',
+ data={}
+ ).save()
+
+
+def down(apps, schema_editor):
+ BotSetting = apps.get_model('api', 'BotSetting')
+ BotSetting.objects.get(name='news').delete()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0050_remove_infractions_active_default_value'),
+ ]
+
+ operations = [
+ migrations.RunPython(up, down)
+ ]
diff --git a/pydis_site/apps/api/migrations/0051_delete_tag.py b/pydis_site/apps/api/migrations/0051_delete_tag.py
new file mode 100644
index 00000000..bada5788
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0051_delete_tag.py
@@ -0,0 +1,16 @@
+# Generated by Django 2.2.11 on 2020-04-01 06:15
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0050_remove_infractions_active_default_value'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='Tag',
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py b/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py
new file mode 100644
index 00000000..dfdf3835
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.11 on 2020-03-30 10:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0051_create_news_setting'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='offtopicchannelname',
+ name='used',
+ field=models.BooleanField(default=False, help_text='Whether or not this name has already been used during this rotation'),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0052_remove_user_avatar_hash.py b/pydis_site/apps/api/migrations/0052_remove_user_avatar_hash.py
new file mode 100644
index 00000000..26b3b954
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0052_remove_user_avatar_hash.py
@@ -0,0 +1,17 @@
+# Generated by Django 2.2.11 on 2020-05-27 07:17
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0051_create_news_setting'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='user',
+ name='avatar_hash',
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0053_user_roles_to_array.py b/pydis_site/apps/api/migrations/0053_user_roles_to_array.py
new file mode 100644
index 00000000..7ff3a548
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0053_user_roles_to_array.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.2.11 on 2020-06-02 13:42
+
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0052_remove_user_avatar_hash'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='user',
+ name='roles',
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='roles',
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')]), default=list, help_text='IDs of roles the user has on the server', size=None),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0054_user_invalidate_unknown_role.py b/pydis_site/apps/api/migrations/0054_user_invalidate_unknown_role.py
new file mode 100644
index 00000000..96230015
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0054_user_invalidate_unknown_role.py
@@ -0,0 +1,21 @@
+# Generated by Django 2.2.11 on 2020-06-02 20:08
+
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+import pydis_site.apps.api.models.bot.user
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0053_user_roles_to_array'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='user',
+ name='roles',
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.'), pydis_site.apps.api.models.bot.user._validate_existing_role]), default=list, help_text='IDs of roles the user has on the server', size=None),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0055_merge_20200714_2027.py b/pydis_site/apps/api/migrations/0055_merge_20200714_2027.py
new file mode 100644
index 00000000..f2a0e638
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0055_merge_20200714_2027.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.8 on 2020-07-14 20:27
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0051_allow_blank_message_embeds'),
+ ('api', '0054_user_invalidate_unknown_role'),
+ ]
+
+ operations = [
+ ]
diff --git a/pydis_site/apps/api/migrations/0055_reminder_mentions.py b/pydis_site/apps/api/migrations/0055_reminder_mentions.py
new file mode 100644
index 00000000..d73b450d
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0055_reminder_mentions.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.2.14 on 2020-07-15 07:37
+
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0054_user_invalidate_unknown_role'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='reminder',
+ name='mentions',
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(validators=[django.core.validators.MinValueValidator(limit_value=0, message='Mention IDs cannot be negative.')]), blank=True, default=list, help_text='IDs of roles or users to ping with the reminder.', size=None),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0056_allow_blank_user_roles.py b/pydis_site/apps/api/migrations/0056_allow_blank_user_roles.py
new file mode 100644
index 00000000..489941c7
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0056_allow_blank_user_roles.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.0.8 on 2020-07-14 20:35
+
+import django.contrib.postgres.fields
+import django.core.validators
+from django.db import migrations, models
+import pydis_site.apps.api.models.bot.user
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0055_merge_20200714_2027'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='user',
+ name='roles',
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.BigIntegerField(validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.'), pydis_site.apps.api.models.bot.user._validate_existing_role]), blank=True, default=list, help_text='IDs of roles the user has on the server', size=None),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0057_merge_20200716_0751.py b/pydis_site/apps/api/migrations/0057_merge_20200716_0751.py
new file mode 100644
index 00000000..47a6d2d4
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0057_merge_20200716_0751.py
@@ -0,0 +1,14 @@
+# Generated by Django 2.2.14 on 2020-07-16 07:51
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0055_reminder_mentions'),
+ ('api', '0056_allow_blank_user_roles'),
+ ]
+
+ operations = [
+ ]
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/migrations/0060_populate_filterlists_fix.py b/pydis_site/apps/api/migrations/0060_populate_filterlists_fix.py
new file mode 100644
index 00000000..53846f02
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0060_populate_filterlists_fix.py
@@ -0,0 +1,85 @@
+from django.db import migrations
+
+bad_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),
+]
+
+guild_invite_whitelist = [
+ ("267624335836053506", "Python Discord", True),
+ ("348658686962696195", "RLBot", True),
+ ("423249981340778496", "Kivy", True),
+ ("438622377094414346", "Pyglet", True),
+ ("524691714909274162", "Panda3D", True),
+ ("666560367173828639", "PyWeek", True),
+ ("702724176489873509", "Microsoft Python", True),
+ ("222078108977594368", "Discord.js Official", True),
+ ("238666723824238602", "Programming Discussions", True),
+ ("433980600391696384", "JetBrains Community", True),
+ ("204621105720328193", "Raspberry Pie", True),
+ ("286633898581164032", "Ren'Py", True),
+ ("440186186024222721", "Python Discord: Emojis 1", True),
+ ("578587418123304970", "Python Discord: Emojis 2", True),
+ ("159039020565790721", "Django", True),
+ ("273944235143593984", "STEM", True),
+ ("336642139381301249", "discord.py", True),
+ ("244230771232079873", "Programmers Hangout", True),
+ ("239433591950540801", "SpeakJS", True),
+ ("280033776820813825", "Functional Programming", True),
+ ("349505959032389632", "PyGame", True),
+ ("488751051629920277", "Python Atlanta", True),
+ ("143867839282020352", "C#", True),
+]
+
+
+class Migration(migrations.Migration):
+ dependencies = [("api", "0059_populate_filterlists")]
+
+ def fix_filterlist(app, _):
+ FilterList = app.get_model("api", "FilterList")
+ FilterList.objects.filter(type="GUILD_INVITE").delete() # Clear out the stuff added in 0059.
+
+ for content, comment, allowed in guild_invite_whitelist:
+ FilterList.objects.create(
+ type="GUILD_INVITE",
+ allowed=allowed,
+ content=content,
+ comment=comment,
+ )
+
+ def restore_bad_filterlist(app, _):
+ FilterList = app.get_model("api", "FilterList")
+ FilterList.objects.filter(type="GUILD_INVITE").delete()
+
+ for content, comment, allowed in bad_guild_invite_whitelist:
+ FilterList.objects.create(
+ type="GUILD_INVITE",
+ allowed=allowed,
+ content=content,
+ comment=comment,
+ )
+
+ operations = [
+ migrations.RunPython(fix_filterlist, restore_bad_filterlist)
+ ]
diff --git a/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py b/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py
new file mode 100644
index 00000000..f0668696
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.8 on 2020-08-30 05:26
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0060_populate_filterlists_fix'),
+ ('api', '0052_offtopicchannelname_used'),
+ ]
+
+ operations = [
+ ]
diff --git a/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py b/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py
new file mode 100644
index 00000000..d162acf1
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.0.8 on 2020-09-01 14:59
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0051_delete_tag'),
+ ('api', '0061_merge_20200830_0526'),
+ ]
+
+ operations = [
+ ]
diff --git a/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py b/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py
new file mode 100644
index 00000000..9eb05eaa
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.9 on 2020-09-11 21:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0062_merge_20200901_1459'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='nomination',
+ name='reason',
+ field=models.TextField(blank=True, help_text='Why this user was nominated.', null=True),
+ ),
+ ]
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index a4656bc3..e3f928e1 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,
@@ -7,11 +8,10 @@ from .bot import (
Message,
MessageDeletionContext,
Nomination,
+ OffensiveMessage,
OffTopicChannelName,
Reminder,
Role,
- Tag,
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 46219ea2..1673b434 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
@@ -7,7 +8,7 @@ from .message import Message
from .message_deletion_context import MessageDeletionContext
from .nomination import Nomination
from .off_topic_channel_name import OffTopicChannelName
+from .offensive_message import OffensiveMessage
from .reminder import Reminder
from .role import Role
-from .tag import Tag
from .user import User
diff --git a/pydis_site/apps/api/models/bot/bot_setting.py b/pydis_site/apps/api/models/bot/bot_setting.py
index b1c3e47c..2a3944f8 100644
--- a/pydis_site/apps/api/models/bot/bot_setting.py
+++ b/pydis_site/apps/api/models/bot/bot_setting.py
@@ -2,13 +2,14 @@ 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:
"""Raises a ValidationError if the given name is not a known setting."""
known_settings = (
'defcon',
+ 'news',
)
if name not in known_settings:
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 dfb32a97..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):
@@ -29,7 +29,6 @@ class Infraction(ModelReprMixin, models.Model):
)
)
active = models.BooleanField(
- default=True,
help_text="Whether the infraction is still active."
)
user = models.ForeignKey(
@@ -71,3 +70,10 @@ class Infraction(ModelReprMixin, models.Model):
"""Defines the meta options for the infraction model."""
ordering = ['-inserted_at']
+ constraints = (
+ models.UniqueConstraint(
+ fields=["user", "type"],
+ condition=models.Q(active=True),
+ name="unique_active_infraction_per_type_per_user"
+ ),
+ )
diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py
index 31316a01..f6ae55a5 100644
--- a/pydis_site/apps/api/models/bot/message.py
+++ b/pydis_site/apps/api/models/bot/message.py
@@ -5,9 +5,9 @@ from django.core.validators import MinValueValidator
from django.db import models
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
+from pydis_site.apps.api.models.utils import validate_embed
class Message(ModelReprMixin, models.Model):
@@ -47,10 +47,18 @@ class Message(ModelReprMixin, models.Model):
)
embeds = pgfields.ArrayField(
pgfields.JSONField(
- validators=(validate_tag_embed,)
+ validators=(validate_embed,)
),
+ blank=True,
help_text="Embeds attached to this message."
)
+ attachments = pgfields.ArrayField(
+ models.URLField(
+ max_length=512
+ ),
+ blank=True,
+ help_text="Attachments attached to this message."
+ )
@property
def timestamp(self) -> datetime:
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 fde9b0a6..1410250a 100644
--- a/pydis_site/apps/api/models/bot/message_deletion_context.py
+++ b/pydis_site/apps/api/models/bot/message_deletion_context.py
@@ -2,7 +2,7 @@ from django.db import models
from django_hosts.resolvers import reverse
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 a0ba42a3..54f56c98 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):
@@ -18,7 +18,9 @@ class Nomination(ModelReprMixin, models.Model):
related_name='nomination_set'
)
reason = models.TextField(
- help_text="Why this user was nominated."
+ help_text="Why this user was nominated.",
+ null=True,
+ blank=True
)
user = models.ForeignKey(
User,
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..403c7465 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):
@@ -16,6 +16,11 @@ class OffTopicChannelName(ModelReprMixin, models.Model):
help_text="The actual channel name that will be used on our Discord server."
)
+ used = models.BooleanField(
+ default=False,
+ help_text="Whether or not this name has already been used during this rotation",
+ )
+
def __str__(self):
"""Returns the current off-topic name, for display purposes."""
return self.name
diff --git a/pydis_site/apps/api/models/bot/offensive_message.py b/pydis_site/apps/api/models/bot/offensive_message.py
new file mode 100644
index 00000000..6c0e5ffb
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/offensive_message.py
@@ -0,0 +1,48 @@
+import datetime
+
+from django.core.exceptions import ValidationError
+from django.core.validators import MinValueValidator
+from django.db import models
+
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+def future_date_validator(date: datetime.date) -> None:
+ """Raise ValidationError if the date isn't a future date."""
+ if date < datetime.datetime.now(datetime.timezone.utc):
+ raise ValidationError("Date must be a future date")
+
+
+class OffensiveMessage(ModelReprMixin, models.Model):
+ """A message that triggered a filter and that will be deleted one week after it was sent."""
+
+ 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."
+ ),
+ )
+ )
+ channel_id = models.BigIntegerField(
+ help_text=(
+ "The channel ID that the message was "
+ "sent in, taken from Discord."
+ ),
+ 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=(future_date_validator,)
+ )
+
+ def __str__(self):
+ """Return some info on this message, for display purposes only."""
+ return f"Message {self.id}, will be deleted at {self.delete_date}"
diff --git a/pydis_site/apps/api/models/bot/reminder.py b/pydis_site/apps/api/models/bot/reminder.py
index decc9391..7d968a0e 100644
--- a/pydis_site/apps/api/models/bot/reminder.py
+++ b/pydis_site/apps/api/models/bot/reminder.py
@@ -1,8 +1,9 @@
+from django.contrib.postgres.fields import ArrayField
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):
@@ -15,6 +16,12 @@ class Reminder(ModelReprMixin, models.Model):
"If not, it has been sent out to the user."
)
)
+ jump_url = models.URLField(
+ max_length=88,
+ help_text=(
+ "The jump url to the message that created the reminder"
+ )
+ )
author = models.ForeignKey(
User,
on_delete=models.CASCADE,
@@ -39,6 +46,19 @@ class Reminder(ModelReprMixin, models.Model):
expiration = models.DateTimeField(
help_text="When this reminder should be sent."
)
+ mentions = ArrayField(
+ models.BigIntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Mention IDs cannot be negative."
+ ),
+ )
+ ),
+ default=list,
+ blank=True,
+ help_text="IDs of roles or users to ping with the reminder."
+ )
def __str__(self):
"""Returns some info on the current reminder, for display purposes."""
diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py
index b95740da..b23fc5f4 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
deleted file mode 100644
index 5d4cc393..00000000
--- a/pydis_site/apps/api/models/bot/tag.py
+++ /dev/null
@@ -1,198 +0,0 @@
-from collections.abc import Mapping
-from typing import Any, Dict
-
-from django.contrib.postgres import fields as pgfields
-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
-
-
-def is_bool_validator(value: Any) -> None:
- """Validates if a given value is of type bool."""
- if not isinstance(value, bool):
- raise ValidationError(f"This field must be of type bool, not {type(value)}.")
-
-
-def validate_tag_embed_fields(fields: dict) -> None:
- """Raises a ValidationError if any of the given embed fields is invalid."""
- field_validators = {
- 'name': (MaxLengthValidator(limit_value=256),),
- 'value': (MaxLengthValidator(limit_value=1024),),
- 'inline': (is_bool_validator,),
- }
-
- required_fields = ('name', 'value')
-
- for field in fields:
- if not isinstance(field, Mapping):
- raise ValidationError("Embed fields must be a mapping.")
-
- if not all(required_field in field for required_field in required_fields):
- raise ValidationError(
- f"Embed fields must contain the following fields: {', '.join(required_fields)}."
- )
-
- 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: Dict[str, str]) -> None:
- """Raises a ValidationError if the given footer is invalid."""
- 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: Any) -> None:
- """Raises a ValidationError if the given author is invalid."""
- 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: Any) -> None:
- """
- 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.models.bot.tag import validate_tag_embed
- >>> class MyMessage(models.Model):
- ... embed = pgfields.JSONField(
- ... validators=(
- ... validate_tag_embed,
- ... )
- ... )
- ... # ...
- ...
-
- Args:
- embed (Any):
- 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)
-
-
-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):
- """Returns the title of this tag, for display purposes."""
- return self.title
diff --git a/pydis_site/apps/api/models/bot/user.py b/pydis_site/apps/api/models/bot/user.py
index 21617dc4..cd2d58b9 100644
--- a/pydis_site/apps/api/models/bot/user.py
+++ b/pydis_site/apps/api/models/bot/user.py
@@ -1,8 +1,18 @@
+from django.contrib.postgres.fields import ArrayField
+from django.core.exceptions import ValidationError
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:
+ """Validate that a role exists when given in to the user model."""
+ role = Role.objects.filter(id=value)
+
+ if not role:
+ raise ValidationError(f"Role with ID {value} does not exist")
class User(ModelReprMixin, models.Model):
@@ -31,17 +41,19 @@ class User(ModelReprMixin, models.Model):
),
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."
+ roles = ArrayField(
+ models.BigIntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Role IDs cannot be negative."
+ ),
+ _validate_existing_role
+ )
),
- null=True
- )
- roles = models.ManyToManyField(
- Role,
- help_text="Any roles this user has on our server."
+ default=list,
+ blank=True,
+ help_text="IDs of roles the user has on the server"
)
in_guild = models.BooleanField(
default=True,
@@ -50,7 +62,7 @@ class User(ModelReprMixin, models.Model):
def __str__(self):
"""Returns the name and discriminator for the current user, for display purposes."""
- return f"{self.name}#{self.discriminator}"
+ return f"{self.name}#{self.discriminator:0>4}"
@property
def top_role(self) -> Role:
@@ -59,7 +71,7 @@ class User(ModelReprMixin, models.Model):
This will fall back to the Developers role if the user does not have any roles.
"""
- roles = self.roles.all()
+ roles = Role.objects.filter(id__in=self.roles)
if not roles:
return Role.objects.get(name="Developers")
- return max(self.roles.all())
+ return max(roles)
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/mixins.py b/pydis_site/apps/api/models/mixins.py
new file mode 100644
index 00000000..5d75b78b
--- /dev/null
+++ b/pydis_site/apps/api/models/mixins.py
@@ -0,0 +1,31 @@
+from operator import itemgetter
+
+from django.db import models
+
+
+class ModelReprMixin:
+ """Mixin providing a `__repr__()` to display model class name and initialisation parameters."""
+
+ def __repr__(self):
+ """Returns the current model class name and initialisation parameters."""
+ 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 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/models/utils.py b/pydis_site/apps/api/models/utils.py
index 0540c4de..107231ba 100644
--- a/pydis_site/apps/api/models/utils.py
+++ b/pydis_site/apps/api/models/utils.py
@@ -1,17 +1,173 @@
-from operator import itemgetter
+from collections.abc import Mapping
+from typing import Any, Dict
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxLengthValidator, MinLengthValidator
-class ModelReprMixin:
- """Mixin providing a `__repr__()` to display model class name and initialisation parameters."""
- def __repr__(self):
- """Returns the current model class name and initialisation parameters."""
- attributes = ' '.join(
- f'{attribute}={value!r}'
- for attribute, value in sorted(
- self.__dict__.items(),
- key=itemgetter(0)
+def is_bool_validator(value: Any) -> None:
+ """Validates if a given value is of type bool."""
+ if not isinstance(value, bool):
+ raise ValidationError(f"This field must be of type bool, not {type(value)}.")
+
+
+def validate_embed_fields(fields: dict) -> None:
+ """Raises a ValidationError if any of the given embed fields is invalid."""
+ field_validators = {
+ 'name': (MaxLengthValidator(limit_value=256),),
+ 'value': (MaxLengthValidator(limit_value=1024),),
+ 'inline': (is_bool_validator,),
+ }
+
+ required_fields = ('name', 'value')
+
+ for field in fields:
+ if not isinstance(field, Mapping):
+ raise ValidationError("Embed fields must be a mapping.")
+
+ if not all(required_field in field for required_field in required_fields):
+ raise ValidationError(
+ f"Embed fields must contain the following fields: {', '.join(required_fields)}."
)
- if not attribute.startswith('_')
- )
- return f'<{self.__class__.__name__}({attributes})>'
+
+ 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_embed_footer(footer: Dict[str, str]) -> None:
+ """Raises a ValidationError if the given footer is invalid."""
+ 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_embed_author(author: Any) -> None:
+ """Raises a ValidationError if the given author is invalid."""
+ 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_embed(embed: Any) -> None:
+ """
+ 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.models.utils import validate_embed
+ >>> class MyMessage(models.Model):
+ ... embed = pgfields.JSONField(
+ ... validators=(
+ ... validate_embed,
+ ... )
+ ... )
+ ... # ...
+ ...
+
+ Args:
+ embed (Any):
+ 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_embed_fields
+ ),
+ 'footer': (validate_embed_footer,),
+ 'author': (validate_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)
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 326e20e1..90bd6f91 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -1,15 +1,22 @@
"""Converters from Django models to data interchange formats and back."""
-
from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError
+from rest_framework.validators import UniqueTogetherValidator
from rest_framework_bulk import BulkSerializerMixin
from .models import (
- BotSetting, DeletedMessage,
- DocumentationLink, Infraction,
- LogEntry, MessageDeletionContext,
- Nomination, OffTopicChannelName,
- Reminder, Role,
- Tag, User
+ BotSetting,
+ DeletedMessage,
+ DocumentationLink,
+ FilterList,
+ Infraction,
+ LogEntry,
+ MessageDeletionContext,
+ Nomination,
+ OffTopicChannelName,
+ OffensiveMessage,
+ Reminder,
+ Role,
+ User
)
@@ -49,7 +56,8 @@ class DeletedMessageSerializer(ModelSerializer):
fields = (
'id', 'author',
'channel_id', 'content',
- 'embeds', 'deletion_context'
+ 'embeds', 'deletion_context',
+ 'attachments'
)
@@ -95,6 +103,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."""
@@ -105,11 +138,22 @@ class InfractionSerializer(ModelSerializer):
fields = (
'id', 'inserted_at', 'expires_at', 'active', 'user', 'actor', 'type', 'reason', 'hidden'
)
+ validators = [
+ UniqueTogetherValidator(
+ queryset=Infraction.objects.filter(active=True),
+ fields=['user', 'type', 'active'],
+ message='This user already has an active infraction of this type.',
+ )
+ ]
def validate(self, attrs: dict) -> dict:
"""Validate data constraints for the given data and abort if it is invalid."""
infr_type = attrs.get('type')
+ active = attrs.get('active')
+ if active and infr_type in ('note', 'warning', 'kick'):
+ raise ValidationError({'active': [f'{infr_type} infractions cannot be active.']})
+
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.']})
@@ -190,7 +234,9 @@ class ReminderSerializer(ModelSerializer):
"""Metadata defined for the Django REST Framework."""
model = Reminder
- fields = ('active', 'author', 'channel_id', 'content', 'expiration', 'id')
+ fields = (
+ 'active', 'author', 'jump_url', 'channel_id', 'content', 'expiration', 'id', 'mentions'
+ )
class RoleSerializer(ModelSerializer):
@@ -203,26 +249,14 @@ class RoleSerializer(ModelSerializer):
fields = ('id', 'name', 'colour', 'permissions', 'position')
-class TagSerializer(ModelSerializer):
- """A class providing (de-)serialization of `Tag` instances."""
-
- class Meta:
- """Metadata defined for the Django REST Framework."""
-
- model = Tag
- fields = ('title', 'embed')
-
-
class UserSerializer(BulkSerializerMixin, ModelSerializer):
"""A class providing (de-)serialization of `User` instances."""
- roles = PrimaryKeyRelatedField(many=True, queryset=Role.objects.all(), required=False)
-
class Meta:
"""Metadata defined for the Django REST Framework."""
model = User
- fields = ('id', 'avatar_hash', 'name', 'discriminator', 'roles', 'in_guild')
+ fields = ('id', 'name', 'discriminator', 'roles', 'in_guild')
depth = 1
@@ -236,3 +270,13 @@ class NominationSerializer(ModelSerializer):
fields = (
'id', 'active', 'actor', 'reason', 'user',
'inserted_at', 'end_reason', 'ended_at')
+
+
+class OffensiveMessageSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `OffensiveMessage` instances."""
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = OffensiveMessage
+ fields = ('id', 'channel_id', 'delete_date')
diff --git a/pydis_site/apps/api/tests/base.py b/pydis_site/apps/api/tests/base.py
index b779256e..61c23b0f 100644
--- a/pydis_site/apps/api/tests/base.py
+++ b/pydis_site/apps/api/tests/base.py
@@ -5,7 +5,7 @@ from rest_framework.test import APITestCase
test_user, _created = User.objects.get_or_create(
username='test',
- password='testpass', # noqa
+ password='testpass',
is_superuser=True,
is_staff=True
)
diff --git a/pydis_site/apps/api/tests/migrations/__init__.py b/pydis_site/apps/api/tests/migrations/__init__.py
new file mode 100644
index 00000000..38e42ffc
--- /dev/null
+++ b/pydis_site/apps/api/tests/migrations/__init__.py
@@ -0,0 +1 @@
+"""This submodule contains tests for functions used in data migrations."""
diff --git a/pydis_site/apps/api/tests/migrations/base.py b/pydis_site/apps/api/tests/migrations/base.py
new file mode 100644
index 00000000..0c0a5bd0
--- /dev/null
+++ b/pydis_site/apps/api/tests/migrations/base.py
@@ -0,0 +1,102 @@
+"""Includes utilities for testing migrations."""
+from django.db import connection
+from django.db.migrations.executor import MigrationExecutor
+from django.test import TestCase
+
+
+class MigrationsTestCase(TestCase):
+ """
+ A `TestCase` subclass to test migration files.
+
+ To be able to properly test a migration, we will need to inject data into the test database
+ before the migrations we want to test are applied, but after the older migrations have been
+ applied. This makes sure that we are testing "as if" we were actually applying this migration
+ to a database in the state it was in before introducing the new migration.
+
+ To set up a MigrationsTestCase, create a subclass of this class and set the following
+ class-level attributes:
+
+ - app: The name of the app that contains the migrations (e.g., `'api'`)
+ - migration_prior: The name* of the last migration file before the migrations you want to test
+ - migration_target: The name* of the last migration file we want to test
+
+ *) Specify the file names without a path or the `.py` file extension.
+
+ Additionally, overwrite the `setUpMigrationData` in the subclass to inject data into the
+ database before the migrations we want to test are applied. Please read the docstring of the
+ method for more information. An optional hook, `setUpPostMigrationData` is also provided.
+ """
+
+ # These class-level attributes should be set in classes that inherit from this base class.
+ app = None
+ migration_prior = None
+ migration_target = None
+
+ @classmethod
+ def setUpTestData(cls):
+ """
+ Injects data into the test database prior to the migration we're trying to test.
+
+ This class methods reverts the test database back to the state of the last migration file
+ prior to the migrations we want to test. It will then allow the user to inject data into the
+ test database by calling the `setUpMigrationData` hook. After the data has been injected, it
+ will apply the migrations we want to test and call the `setUpPostMigrationData` hook. The
+ user can now test if the migration correctly migrated the injected test data.
+ """
+ if not cls.app:
+ raise ValueError("The `app` attribute was not set.")
+
+ if not cls.migration_prior or not cls.migration_target:
+ raise ValueError("Both ` migration_prior` and `migration_target` need to be set.")
+
+ cls.migrate_from = [(cls.app, cls.migration_prior)]
+ cls.migrate_to = [(cls.app, cls.migration_target)]
+
+ # Reverse to database state prior to the migrations we want to test
+ executor = MigrationExecutor(connection)
+ executor.migrate(cls.migrate_from)
+
+ # Call the data injection hook with the current state of the project
+ old_apps = executor.loader.project_state(cls.migrate_from).apps
+ cls.setUpMigrationData(old_apps)
+
+ # Run the migrations we want to test
+ executor = MigrationExecutor(connection)
+ executor.loader.build_graph()
+ executor.migrate(cls.migrate_to)
+
+ # Save the project state so we're able to work with the correct model states
+ cls.apps = executor.loader.project_state(cls.migrate_to).apps
+
+ # Call `setUpPostMigrationData` to potentially set up post migration data used in testing
+ cls.setUpPostMigrationData(cls.apps)
+
+ @classmethod
+ def setUpMigrationData(cls, apps):
+ """
+ Override this method to inject data into the test database before the migration is applied.
+
+ This method will be called after setting up the database according to the migrations that
+ come before the migration(s) we are trying to test, but before the to-be-tested migration(s)
+ are applied. This allows us to simulate a database state just prior to the migrations we are
+ trying to test.
+
+ To make sure we're creating objects according to the state the models were in at this point
+ in the migration history, use `apps.get_model(app_name: str, model_name: str)` to get the
+ appropriate model, e.g.:
+
+ >>> Infraction = apps.get_model('api', 'Infraction')
+ """
+ pass
+
+ @classmethod
+ def setUpPostMigrationData(cls, apps):
+ """
+ Set up additional test data after the target migration has been applied.
+
+ Use `apps.get_model(app_name: str, model_name: str)` to get the correct instances of the
+ model classes:
+
+ >>> Infraction = apps.get_model('api', 'Infraction')
+ """
+ pass
diff --git a/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py b/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py
new file mode 100644
index 00000000..8dc29b34
--- /dev/null
+++ b/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py
@@ -0,0 +1,496 @@
+"""Tests for the data migration in `filename`."""
+import logging
+from collections import ChainMap, namedtuple
+from datetime import timedelta
+from itertools import count
+from typing import Dict, Iterable, Type, Union
+
+from django.db.models import Q
+from django.forms.models import model_to_dict
+from django.utils import timezone
+
+from pydis_site.apps.api.models import Infraction, User
+from .base import MigrationsTestCase
+
+log = logging.getLogger(__name__)
+log.setLevel(logging.DEBUG)
+
+
+InfractionHistory = namedtuple('InfractionHistory', ("user_id", "infraction_history"))
+
+
+class InfractionFactory:
+ """Factory that creates infractions for a User instance."""
+
+ infraction_id = count(1)
+ user_id = count(1)
+ default_values = {
+ 'active': True,
+ 'expires_at': None,
+ 'hidden': False,
+ }
+
+ @classmethod
+ def create(
+ cls,
+ actor: User,
+ infractions: Iterable[Dict[str, Union[str, int, bool]]],
+ infraction_model: Type[Infraction] = Infraction,
+ user_model: Type[User] = User,
+ ) -> InfractionHistory:
+ """
+ Creates `infractions` for the `user` with the given `actor`.
+
+ The `infractions` dictionary can contain the following fields:
+ - `type` (required)
+ - `active` (default: True)
+ - `expires_at` (default: None; i.e, permanent)
+ - `hidden` (default: False).
+
+ The parameters `infraction_model` and `user_model` can be used to pass in an instance of
+ both model classes from a different migration/project state.
+ """
+ user_id = next(cls.user_id)
+ user = user_model.objects.create(
+ id=user_id,
+ name=f"Infracted user {user_id}",
+ discriminator=user_id,
+ avatar_hash=None,
+ )
+ infraction_history = []
+
+ for infraction in infractions:
+ infraction = dict(infraction)
+ infraction["id"] = next(cls.infraction_id)
+ infraction = ChainMap(infraction, cls.default_values)
+ new_infraction = infraction_model.objects.create(
+ user=user,
+ actor=actor,
+ type=infraction["type"],
+ reason=f"`{infraction['type']}` infraction (ID: {infraction['id']} of {user}",
+ active=infraction['active'],
+ hidden=infraction['hidden'],
+ expires_at=infraction['expires_at'],
+ )
+ infraction_history.append(new_infraction)
+
+ return InfractionHistory(user_id=user_id, infraction_history=infraction_history)
+
+
+class InfractionFactoryTests(MigrationsTestCase):
+ """Tests for the InfractionFactory."""
+
+ app = "api"
+ migration_prior = "0046_reminder_jump_url"
+ migration_target = "0046_reminder_jump_url"
+
+ @classmethod
+ def setUpPostMigrationData(cls, apps):
+ """Create a default actor for all infractions."""
+ cls.infraction_model = apps.get_model('api', 'Infraction')
+ cls.user_model = apps.get_model('api', 'User')
+
+ cls.actor = cls.user_model.objects.create(
+ id=9999,
+ name="Unknown Moderator",
+ discriminator=1040,
+ avatar_hash=None,
+ )
+
+ def test_infraction_factory_total_count(self):
+ """Does the test database hold as many infractions as we tried to create?"""
+ InfractionFactory.create(
+ actor=self.actor,
+ infractions=(
+ {'type': 'kick', 'active': False, 'hidden': False},
+ {'type': 'ban', 'active': True, 'hidden': False},
+ {'type': 'note', 'active': False, 'hidden': True},
+ ),
+ infraction_model=self.infraction_model,
+ user_model=self.user_model,
+ )
+ database_count = Infraction.objects.all().count()
+ self.assertEqual(3, database_count)
+
+ def test_infraction_factory_multiple_users(self):
+ """Does the test database hold as many infractions as we tried to create?"""
+ for _user in range(5):
+ InfractionFactory.create(
+ actor=self.actor,
+ infractions=(
+ {'type': 'kick', 'active': False, 'hidden': True},
+ {'type': 'ban', 'active': True, 'hidden': False},
+ ),
+ infraction_model=self.infraction_model,
+ user_model=self.user_model,
+ )
+
+ # Check if infractions and users are recorded properly in the database
+ database_count = Infraction.objects.all().count()
+ self.assertEqual(database_count, 10)
+
+ user_count = User.objects.all().count()
+ self.assertEqual(user_count, 5 + 1)
+
+ def test_infraction_factory_sets_correct_fields(self):
+ """Does the InfractionFactory set the correct attributes?"""
+ infractions = (
+ {
+ 'type': 'note',
+ 'active': False,
+ 'hidden': True,
+ 'expires_at': timezone.now()
+ },
+ {'type': 'warning', 'active': False, 'hidden': False, 'expires_at': None},
+ {'type': 'watch', 'active': False, 'hidden': True, 'expires_at': None},
+ {'type': 'mute', 'active': True, 'hidden': False, 'expires_at': None},
+ {'type': 'kick', 'active': True, 'hidden': True, 'expires_at': None},
+ {'type': 'ban', 'active': True, 'hidden': False, 'expires_at': None},
+ {
+ 'type': 'superstar',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': timezone.now()
+ },
+ )
+
+ InfractionFactory.create(
+ actor=self.actor,
+ infractions=infractions,
+ infraction_model=self.infraction_model,
+ user_model=self.user_model,
+ )
+
+ for infraction in infractions:
+ with self.subTest(**infraction):
+ self.assertTrue(Infraction.objects.filter(**infraction).exists())
+
+
+class ActiveInfractionMigrationTests(MigrationsTestCase):
+ """
+ Tests the active infraction data migration.
+
+ The active infraction data migration should do the following things:
+
+ 1. migrates all active notes, warnings, and kicks to an inactive status;
+ 2. migrates all users with multiple active infractions of a single type to have only one active
+ infraction of that type. The infraction with the longest duration stays active.
+ """
+
+ app = "api"
+ migration_prior = "0046_reminder_jump_url"
+ migration_target = "0047_active_infractions_migration"
+
+ @classmethod
+ def setUpMigrationData(cls, apps):
+ """Sets up an initial database state that contains the relevant test cases."""
+ # Fetch the Infraction and User model in the current migration state
+ cls.infraction_model = apps.get_model('api', 'Infraction')
+ cls.user_model = apps.get_model('api', 'User')
+
+ cls.created_infractions = {}
+
+ # Moderator that serves as actor for all infractions
+ cls.user_moderator = cls.user_model.objects.create(
+ id=9999,
+ name="Olivier de Vienne",
+ discriminator=1040,
+ avatar_hash=None,
+ )
+
+ # User #1: clean user with no infractions
+ cls.created_infractions["no infractions"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=[],
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #2: One inactive note infraction
+ cls.created_infractions["one inactive note"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {'type': 'note', 'active': False, 'hidden': True},
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #3: One active note infraction
+ cls.created_infractions["one active note"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {'type': 'note', 'active': True, 'hidden': True},
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #4: One active and one inactive note infraction
+ cls.created_infractions["one active and one inactive note"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {'type': 'note', 'active': False, 'hidden': True},
+ {'type': 'note', 'active': True, 'hidden': True},
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #5: Once active note, one active kick, once active warning
+ cls.created_infractions["active note, kick, warning"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {'type': 'note', 'active': True, 'hidden': True},
+ {'type': 'kick', 'active': True, 'hidden': True},
+ {'type': 'warning', 'active': True, 'hidden': True},
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #6: One inactive ban and one active ban
+ cls.created_infractions["one inactive and one active ban"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {'type': 'ban', 'active': False, 'hidden': True},
+ {'type': 'ban', 'active': True, 'hidden': True},
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #7: Two active permanent bans
+ cls.created_infractions["two active perm bans"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {'type': 'ban', 'active': True, 'hidden': True},
+ {'type': 'ban', 'active': True, 'hidden': True},
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #8: Multiple active temporary bans
+ cls.created_infractions["multiple active temp bans"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {
+ 'type': 'ban',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': timezone.now() + timedelta(days=1)
+ },
+ {
+ 'type': 'ban',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': timezone.now() + timedelta(days=10)
+ },
+ {
+ 'type': 'ban',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': timezone.now() + timedelta(days=20)
+ },
+ {
+ 'type': 'ban',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': timezone.now() + timedelta(days=5)
+ },
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #9: One active permanent ban, two active temporary bans
+ cls.created_infractions["active perm, two active temp bans"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {
+ 'type': 'ban',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': timezone.now() + timedelta(days=10)
+ },
+ {
+ 'type': 'ban',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': None,
+ },
+ {
+ 'type': 'ban',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': timezone.now() + timedelta(days=7)
+ },
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #10: One inactive permanent ban, two active temporary bans
+ cls.created_infractions["one inactive perm ban, two active temp bans"] = (
+ InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {
+ 'type': 'ban',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': timezone.now() + timedelta(days=10)
+ },
+ {
+ 'type': 'ban',
+ 'active': False,
+ 'hidden': True,
+ 'expires_at': None,
+ },
+ {
+ 'type': 'ban',
+ 'active': True,
+ 'hidden': True,
+ 'expires_at': timezone.now() + timedelta(days=7)
+ },
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+ )
+
+ # User #11: Active ban, active mute, active superstar
+ cls.created_infractions["active ban, mute, and superstar"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {'type': 'ban', 'active': True, 'hidden': True},
+ {'type': 'mute', 'active': True, 'hidden': True},
+ {'type': 'superstar', 'active': True, 'hidden': True},
+ {'type': 'watch', 'active': True, 'hidden': True},
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ # User #12: Multiple active bans, active mutes, active superstars
+ cls.created_infractions["multiple active bans, mutes, stars"] = InfractionFactory.create(
+ actor=cls.user_moderator,
+ infractions=(
+ {'type': 'ban', 'active': True, 'hidden': True},
+ {'type': 'ban', 'active': True, 'hidden': True},
+ {'type': 'ban', 'active': True, 'hidden': True},
+ {'type': 'mute', 'active': True, 'hidden': True},
+ {'type': 'mute', 'active': True, 'hidden': True},
+ {'type': 'mute', 'active': True, 'hidden': True},
+ {'type': 'superstar', 'active': True, 'hidden': True},
+ {'type': 'superstar', 'active': True, 'hidden': True},
+ {'type': 'superstar', 'active': True, 'hidden': True},
+ {'type': 'watch', 'active': True, 'hidden': True},
+ {'type': 'watch', 'active': True, 'hidden': True},
+ {'type': 'watch', 'active': True, 'hidden': True},
+ ),
+ infraction_model=cls.infraction_model,
+ user_model=cls.user_model,
+ )
+
+ def test_all_never_active_types_became_inactive(self):
+ """Are all infractions of a non-active type inactive after the migration?"""
+ inactive_type_query = Q(type="note") | Q(type="warning") | Q(type="kick")
+ self.assertFalse(
+ self.infraction_model.objects.filter(inactive_type_query, active=True).exists()
+ )
+
+ def test_migration_left_clean_user_without_infractions(self):
+ """Do users without infractions have no infractions after the migration?"""
+ user_id, infraction_history = self.created_infractions["no infractions"]
+ self.assertFalse(
+ self.infraction_model.objects.filter(user__id=user_id).exists()
+ )
+
+ def test_migration_left_user_with_inactive_note_untouched(self):
+ """Did the migration leave users with only an inactive note untouched?"""
+ user_id, infraction_history = self.created_infractions["one inactive note"]
+ inactive_note = infraction_history[0]
+ self.assertTrue(
+ self.infraction_model.objects.filter(**model_to_dict(inactive_note)).exists()
+ )
+
+ def test_migration_only_touched_active_field_of_active_note(self):
+ """Does the migration only change the `active` field?"""
+ user_id, infraction_history = self.created_infractions["one active note"]
+ note = model_to_dict(infraction_history[0])
+ note['active'] = False
+ self.assertTrue(
+ self.infraction_model.objects.filter(**note).exists()
+ )
+
+ def test_migration_only_touched_active_field_of_active_note_left_inactive_untouched(self):
+ """Does the migration only change the `active` field of active notes?"""
+ user_id, infraction_history = self.created_infractions["one active and one inactive note"]
+ for note in infraction_history:
+ with self.subTest(active=note.active):
+ note = model_to_dict(note)
+ note['active'] = False
+ self.assertTrue(
+ self.infraction_model.objects.filter(**note).exists()
+ )
+
+ def test_migration_migrates_all_nonactive_types_to_inactive(self):
+ """Do we set the `active` field of all non-active infractions to `False`?"""
+ user_id, infraction_history = self.created_infractions["active note, kick, warning"]
+ self.assertFalse(
+ self.infraction_model.objects.filter(user__id=user_id, active=True).exists()
+ )
+
+ def test_migration_leaves_user_with_one_active_ban_untouched(self):
+ """Do we leave a user with one active and one inactive ban untouched?"""
+ user_id, infraction_history = self.created_infractions["one inactive and one active ban"]
+ for infraction in infraction_history:
+ with self.subTest(active=infraction.active):
+ self.assertTrue(
+ self.infraction_model.objects.filter(**model_to_dict(infraction)).exists()
+ )
+
+ def test_migration_turns_double_active_perm_ban_into_single_active_perm_ban(self):
+ """Does the migration turn two active permanent bans into one active permanent ban?"""
+ user_id, infraction_history = self.created_infractions["two active perm bans"]
+ active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count()
+ self.assertEqual(active_count, 1)
+
+ def test_migration_leaves_temporary_ban_with_longest_duration_active(self):
+ """Does the migration turn two active permanent bans into one active permanent ban?"""
+ user_id, infraction_history = self.created_infractions["multiple active temp bans"]
+ active_ban = self.infraction_model.objects.get(user__id=user_id, active=True)
+ self.assertEqual(active_ban.expires_at, infraction_history[2].expires_at)
+
+ def test_migration_leaves_permanent_ban_active(self):
+ """Does the migration leave the permanent ban active?"""
+ user_id, infraction_history = self.created_infractions["active perm, two active temp bans"]
+ active_ban = self.infraction_model.objects.get(user__id=user_id, active=True)
+ self.assertIsNone(active_ban.expires_at)
+
+ def test_migration_leaves_longest_temp_ban_active_with_inactive_permanent_ban(self):
+ """Does the longest temp ban stay active, even with an inactive perm ban present?"""
+ user_id, infraction_history = self.created_infractions[
+ "one inactive perm ban, two active temp bans"
+ ]
+ active_ban = self.infraction_model.objects.get(user__id=user_id, active=True)
+ self.assertEqual(active_ban.expires_at, infraction_history[0].expires_at)
+
+ def test_migration_leaves_all_active_types_active_if_one_of_each_exists(self):
+ """Do all active infractions stay active if only one of each is present?"""
+ user_id, infraction_history = self.created_infractions["active ban, mute, and superstar"]
+ active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count()
+ self.assertEqual(active_count, 4)
+
+ def test_migration_reduces_all_active_types_to_a_single_active_infraction(self):
+ """Do we reduce all of the infraction types to one active infraction?"""
+ user_id, infraction_history = self.created_infractions["multiple active bans, mutes, stars"]
+ active_infractions = self.infraction_model.objects.filter(user__id=user_id, active=True)
+ self.assertEqual(len(active_infractions), 4)
+ types_observed = [infraction.type for infraction in active_infractions]
+
+ for infraction_type in ('ban', 'mute', 'superstar', 'watch'):
+ with self.subTest(type=infraction_type):
+ self.assertIn(infraction_type, types_observed)
diff --git a/pydis_site/apps/api/tests/migrations/test_base.py b/pydis_site/apps/api/tests/migrations/test_base.py
new file mode 100644
index 00000000..f69bc92c
--- /dev/null
+++ b/pydis_site/apps/api/tests/migrations/test_base.py
@@ -0,0 +1,135 @@
+import logging
+from unittest.mock import call, patch
+
+from django.db.migrations.loader import MigrationLoader
+from django.test import TestCase
+
+from .base import MigrationsTestCase, connection
+
+log = logging.getLogger(__name__)
+
+
+class SpanishInquisition(MigrationsTestCase):
+ app = "api"
+ migration_prior = "scragly"
+ migration_target = "kosa"
+
+
+@patch("pydis_site.apps.api.tests.migrations.base.MigrationExecutor")
+class MigrationsTestCaseNoSideEffectsTests(TestCase):
+ """Tests the MigrationTestCase class with actual migration side effects disabled."""
+
+ def setUp(self):
+ """Set up an instance of MigrationsTestCase for use in tests."""
+ self.test_case = SpanishInquisition()
+
+ def test_missing_app_class_raises_value_error(self, _migration_executor):
+ """A MigrationsTestCase subclass should set the class-attribute `app`."""
+ class Spam(MigrationsTestCase):
+ pass
+
+ spam = Spam()
+ with self.assertRaises(ValueError, msg="The `app` attribute was not set."):
+ spam.setUpTestData()
+
+ def test_missing_migration_class_attributes_raise_value_error(self, _migration_executor):
+ """A MigrationsTestCase subclass should set both `migration_prior` and `migration_target`"""
+ class Eggs(MigrationsTestCase):
+ app = "api"
+ migration_target = "lemon"
+
+ class Bacon(MigrationsTestCase):
+ app = "api"
+ migration_prior = "mark"
+
+ instances = (Eggs(), Bacon())
+
+ exception_message = "Both ` migration_prior` and `migration_target` need to be set."
+ for instance in instances:
+ with self.subTest(
+ migration_prior=instance.migration_prior,
+ migration_target=instance.migration_target,
+ ):
+ with self.assertRaises(ValueError, msg=exception_message):
+ instance.setUpTestData()
+
+ @patch(f"{__name__}.SpanishInquisition.setUpMigrationData")
+ @patch(f"{__name__}.SpanishInquisition.setUpPostMigrationData")
+ def test_migration_data_hooks_are_called_once(self, pre_hook, post_hook, _migration_executor):
+ """The `setUpMigrationData` and `setUpPostMigrationData` hooks should be called once."""
+ self.test_case.setUpTestData()
+ for hook in (pre_hook, post_hook):
+ with self.subTest(hook=repr(hook)):
+ hook.assert_called_once()
+
+ def test_migration_executor_is_instantiated_twice(self, migration_executor):
+ """The `MigrationExecutor` should be instantiated with the database connection twice."""
+ self.test_case.setUpTestData()
+
+ expected_args = [call(connection), call(connection)]
+ self.assertEqual(migration_executor.call_args_list, expected_args)
+
+ def test_project_state_is_loaded_for_correct_migration_files_twice(self, migration_executor):
+ """The `project_state` should first be loaded with `migrate_from`, then `migrate_to`."""
+ self.test_case.setUpTestData()
+
+ expected_args = [call(self.test_case.migrate_from), call(self.test_case.migrate_to)]
+ self.assertEqual(migration_executor().loader.project_state.call_args_list, expected_args)
+
+ def test_loader_build_graph_gets_called_once(self, migration_executor):
+ """We should rebuild the migration graph before applying the second set of migrations."""
+ self.test_case.setUpTestData()
+
+ migration_executor().loader.build_graph.assert_called_once()
+
+ def test_migration_executor_migrate_method_is_called_correctly_twice(self, migration_executor):
+ """The migrate method of the executor should be called twice with the correct arguments."""
+ self.test_case.setUpTestData()
+
+ self.assertEqual(migration_executor().migrate.call_count, 2)
+ calls = [call([('api', 'scragly')]), call([('api', 'kosa')])]
+ migration_executor().migrate.assert_has_calls(calls)
+
+
+class LifeOfBrian(MigrationsTestCase):
+ app = "api"
+ migration_prior = "0046_reminder_jump_url"
+ migration_target = "0048_add_infractions_unique_constraints_active"
+
+ @classmethod
+ def log_last_migration(cls):
+ """Parses the applied migrations dictionary to log the last applied migration."""
+ loader = MigrationLoader(connection)
+ api_migrations = [
+ migration for app, migration in loader.applied_migrations if app == cls.app
+ ]
+ last_migration = max(api_migrations, key=lambda name: int(name[:4]))
+ log.info(f"The last applied migration: {last_migration}")
+
+ @classmethod
+ def setUpMigrationData(cls, apps):
+ """Method that logs the last applied migration at this point."""
+ cls.log_last_migration()
+
+ @classmethod
+ def setUpPostMigrationData(cls, apps):
+ """Method that logs the last applied migration at this point."""
+ cls.log_last_migration()
+
+
+class MigrationsTestCaseMigrationTest(TestCase):
+ """Tests if `MigrationsTestCase` travels to the right points in the migration history."""
+
+ def test_migrations_test_case_travels_to_correct_migrations_in_history(self):
+ """The test case should first revert to `migration_prior`, then go to `migration_target`."""
+ brian = LifeOfBrian()
+
+ with self.assertLogs(log, level=logging.INFO) as logs:
+ brian.setUpTestData()
+
+ self.assertEqual(len(logs.records), 2)
+
+ for time_point, record in zip(("migration_prior", "migration_target"), logs.records):
+ with self.subTest(time_point=time_point):
+ message = f"The last applied migration: {getattr(brian, time_point)}"
+ self.assertEqual(record.getMessage(), message)
diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py
index ccccdda4..287c1737 100644
--- a/pydis_site/apps/api/tests/test_deleted_messages.py
+++ b/pydis_site/apps/api/tests/test_deleted_messages.py
@@ -9,12 +9,11 @@ from ..models import MessageDeletionContext, User
class DeletedMessagesWithoutActorTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.author = User.objects.create(
id=55,
name='Robbie Rotten',
discriminator=55,
- avatar_hash=None
)
cls.data = {
@@ -26,14 +25,16 @@ class DeletedMessagesWithoutActorTests(APISubdomainTestCase):
'id': 55,
'channel_id': 5555,
'content': "Terror Billy is a meanie",
- 'embeds': []
+ 'embeds': [],
+ 'attachments': []
},
{
'author': cls.author.id,
'id': 56,
'channel_id': 5555,
'content': "If you purge this, you're evil",
- 'embeds': []
+ 'embeds': [],
+ 'attachments': []
}
]
}
@@ -48,12 +49,11 @@ class DeletedMessagesWithoutActorTests(APISubdomainTestCase):
class DeletedMessagesWithActorTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.author = cls.actor = User.objects.create(
id=12904,
name='Joe Armstrong',
discriminator=1245,
- avatar_hash=None
)
cls.data = {
@@ -65,7 +65,8 @@ class DeletedMessagesWithActorTests(APISubdomainTestCase):
'id': 12903,
'channel_id': 1824,
'content': "I hate trailing commas",
- 'embeds': []
+ 'embeds': [],
+ 'attachments': []
},
]
}
diff --git a/pydis_site/apps/api/tests/test_documentation_links.py b/pydis_site/apps/api/tests/test_documentation_links.py
index f6c78391..e560a2fd 100644
--- a/pydis_site/apps/api/tests/test_documentation_links.py
+++ b/pydis_site/apps/api/tests/test_documentation_links.py
@@ -57,7 +57,7 @@ class EmptyDatabaseDocumentationLinkAPITests(APISubdomainTestCase):
class DetailLookupDocumentationLinkAPITests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.doc_link = DocumentationLink.objects.create(
package='testpackage',
base_url='https://example.com',
@@ -141,7 +141,7 @@ class DocumentationLinkCreationTests(APISubdomainTestCase):
class DocumentationLinkDeletionTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.doc_link = DocumentationLink.objects.create(
package='example',
base_url='https://example.com',
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_infractions.py b/pydis_site/apps/api/tests/test_infractions.py
index c58c32e2..93ef8171 100644
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -1,10 +1,13 @@
from datetime import datetime as dt, timedelta, timezone
+from unittest.mock import patch
from urllib.parse import quote
+from django.db.utils import IntegrityError
from django_hosts.resolvers import reverse
from .base import APISubdomainTestCase
from ..models import Infraction, User
+from ..serializers import InfractionSerializer
class UnauthenticatedTests(APISubdomainTestCase):
@@ -39,12 +42,11 @@ class UnauthenticatedTests(APISubdomainTestCase):
class InfractionTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
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,
@@ -52,7 +54,8 @@ class InfractionTests(APISubdomainTestCase):
type='ban',
reason='He terk my jerb!',
hidden=True,
- expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc)
+ expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc),
+ active=True
)
cls.ban_inactive = Infraction.objects.create(
user_id=cls.user.id,
@@ -160,12 +163,16 @@ class InfractionTests(APISubdomainTestCase):
class CreationTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.user = User.objects.create(
id=5,
name='james',
discriminator=1,
- avatar_hash=None
+ )
+ cls.second_user = User.objects.create(
+ id=6,
+ name='carl',
+ discriminator=2,
)
def test_accepts_valid_data(self):
@@ -176,7 +183,8 @@ class CreationTests(APISubdomainTestCase):
'type': 'ban',
'reason': 'He terk my jerb!',
'hidden': True,
- 'expires_at': '5018-11-20T15:52:00+00:00'
+ 'expires_at': '5018-11-20T15:52:00+00:00',
+ 'active': True,
}
response = self.client.post(url, data=data)
@@ -200,7 +208,8 @@ class CreationTests(APISubdomainTestCase):
url = reverse('bot:infraction-list', host='api')
data = {
'actor': self.user.id,
- 'type': 'kick'
+ 'type': 'kick',
+ 'active': False,
}
response = self.client.post(url, data=data)
@@ -214,7 +223,8 @@ class CreationTests(APISubdomainTestCase):
data = {
'user': 1337,
'actor': self.user.id,
- 'type': 'kick'
+ 'type': 'kick',
+ 'active': True,
}
response = self.client.post(url, data=data)
@@ -228,7 +238,8 @@ class CreationTests(APISubdomainTestCase):
data = {
'user': self.user.id,
'actor': self.user.id,
- 'type': 'hug'
+ 'type': 'hug',
+ 'active': True,
}
response = self.client.post(url, data=data)
@@ -243,7 +254,8 @@ class CreationTests(APISubdomainTestCase):
'user': self.user.id,
'actor': self.user.id,
'type': 'ban',
- 'expires_at': '20/11/5018 15:52:00'
+ 'expires_at': '20/11/5018 15:52:00',
+ 'active': True,
}
response = self.client.post(url, data=data)
@@ -263,7 +275,8 @@ class CreationTests(APISubdomainTestCase):
'user': self.user.id,
'actor': self.user.id,
'type': infraction_type,
- 'expires_at': '5018-11-20T15:52:00+00:00'
+ 'expires_at': '5018-11-20T15:52:00+00:00',
+ 'active': False,
}
response = self.client.post(url, data=data)
@@ -280,7 +293,8 @@ class CreationTests(APISubdomainTestCase):
'user': self.user.id,
'actor': self.user.id,
'type': infraction_type,
- 'hidden': True
+ 'hidden': True,
+ 'active': False,
}
response = self.client.post(url, data=data)
@@ -297,6 +311,7 @@ class CreationTests(APISubdomainTestCase):
'actor': self.user.id,
'type': 'note',
'hidden': False,
+ 'active': False,
}
response = self.client.post(url, data=data)
@@ -305,31 +320,223 @@ class CreationTests(APISubdomainTestCase):
'hidden': [f'{data["type"]} infractions must be hidden.']
})
+ def test_returns_400_for_active_infraction_of_type_that_cannot_be_active(self):
+ """Test if the API rejects active infractions for types that cannot be active."""
+ url = reverse('bot:infraction-list', host='api')
+ restricted_types = (
+ ('note', True),
+ ('warning', False),
+ ('kick', False),
+ )
+
+ for infraction_type, hidden in restricted_types:
+ with self.subTest(infraction_type=infraction_type):
+ invalid_infraction = {
+ 'user': self.user.id,
+ 'actor': self.user.id,
+ 'type': infraction_type,
+ 'reason': 'Take me on!',
+ 'hidden': hidden,
+ 'active': True,
+ 'expires_at': None,
+ }
+ response = self.client.post(url, data=invalid_infraction)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(
+ response.json(),
+ {'active': [f'{infraction_type} infractions cannot be active.']}
+ )
+
+ def test_returns_400_for_second_active_infraction_of_the_same_type(self):
+ """Test if the API rejects a second active infraction of the same type for a given user."""
+ url = reverse('bot:infraction-list', host='api')
+ active_infraction_types = ('mute', 'ban', 'superstar')
+
+ for infraction_type in active_infraction_types:
+ with self.subTest(infraction_type=infraction_type):
+ first_active_infraction = {
+ 'user': self.user.id,
+ 'actor': self.user.id,
+ 'type': infraction_type,
+ 'reason': 'Take me on!',
+ 'active': True,
+ 'expires_at': '2019-10-04T12:52:00+00:00'
+ }
+
+ # Post the first active infraction of a type and confirm it's accepted.
+ first_response = self.client.post(url, data=first_active_infraction)
+ self.assertEqual(first_response.status_code, 201)
+
+ second_active_infraction = {
+ 'user': self.user.id,
+ 'actor': self.user.id,
+ 'type': infraction_type,
+ 'reason': 'Take on me!',
+ 'active': True,
+ 'expires_at': '2019-10-04T12:52:00+00:00'
+ }
+ second_response = self.client.post(url, data=second_active_infraction)
+ self.assertEqual(second_response.status_code, 400)
+ self.assertEqual(
+ second_response.json(),
+ {
+ 'non_field_errors': [
+ 'This user already has an active infraction of this type.'
+ ]
+ }
+ )
+
+ def test_returns_201_for_second_active_infraction_of_different_type(self):
+ """Test if the API accepts a second active infraction of a different type than the first."""
+ url = reverse('bot:infraction-list', host='api')
+ first_active_infraction = {
+ 'user': self.user.id,
+ 'actor': self.user.id,
+ 'type': 'mute',
+ 'reason': 'Be silent!',
+ 'hidden': True,
+ 'active': True,
+ 'expires_at': '2019-10-04T12:52:00+00:00'
+ }
+ second_active_infraction = {
+ 'user': self.user.id,
+ 'actor': self.user.id,
+ 'type': 'ban',
+ 'reason': 'Be gone!',
+ 'hidden': True,
+ 'active': True,
+ 'expires_at': '2019-10-05T12:52:00+00:00'
+ }
+ # Post the first active infraction of a type and confirm it's accepted.
+ first_response = self.client.post(url, data=first_active_infraction)
+ self.assertEqual(first_response.status_code, 201)
+
+ # Post the first active infraction of a type and confirm it's accepted.
+ second_response = self.client.post(url, data=second_active_infraction)
+ self.assertEqual(second_response.status_code, 201)
+
+ def test_unique_constraint_raises_integrity_error_on_second_active_of_same_type(self):
+ """Do we raise `IntegrityError` for the second active infraction of a type for a user?"""
+ Infraction.objects.create(
+ user=self.user,
+ actor=self.user,
+ type="ban",
+ active=True,
+ reason="The first active ban"
+ )
+ with self.assertRaises(IntegrityError):
+ Infraction.objects.create(
+ user=self.user,
+ actor=self.user,
+ type="ban",
+ active=True,
+ reason="The second active ban"
+ )
+
+ def test_unique_constraint_accepts_active_infraction_after_inactive_infraction(self):
+ """Do we accept an active infraction if the others of the same type are inactive?"""
+ try:
+ Infraction.objects.create(
+ user=self.user,
+ actor=self.user,
+ type="ban",
+ active=False,
+ reason="The first inactive ban"
+ )
+ Infraction.objects.create(
+ user=self.user,
+ actor=self.user,
+ type="ban",
+ active=False,
+ reason="The second inactive ban"
+ )
+ Infraction.objects.create(
+ user=self.user,
+ actor=self.user,
+ type="ban",
+ active=True,
+ reason="The first active ban"
+ )
+ except IntegrityError:
+ self.fail("An unexpected IntegrityError was raised.")
+
+ @patch(f"{__name__}.Infraction")
+ def test_if_accepts_active_infraction_test_catches_integrity_error(self, infraction_patch):
+ """Does the test properly catch the IntegrityError and raise an AssertionError?"""
+ infraction_patch.objects.create.side_effect = IntegrityError
+ with self.assertRaises(AssertionError, msg="An unexpected IntegrityError was raised."):
+ self.test_unique_constraint_accepts_active_infraction_after_inactive_infraction()
+
+ def test_unique_constraint_accepts_second_active_of_different_type(self):
+ """Do we accept a second active infraction of a different type for a given user?"""
+ Infraction.objects.create(
+ user=self.user,
+ actor=self.user,
+ type="ban",
+ active=True,
+ reason="The first active ban"
+ )
+ Infraction.objects.create(
+ user=self.user,
+ actor=self.user,
+ type="mute",
+ active=True,
+ reason="The first active mute"
+ )
+
+ def test_unique_constraint_accepts_active_infractions_for_different_users(self):
+ """Do we accept two active infractions of the same type for two different users?"""
+ Infraction.objects.create(
+ user=self.user,
+ actor=self.user,
+ type="ban",
+ active=True,
+ reason="An active ban for the first user"
+ )
+ Infraction.objects.create(
+ user=self.second_user,
+ actor=self.second_user,
+ type="ban",
+ active=False,
+ reason="An active ban for the second user"
+ )
+
+ def test_integrity_error_if_missing_active_field(self):
+ pattern = 'null value in column "active" violates not-null constraint'
+ with self.assertRaisesRegex(IntegrityError, pattern):
+ Infraction.objects.create(
+ user=self.user,
+ actor=self.user,
+ type='ban',
+ reason='A reason.',
+ )
+
class ExpandedTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
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'
+ type='kick',
+ active=False
)
cls.warning = Infraction.objects.create(
user_id=cls.user.id,
actor_id=cls.user.id,
- type='warning'
+ type='warning',
+ active=False,
)
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'):
+ for field in ('id', 'name', 'discriminator', 'roles', 'in_guild'):
self.assertTrue(field in obj, msg=f'field "{field}" missing from {key}')
def test_list_expanded(self):
@@ -349,7 +556,8 @@ class ExpandedTests(APISubdomainTestCase):
data = {
'user': self.user.id,
'actor': self.user.id,
- 'type': 'warning'
+ 'type': 'warning',
+ 'active': False
}
response = self.client.post(url, data=data)
@@ -378,3 +586,80 @@ class ExpandedTests(APISubdomainTestCase):
infraction = Infraction.objects.get(id=self.kick.id)
self.assertEqual(infraction.active, data['active'])
self.check_expanded_fields(response.json())
+
+
+class SerializerTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.user = User.objects.create(
+ id=5,
+ name='james',
+ discriminator=1,
+ )
+
+ def create_infraction(self, _type: str, active: bool):
+ return Infraction.objects.create(
+ user_id=self.user.id,
+ actor_id=self.user.id,
+ type=_type,
+ reason='A reason.',
+ expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc),
+ active=active
+ )
+
+ def test_is_valid_if_active_infraction_with_same_fields_exists(self):
+ self.create_infraction('ban', active=True)
+ instance = self.create_infraction('ban', active=False)
+
+ data = {'reason': 'hello'}
+ serializer = InfractionSerializer(instance, data=data, partial=True)
+
+ self.assertTrue(serializer.is_valid(), msg=serializer.errors)
+
+ def test_validation_error_if_active_duplicate(self):
+ self.create_infraction('ban', active=True)
+ instance = self.create_infraction('ban', active=False)
+
+ data = {'active': True}
+ serializer = InfractionSerializer(instance, data=data, partial=True)
+
+ if not serializer.is_valid():
+ self.assertIn('non_field_errors', serializer.errors)
+
+ code = serializer.errors['non_field_errors'][0].code
+ msg = f'Expected failure on unique validator but got {serializer.errors}'
+ self.assertEqual(code, 'unique', msg=msg)
+ else: # pragma: no cover
+ self.fail('Validation unexpectedly succeeded.')
+
+ def test_is_valid_for_new_active_infraction(self):
+ self.create_infraction('ban', active=False)
+
+ data = {
+ 'user': self.user.id,
+ 'actor': self.user.id,
+ 'type': 'ban',
+ 'reason': 'A reason.',
+ 'active': True
+ }
+ serializer = InfractionSerializer(data=data)
+
+ self.assertTrue(serializer.is_valid(), msg=serializer.errors)
+
+ def test_validation_error_if_missing_active_field(self):
+ data = {
+ 'user': self.user.id,
+ 'actor': self.user.id,
+ 'type': 'ban',
+ 'reason': 'A reason.',
+ }
+ serializer = InfractionSerializer(data=data)
+
+ if not serializer.is_valid():
+ self.assertIn('active', serializer.errors)
+
+ code = serializer.errors['active'][0].code
+ msg = f'Expected failure on required active field but got {serializer.errors}'
+ self.assertEqual(code, 'required', msg=msg)
+ else: # pragma: no cover
+ self.fail('Validation unexpectedly succeeded.')
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index bce76942..853e6621 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -3,20 +3,20 @@ 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,
Reminder,
Role,
- Tag,
User
)
+from pydis_site.apps.api.models.mixins import ModelReprMixin
class SimpleClass(ModelReprMixin):
@@ -38,12 +38,14 @@ class StringDunderMethodTests(SimpleTestCase):
self.nomination = Nomination(
id=123,
actor=User(
- id=9876, name='Mr. Hemlock',
- discriminator=6666, avatar_hash=None
+ id=9876,
+ name='Mr. Hemlock',
+ discriminator=6666,
),
user=User(
- id=9876, name="Hemlock's Cat",
- discriminator=7777, avatar_hash=None
+ id=9876,
+ name="Hemlock's Cat",
+ discriminator=7777,
),
reason="He purrrrs like the best!",
)
@@ -52,15 +54,17 @@ class StringDunderMethodTests(SimpleTestCase):
DeletedMessage(
id=45,
author=User(
- id=444, name='bill',
- discriminator=5, avatar_hash=None
+ id=444,
+ name='bill',
+ discriminator=5,
),
channel_id=666,
content="wooey",
deletion_context=MessageDeletionContext(
actor=User(
- id=5555, name='shawn',
- discriminator=555, avatar_hash=None
+ id=5555,
+ name='shawn',
+ discriminator=555,
),
creation=dt.utcnow()
),
@@ -69,6 +73,11 @@ class StringDunderMethodTests(SimpleTestCase):
DocumentationLink(
'test', 'http://example.com', 'http://example.com'
),
+ OffensiveMessage(
+ id=602951077675139072,
+ channel_id=291284109232308226,
+ delete_date=dt(3000, 1, 1)
+ ),
OffTopicChannelName(name='bob-the-builders-playground'),
Role(
id=5, name='test role',
@@ -78,8 +87,9 @@ class StringDunderMethodTests(SimpleTestCase):
Message(
id=45,
author=User(
- id=444, name='bill',
- discriminator=5, avatar_hash=None
+ id=444,
+ name='bill',
+ discriminator=5,
),
channel_id=666,
content="wooey",
@@ -87,34 +97,42 @@ class StringDunderMethodTests(SimpleTestCase):
),
MessageDeletionContext(
actor=User(
- id=5555, name='shawn',
- discriminator=555, avatar_hash=None
+ id=5555,
+ name='shawn',
+ discriminator=555,
),
creation=dt.utcnow()
),
- Tag(
- title='bob',
- embed={'content': "the builder"}
- ),
User(
- id=5, name='bob',
- discriminator=1, avatar_hash=None
+ id=5,
+ name='bob',
+ discriminator=1,
),
Infraction(
- user_id=5, actor_id=5,
- type='kick', reason='He terk my jerb!'
+ 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!',
+ 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
+ id=452,
+ name='billy',
+ discriminator=5,
),
channel_id=555,
+ jump_url=(
+ 'https://discordapp.com/channels/'
+ '267624335836053506/291284109232308226/463087129459949587'
+ ),
content="oh no",
expiration=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc)
)
diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py
index add5a7e4..b37135f8 100644
--- a/pydis_site/apps/api/tests/test_nominations.py
+++ b/pydis_site/apps/api/tests/test_nominations.py
@@ -8,12 +8,11 @@ from ..models import Nomination, User
class CreationTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.user = User.objects.create(
id=1234,
name='joe dart',
discriminator=1111,
- avatar_hash=None
)
def test_accepts_valid_data(self):
@@ -81,7 +80,7 @@ class CreationTests(APISubdomainTestCase):
'actor': ['This field is required.']
})
- def test_returns_400_for_missing_reason(self):
+ def test_returns_201_for_missing_reason(self):
url = reverse('bot:nomination-list', host='api')
data = {
'user': self.user.id,
@@ -89,10 +88,7 @@ class CreationTests(APISubdomainTestCase):
}
response = self.client.post(url, data=data)
- self.assertEqual(response.status_code, 400)
- self.assertEqual(response.json(), {
- 'reason': ['This field is required.']
- })
+ self.assertEqual(response.status_code, 201)
def test_returns_400_for_bad_user(self):
url = reverse('bot:nomination-list', host='api')
@@ -185,12 +181,11 @@ class CreationTests(APISubdomainTestCase):
class NominationTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.user = User.objects.create(
id=1234,
name='joe dart',
discriminator=1111,
- avatar_hash=None
)
cls.active_nomination = Nomination.objects.create(
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
index 9ab71409..3ab8b22d 100644
--- a/pydis_site/apps/api/tests/test_off_topic_channel_names.py
+++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py
@@ -10,12 +10,14 @@ class UnauthenticatedTests(APISubdomainTestCase):
self.client.force_authenticate(user=None)
def test_cannot_read_off_topic_channel_name_list(self):
+ """Return a 401 response when not authenticated."""
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):
+ """Return a 401 response when `random_items` provided and not authenticated."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(f'{url}?random_items=no')
@@ -24,6 +26,7 @@ class UnauthenticatedTests(APISubdomainTestCase):
class EmptyDatabaseTests(APISubdomainTestCase):
def test_returns_empty_object(self):
+ """Return empty list when no names in database."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(url)
@@ -31,6 +34,7 @@ class EmptyDatabaseTests(APISubdomainTestCase):
self.assertEqual(response.json(), [])
def test_returns_empty_list_with_get_all_param(self):
+ """Return empty list when no names and `random_items` param provided."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(f'{url}?random_items=5')
@@ -38,6 +42,7 @@ class EmptyDatabaseTests(APISubdomainTestCase):
self.assertEqual(response.json(), [])
def test_returns_400_for_bad_random_items_param(self):
+ """Return error message when passing not integer as `random_items`."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(f'{url}?random_items=totally-a-valid-integer')
@@ -47,6 +52,7 @@ class EmptyDatabaseTests(APISubdomainTestCase):
})
def test_returns_400_for_negative_random_items_param(self):
+ """Return error message when passing negative int as `random_items`."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(f'{url}?random_items=-5')
@@ -58,11 +64,12 @@ class EmptyDatabaseTests(APISubdomainTestCase):
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 setUpTestData(cls):
+ cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand', used=False)
+ cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk', used=True)
def test_returns_name_in_list(self):
+ """Return all off-topic channel names."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.get(url)
@@ -76,11 +83,21 @@ class ListTests(APISubdomainTestCase):
)
def test_returns_single_item_with_random_items_param_set_to_1(self):
+ """Return not-used name instead used."""
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)
+ self.assertEqual(response.json(), [self.test_name.name])
+
+ def test_running_out_of_names_with_random_parameter(self):
+ """Reset names `used` parameter to `False` when running out of names."""
+ url = reverse('bot:offtopicchannelname-list', host='api')
+ response = self.client.get(f'{url}?random_items=2')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), [self.test_name.name, self.test_name_2.name])
class CreationTests(APISubdomainTestCase):
@@ -93,6 +110,7 @@ class CreationTests(APISubdomainTestCase):
self.assertEqual(response.status_code, 201)
def test_returns_201_for_unicode_chars(self):
+ """Accept all valid characters."""
url = reverse('bot:offtopicchannelname-list', host='api')
names = (
'𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹',
@@ -104,6 +122,7 @@ class CreationTests(APISubdomainTestCase):
self.assertEqual(response.status_code, 201)
def test_returns_400_for_missing_name_param(self):
+ """Return error message when name not provided."""
url = reverse('bot:offtopicchannelname-list', host='api')
response = self.client.post(url)
self.assertEqual(response.status_code, 400)
@@ -112,6 +131,7 @@ class CreationTests(APISubdomainTestCase):
})
def test_returns_400_for_bad_name_param(self):
+ """Return error message when invalid characters provided."""
url = reverse('bot:offtopicchannelname-list', host='api')
invalid_names = (
'space between words',
@@ -129,23 +149,26 @@ class CreationTests(APISubdomainTestCase):
class DeletionTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
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):
+ """Return 404 reponse when trying to delete unknown name."""
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):
+ """Return 204 response when deleting was successful."""
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):
+ """Name gets actually deleted."""
url = reverse('bot:offtopicchannelname-detail', args=(self.test_name_2.name,), host='api')
response = self.client.delete(url)
diff --git a/pydis_site/apps/api/tests/test_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py
new file mode 100644
index 00000000..0f3dbffa
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_offensive_message.py
@@ -0,0 +1,155 @@
+import datetime
+
+from django_hosts.resolvers import reverse
+
+from .base import APISubdomainTestCase
+from ..models import OffensiveMessage
+
+
+class CreationTests(APISubdomainTestCase):
+ def test_accept_valid_data(self):
+ url = reverse('bot:offensivemessage-list', host='api')
+ delete_at = datetime.datetime.now() + datetime.timedelta(days=1)
+ data = {
+ 'id': '602951077675139072',
+ 'channel_id': '291284109232308226',
+ 'delete_date': delete_at.isoformat()[:-1]
+ }
+
+ aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc)
+
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 201)
+
+ offensive_message = OffensiveMessage.objects.get(id=response.json()['id'])
+ self.assertAlmostEqual(
+ aware_delete_at,
+ offensive_message.delete_date,
+ delta=datetime.timedelta(seconds=1)
+ )
+ self.assertEqual(data['id'], str(offensive_message.id))
+ self.assertEqual(data['channel_id'], str(offensive_message.channel_id))
+
+ def test_returns_400_on_non_future_date(self):
+ url = reverse('bot:offensivemessage-list', host='api')
+ delete_at = datetime.datetime.now() - datetime.timedelta(days=1)
+ data = {
+ 'id': '602951077675139072',
+ 'channel_id': '291284109232308226',
+ 'delete_date': delete_at.isoformat()[:-1]
+ }
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'delete_date': ['Date must be a future date']
+ })
+
+ def test_returns_400_on_negative_id_or_channel_id(self):
+ url = reverse('bot:offensivemessage-list', host='api')
+ delete_at = datetime.datetime.now() + datetime.timedelta(days=1)
+ data = {
+ 'id': '602951077675139072',
+ 'channel_id': '291284109232308226',
+ 'delete_date': delete_at.isoformat()[:-1]
+ }
+ cases = (
+ ('id', '-602951077675139072'),
+ ('channel_id', '-291284109232308226')
+ )
+
+ for field, invalid_value in cases:
+ with self.subTest(fied=field, invalid_value=invalid_value):
+ test_data = data.copy()
+ test_data.update({field: invalid_value})
+
+ response = self.client.post(url, test_data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ field: ['Ensure this value is greater than or equal to 0.']
+ })
+
+
+class ListTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ delete_at = datetime.datetime.now() + datetime.timedelta(days=1)
+ aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc)
+
+ cls.messages = [
+ {
+ 'id': 602951077675139072,
+ 'channel_id': 91284109232308226,
+ },
+ {
+ 'id': 645298201494159401,
+ 'channel_id': 592000283102674944
+ }
+ ]
+
+ cls.of1 = OffensiveMessage.objects.create(
+ **cls.messages[0],
+ delete_date=aware_delete_at.isoformat()
+ )
+ cls.of2 = OffensiveMessage.objects.create(
+ **cls.messages[1],
+ delete_date=aware_delete_at.isoformat()
+ )
+
+ # Expected API answer :
+ cls.messages[0]['delete_date'] = delete_at.isoformat() + 'Z'
+ cls.messages[1]['delete_date'] = delete_at.isoformat() + 'Z'
+
+ def test_get_data(self):
+ url = reverse('bot:offensivemessage-list', host='api')
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ self.assertEqual(response.json(), self.messages)
+
+
+class DeletionTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ delete_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1)
+
+ cls.valid_offensive_message = OffensiveMessage.objects.create(
+ id=602951077675139072,
+ channel_id=291284109232308226,
+ delete_date=delete_at.isoformat()
+ )
+
+ def test_delete_data(self):
+ url = reverse(
+ 'bot:offensivemessage-detail', host='api', args=(self.valid_offensive_message.id,)
+ )
+
+ response = self.client.delete(url)
+ self.assertEqual(response.status_code, 204)
+
+ self.assertFalse(
+ OffensiveMessage.objects.filter(id=self.valid_offensive_message.id).exists()
+ )
+
+
+class NotAllowedMethodsTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ delete_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1)
+
+ cls.valid_offensive_message = OffensiveMessage.objects.create(
+ id=602951077675139072,
+ channel_id=291284109232308226,
+ delete_date=delete_at.isoformat()
+ )
+
+ def test_returns_405_for_patch_and_put_requests(self):
+ url = reverse(
+ 'bot:offensivemessage-detail', host='api', args=(self.valid_offensive_message.id,)
+ )
+ not_allowed_methods = (self.client.patch, self.client.put)
+
+ for method in not_allowed_methods:
+ with self.subTest(method=method):
+ response = method(url, {})
+ self.assertEqual(response.status_code, 405)
diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py
new file mode 100644
index 00000000..9dffb668
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_reminders.py
@@ -0,0 +1,221 @@
+from datetime import datetime
+
+from django.forms.models import model_to_dict
+from django_hosts.resolvers import reverse
+
+from .base import APISubdomainTestCase
+from ..models import Reminder, User
+
+
+class UnauthedReminderAPITests(APISubdomainTestCase):
+ def setUp(self):
+ super().setUp()
+ self.client.force_authenticate(user=None)
+
+ def test_list_returns_401(self):
+ url = reverse('bot:reminder-list', host='api')
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_create_returns_401(self):
+ url = reverse('bot:reminder-list', host='api')
+ response = self.client.post(url, data={'not': 'important'})
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_delete_returns_401(self):
+ url = reverse('bot:reminder-detail', args=('1234',), host='api')
+ response = self.client.delete(url)
+
+ self.assertEqual(response.status_code, 401)
+
+
+class EmptyDatabaseReminderAPITests(APISubdomainTestCase):
+ def test_list_all_returns_empty_list(self):
+ url = reverse('bot:reminder-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:reminder-detail', args=('1234',), host='api')
+ response = self.client.delete(url)
+
+ self.assertEqual(response.status_code, 404)
+
+
+class ReminderCreationTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.author = User.objects.create(
+ id=1234,
+ name='Mermaid Man',
+ discriminator=1234,
+ )
+
+ def test_accepts_valid_data(self):
+ data = {
+ 'author': self.author.id,
+ 'content': 'Remember to...wait what was it again?',
+ 'expiration': datetime.utcnow().isoformat(),
+ 'jump_url': "https://www.google.com",
+ 'channel_id': 123,
+ 'mentions': [8888, 9999],
+ }
+ url = reverse('bot:reminder-list', host='api')
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 201)
+ self.assertIsNotNone(Reminder.objects.filter(id=1).first())
+
+ def test_rejects_invalid_data(self):
+ data = {
+ 'author': self.author.id, # Missing multiple required fields
+ }
+ url = reverse('bot:reminder-list', host='api')
+ response = self.client.post(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertRaises(Reminder.DoesNotExist, Reminder.objects.get, id=1)
+
+
+class ReminderDeletionTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.author = User.objects.create(
+ id=6789,
+ name='Barnacle Boy',
+ discriminator=6789,
+ )
+
+ cls.reminder = Reminder.objects.create(
+ author=cls.author,
+ content="Don't forget to set yourself a reminder",
+ expiration=datetime.utcnow().isoformat(),
+ jump_url="https://www.decliningmentalfaculties.com",
+ channel_id=123
+ )
+
+ def test_delete_unknown_reminder_returns_404(self):
+ url = reverse('bot:reminder-detail', args=('something',), host='api')
+ response = self.client.delete(url)
+
+ self.assertEqual(response.status_code, 404)
+
+ def test_delete_known_reminder_returns_204(self):
+ url = reverse('bot:reminder-detail', args=(self.reminder.id,), host='api')
+ response = self.client.delete(url)
+
+ self.assertEqual(response.status_code, 204)
+ self.assertRaises(Reminder.DoesNotExist, Reminder.objects.get, id=self.reminder.id)
+
+
+class ReminderListTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.author = User.objects.create(
+ id=6789,
+ name='Patrick Star',
+ discriminator=6789,
+ )
+
+ cls.reminder_one = Reminder.objects.create(
+ author=cls.author,
+ content="We should take Bikini Bottom, and push it somewhere else!",
+ expiration=datetime.utcnow().isoformat(),
+ jump_url="https://www.icantseemyforehead.com",
+ channel_id=123
+ )
+
+ cls.reminder_two = Reminder.objects.create(
+ author=cls.author,
+ content="Gahhh-I love being purple!",
+ expiration=datetime.utcnow().isoformat(),
+ jump_url="https://www.goofygoobersicecreampartyboat.com",
+ channel_id=123,
+ active=False
+ )
+
+ cls.rem_dict_one = model_to_dict(cls.reminder_one)
+ cls.rem_dict_one['expiration'] += 'Z' # Massaging a quirk of the response time format
+ cls.rem_dict_two = model_to_dict(cls.reminder_two)
+ cls.rem_dict_two['expiration'] += 'Z' # Massaging a quirk of the response time format
+
+ def test_reminders_in_full_list(self):
+ url = reverse('bot:reminder-list', host='api')
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertCountEqual(response.json(), [self.rem_dict_one, self.rem_dict_two])
+
+ def test_filter_search(self):
+ url = reverse('bot:reminder-list', host='api')
+ response = self.client.get(f'{url}?search={self.author.name}')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertCountEqual(response.json(), [self.rem_dict_one, self.rem_dict_two])
+
+ def test_filter_field(self):
+ url = reverse('bot:reminder-list', host='api')
+ response = self.client.get(f'{url}?active=true')
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), [self.rem_dict_one])
+
+
+class ReminderRetrieveTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.author = User.objects.create(
+ id=6789,
+ name='Reminder author',
+ discriminator=6789,
+ )
+
+ cls.reminder = Reminder.objects.create(
+ author=cls.author,
+ content="Reminder content",
+ expiration=datetime.utcnow().isoformat(),
+ jump_url="http://example.com/",
+ channel_id=123
+ )
+
+ def test_retrieve_unknown_returns_404(self):
+ url = reverse('bot:reminder-detail', args=("not_an_id",), host='api')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+
+ def test_retrieve_known_returns_200(self):
+ url = reverse('bot:reminder-detail', args=(self.reminder.id,), host='api')
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+
+class ReminderUpdateTests(APISubdomainTestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.author = User.objects.create(
+ id=666,
+ name='Man Ray',
+ discriminator=666,
+ )
+
+ cls.reminder = Reminder.objects.create(
+ author=cls.author,
+ content="Squash those do-gooders",
+ expiration=datetime.utcnow().isoformat(),
+ jump_url="https://www.decliningmentalfaculties.com",
+ channel_id=123
+ )
+
+ cls.data = {'content': 'Oops I forgot'}
+
+ def test_patch_updates_record(self):
+ url = reverse('bot:reminder-detail', args=(self.reminder.id,), host='api')
+ response = self.client.patch(url, data=self.data)
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ Reminder.objects.filter(id=self.reminder.id).first().content,
+ self.data['content']
+ )
diff --git a/pydis_site/apps/api/tests/test_roles.py b/pydis_site/apps/api/tests/test_roles.py
index 0a6cea9e..4d1a430c 100644
--- a/pydis_site/apps/api/tests/test_roles.py
+++ b/pydis_site/apps/api/tests/test_roles.py
@@ -6,7 +6,7 @@ from ..models import Role
class CreationTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.admins_role = Role.objects.create(
id=1,
name="Admins",
diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py
index bbdd3ff4..4c0f6e27 100644
--- a/pydis_site/apps/api/tests/test_users.py
+++ b/pydis_site/apps/api/tests/test_users.py
@@ -36,7 +36,7 @@ class UnauthedUserAPITests(APISubdomainTestCase):
class CreationTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.role = Role.objects.create(
id=5,
name="Test role pls ignore",
@@ -49,7 +49,6 @@ class CreationTests(APISubdomainTestCase):
url = reverse('bot:user-list', host='api')
data = {
'id': 42,
- 'avatar_hash': "validavatarhashiswear",
'name': "Test",
'discriminator': 42,
'roles': [
@@ -63,7 +62,6 @@ class CreationTests(APISubdomainTestCase):
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'])
@@ -73,7 +71,6 @@ class CreationTests(APISubdomainTestCase):
data = [
{
'id': 5,
- 'avatar_hash': "hahayes",
'name': "test man",
'discriminator': 42,
'roles': [
@@ -83,7 +80,6 @@ class CreationTests(APISubdomainTestCase):
},
{
'id': 8,
- 'avatar_hash': "maybenot",
'name': "another test man",
'discriminator': 555,
'roles': [],
@@ -99,7 +95,6 @@ class CreationTests(APISubdomainTestCase):
url = reverse('bot:user-list', host='api')
data = {
'id': 5,
- 'avatar_hash': "hahayes",
'name': "test man",
'discriminator': 42,
'roles': [
@@ -114,7 +109,6 @@ class CreationTests(APISubdomainTestCase):
url = reverse('bot:user-list', host='api')
data = {
'id': True,
- 'avatar_hash': 1902831,
'discriminator': "totally!"
}
@@ -124,7 +118,7 @@ class CreationTests(APISubdomainTestCase):
class UserModelTests(APISubdomainTestCase):
@classmethod
- def setUpTestData(cls): # noqa
+ def setUpTestData(cls):
cls.role_top = Role.objects.create(
id=777,
name="High test role",
@@ -148,16 +142,14 @@ class UserModelTests(APISubdomainTestCase):
)
cls.user_with_roles = User.objects.create(
id=1,
- avatar_hash="coolavatarhash",
name="Test User with two roles",
discriminator=1111,
in_guild=True,
)
- cls.user_with_roles.roles.add(cls.role_bottom, cls.role_top)
+ cls.user_with_roles.roles.extend([cls.role_bottom.id, cls.role_top.id])
cls.user_without_roles = User.objects.create(
id=2,
- avatar_hash="coolavatarhash",
name="Test User without roles",
discriminator=2222,
in_guild=True,
diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py
index 4222f0c0..8bb7b917 100644
--- a/pydis_site/apps/api/tests/test_validators.py
+++ b/pydis_site/apps/api/tests/test_validators.py
@@ -1,8 +1,11 @@
+from datetime import datetime, timezone
+
from django.core.exceptions import ValidationError
from django.test import TestCase
from ..models.bot.bot_setting import validate_bot_setting_name
-from ..models.bot.tag import validate_tag_embed
+from ..models.bot.offensive_message import future_date_validator
+from ..models.utils import validate_embed
REQUIRED_KEYS = (
@@ -22,77 +25,77 @@ class BotSettingValidatorTests(TestCase):
class TagEmbedValidatorTests(TestCase):
def test_rejects_non_mapping(self):
with self.assertRaises(ValidationError):
- validate_tag_embed('non-empty non-mapping')
+ validate_embed('non-empty non-mapping')
def test_rejects_missing_required_keys(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'unknown': "key"
})
def test_rejects_one_correct_one_incorrect(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'provider': "??",
'title': ""
})
def test_rejects_empty_required_key(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': ''
})
def test_rejects_list_as_embed(self):
with self.assertRaises(ValidationError):
- validate_tag_embed([])
+ validate_embed([])
def test_rejects_required_keys_and_unknown_keys(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_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({
+ validate_embed({
'title': 'a' * 257
})
def test_rejects_too_many_fields(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [{} for _ in range(26)]
})
def test_rejects_too_long_description(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'description': 'd' * 2049
})
def test_allows_valid_embed(self):
- validate_tag_embed({
+ validate_embed({
'title': "My embed",
'description': "look at my embed, my embed is amazing"
})
def test_allows_unvalidated_fields(self):
- validate_tag_embed({
+ validate_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({
+ validate_embed({
'fields': ['abc']
})
def test_rejects_fields_with_unknown_fields(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'what': "is this field"
@@ -102,7 +105,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_fields_with_too_long_name(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "a" * 257
@@ -112,7 +115,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_one_correct_one_incorrect_field(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "Totally valid",
@@ -128,7 +131,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_missing_required_field_field(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "Totally valid",
@@ -139,7 +142,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_invalid_inline_field_field(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "Totally valid",
@@ -150,7 +153,7 @@ class TagEmbedValidatorTests(TestCase):
})
def test_allows_valid_fields(self):
- validate_tag_embed({
+ validate_embed({
'fields': [
{
'name': "valid",
@@ -171,14 +174,14 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_footer_as_non_mapping(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'footer': []
})
def test_rejects_footer_with_unknown_fields(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'footer': {
'duck': "quack"
@@ -187,7 +190,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_footer_with_empty_text(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'footer': {
'text': ""
@@ -195,7 +198,7 @@ class TagEmbedValidatorTests(TestCase):
})
def test_allows_footer_with_proper_values(self):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'footer': {
'text': "django good"
@@ -204,14 +207,14 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_author_as_non_mapping(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': []
})
def test_rejects_author_with_unknown_field(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': {
'field': "that is unknown"
@@ -220,7 +223,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_author_with_empty_name(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': {
'name': ""
@@ -229,7 +232,7 @@ class TagEmbedValidatorTests(TestCase):
def test_rejects_author_with_one_correct_one_incorrect(self):
with self.assertRaises(ValidationError):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': {
# Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour
@@ -239,9 +242,18 @@ class TagEmbedValidatorTests(TestCase):
})
def test_allows_author_with_proper_values(self):
- validate_tag_embed({
+ validate_embed({
'title': "whatever",
'author': {
'name': "Bob"
}
})
+
+
+class OffensiveMessageValidatorsTests(TestCase):
+ def test_accepts_future_date(self):
+ future_date_validator(datetime(3000, 1, 1, tzinfo=timezone.utc))
+
+ def test_rejects_non_future_date(self):
+ with self.assertRaises(ValidationError):
+ future_date_validator(datetime(1000, 1, 1, tzinfo=timezone.utc))
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index ac6704c8..4dbf93db 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -3,17 +3,27 @@ from rest_framework.routers import DefaultRouter
from .views import HealthcheckView, RulesView
from .viewsets import (
- BotSettingViewSet, DeletedMessageViewSet,
- DocumentationLinkViewSet, InfractionViewSet,
- LogEntryViewSet, NominationViewSet,
- OffTopicChannelNameViewSet, ReminderViewSet,
- RoleViewSet, TagViewSet, UserViewSet
+ BotSettingViewSet,
+ DeletedMessageViewSet,
+ DocumentationLinkViewSet,
+ FilterListViewSet,
+ InfractionViewSet,
+ LogEntryViewSet,
+ NominationViewSet,
+ OffTopicChannelNameViewSet,
+ OffensiveMessageViewSet,
+ ReminderViewSet,
+ RoleViewSet,
+ 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
)
@@ -34,9 +44,13 @@ bot_router.register(
NominationViewSet
)
bot_router.register(
+ 'offensive-messages',
+ OffensiveMessageViewSet
+)
+bot_router.register(
'off-topic-channel-names',
OffTopicChannelNameViewSet,
- base_name='offtopicchannelname'
+ basename='offtopicchannelname'
)
bot_router.register(
'reminders',
@@ -47,10 +61,6 @@ bot_router.register(
RoleViewSet
)
bot_router.register(
- 'tags',
- TagViewSet
-)
-bot_router.register(
'users',
UserViewSet
)
diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py
index 32583665..7ac56641 100644
--- a/pydis_site/apps/api/views.py
+++ b/pydis_site/apps/api/views.py
@@ -24,7 +24,7 @@ class HealthcheckView(APIView):
authentication_classes = ()
permission_classes = ()
- def get(self, request, format=None): # noqa
+ def get(self, request, format=None): # noqa: D102,ANN001,ANN201
return Response({'status': 'ok'})
@@ -96,67 +96,54 @@ class RulesView(APIView):
)
# `format` here is the result format, we have a link format here instead.
- def get(self, request, format=None): # noqa
+ def get(self, request, format=None): # noqa: D102,ANN001,ANN201
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 = 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',
+ discord_tos = self._format_link(
+ 'Terms Of Service',
+ 'https://discordapp.com/terms',
link_format
)
- google_translate_link = self._format_link(
- 'Google Translate',
- 'https://translate.google.com/',
+ pydis_coc = self._format_link(
+ 'Python Discord Code of Conduct',
+ 'https://pythondiscord.com/pages/code-of-conduct/',
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."
+ f"Follow the {discord_community_guidelines} and {discord_tos}."
),
(
- "Be patient both with users asking "
- "questions, and the users answering them."
+ f"Follow the {pydis_coc}."
),
(
- "We will not help you with anything that might break a law or the "
- "terms of service of any other community, site, service, or "
- "otherwise - No piracy, brute-forcing, captcha circumvention, "
- "sneaker bots, or anything else of that nature."
+ "Listen to and respect staff members and their instructions."
),
(
- "Listen to and respect the staff members - we're "
- "here to help, but we're all human beings."
+ "This is an English-speaking server, "
+ "so please speak English to the best of your ability."
),
(
- "All discussion should be kept within the relevant "
- "channels for the subject - See the "
- f"{channels_page_link} for more information."
+ "Do not provide or request help on projects that may break laws, "
+ "breach terms of services, be considered malicious/inappropriate "
+ "or be for graded coursework/exams."
),
(
- "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."
+ "No spamming or unapproved advertising, including requests for paid work. "
+ "Open-source projects can be shared with others in #python-general and "
+ "code reviews can be asked for in a help channel."
),
(
- "Keep all discussions safe for work - No gore, nudity, sexual "
- "soliciting, references to suicide, or anything else of that nature"
+ "Keep discussions relevant to channel topics and guidelines."
),
- (
- "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/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index f9a186d9..dfbb880d 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -1,14 +1,15 @@
# flake8: noqa
from .bot import (
+ FilterListViewSet,
BotSettingViewSet,
DeletedMessageViewSet,
DocumentationLinkViewSet,
InfractionViewSet,
NominationViewSet,
+ OffensiveMessageViewSet,
OffTopicChannelNameViewSet,
ReminderViewSet,
RoleViewSet,
- TagViewSet,
UserViewSet
)
from .log_entry import LogEntryViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index f1851e32..84b87eab 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -1,11 +1,12 @@
# flake8: noqa
+from .filter_list import FilterListViewSet
from .bot_setting import BotSettingViewSet
from .deleted_message import DeletedMessageViewSet
from .documentation_link import DocumentationLinkViewSet
from .infraction import InfractionViewSet
from .nomination import NominationViewSet
from .off_topic_channel_name import OffTopicChannelNameViewSet
+from .offensive_message import OffensiveMessageViewSet
from .reminder import ReminderViewSet
from .role import RoleViewSet
-from .tag import TagViewSet
from .user import UserViewSet
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/apps/api/viewsets/bot/off_topic_channel_name.py b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
index d6da2399..826ad25e 100644
--- a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
+++ b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
@@ -1,3 +1,4 @@
+from django.db.models import Case, Value, When
from django.db.models.query import QuerySet
from django.http.request import HttpRequest
from django.shortcuts import get_object_or_404
@@ -20,7 +21,9 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet):
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.
+ ... then the API will return `5` random items from the database
+ that is not used in current rotation.
+ When running out of names, API will mark all names to not used and start new rotation.
#### Response format
Return a list of off-topic-channel names:
@@ -106,7 +109,27 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet):
'random_items': ["Must be a positive integer."]
})
- queryset = self.get_queryset().order_by('?')[:random_count]
+ queryset = self.get_queryset().order_by('used', '?')[:random_count]
+
+ # When any name is used in our listing then this means we reached end of round
+ # and we need to reset all other names `used` to False
+ if any(offtopic_name.used for offtopic_name in queryset):
+ # These names that we just got have to be excluded from updating used to False
+ self.get_queryset().update(
+ used=Case(
+ When(
+ name__in=(offtopic_name.name for offtopic_name in queryset),
+ then=Value(True)
+ ),
+ default=Value(False)
+ )
+ )
+ else:
+ # Otherwise mark selected names `used` to True
+ self.get_queryset().filter(
+ name__in=(offtopic_name.name for offtopic_name in queryset)
+ ).update(used=True)
+
serialized = self.serializer_class(queryset, many=True)
return Response(serialized.data)
diff --git a/pydis_site/apps/api/viewsets/bot/offensive_message.py b/pydis_site/apps/api/viewsets/bot/offensive_message.py
new file mode 100644
index 00000000..54cb3a38
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/offensive_message.py
@@ -0,0 +1,61 @@
+from rest_framework.mixins import (
+ CreateModelMixin,
+ DestroyModelMixin,
+ ListModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot.offensive_message import OffensiveMessage
+from pydis_site.apps.api.serializers import OffensiveMessageSerializer
+
+
+class OffensiveMessageViewSet(
+ CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet
+):
+ """
+ View providing CRUD access to offensive messages.
+
+ ## Routes
+ ### GET /bot/offensive-messages
+ Returns all offensive messages in the database.
+
+ #### Response format
+ >>> [
+ ... {
+ ... 'id': '631953598091100200',
+ ... 'channel_id': '291284109232308226',
+ ... 'delete_date': '2019-11-01T21:51:15.545000Z'
+ ... },
+ ... ...
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ### POST /bot/offensive-messages
+ Create a new offensive message object.
+
+ #### Request body
+ >>> {
+ ... 'id': int,
+ ... 'channel_id': int,
+ ... 'delete_date': datetime.datetime # ISO-8601-formatted date
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if the body format is invalid
+
+ ### DELETE /bot/offensive-messages/<id:int>
+ Delete the offensive message object with the given `id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a offensive message object with the given `id` does not exist
+
+ ## Authentication
+ Requires an API token.
+ """
+
+ serializer_class = OffensiveMessageSerializer
+ queryset = OffensiveMessage.objects.all()
diff --git a/pydis_site/apps/api/viewsets/bot/reminder.py b/pydis_site/apps/api/viewsets/bot/reminder.py
index 147f6dbc..111660d9 100644
--- a/pydis_site/apps/api/viewsets/bot/reminder.py
+++ b/pydis_site/apps/api/viewsets/bot/reminder.py
@@ -4,6 +4,7 @@ from rest_framework.mixins import (
CreateModelMixin,
DestroyModelMixin,
ListModelMixin,
+ RetrieveModelMixin,
UpdateModelMixin
)
from rest_framework.viewsets import GenericViewSet
@@ -13,7 +14,12 @@ from pydis_site.apps.api.serializers import ReminderSerializer
class ReminderViewSet(
- CreateModelMixin, ListModelMixin, DestroyModelMixin, UpdateModelMixin, GenericViewSet
+ CreateModelMixin,
+ RetrieveModelMixin,
+ ListModelMixin,
+ DestroyModelMixin,
+ UpdateModelMixin,
+ GenericViewSet,
):
"""
View providing CRUD access to reminders.
@@ -27,9 +33,16 @@ class ReminderViewSet(
... {
... 'active': True,
... 'author': 1020103901030,
+ ... 'mentions': [
+ ... 336843820513755157,
+ ... 165023948638126080,
+ ... 267628507062992896
+ ... ],
... 'content': "Make dinner",
... 'expiration': '5018-11-20T15:52:00Z',
- ... 'id': 11
+ ... 'id': 11,
+ ... 'channel_id': 634547009956872193,
+ ... 'jump_url': "https://discord.com/channels/<guild_id>/<channel_id>/<message_id>"
... },
... ...
... ]
@@ -37,14 +50,41 @@ class ReminderViewSet(
#### Status codes
- 200: returned on success
+ ### GET /bot/reminders/<id:int>
+ Fetches the reminder with the given id.
+
+ #### Response format
+ >>>
+ ... {
+ ... 'active': True,
+ ... 'author': 1020103901030,
+ ... 'mentions': [
+ ... 336843820513755157,
+ ... 165023948638126080,
+ ... 267628507062992896
+ ... ],
+ ... 'content': "Make dinner",
+ ... 'expiration': '5018-11-20T15:52:00Z',
+ ... 'id': 11,
+ ... 'channel_id': 634547009956872193,
+ ... 'jump_url': "https://discord.com/channels/<guild_id>/<channel_id>/<message_id>"
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned when the reminder doesn't exist
+
### POST /bot/reminders
Create a new reminder.
#### Request body
>>> {
... 'author': int,
+ ... 'mentions': List[int],
... 'content': str,
- ... 'expiration': str # ISO-formatted datetime
+ ... 'expiration': str, # ISO-formatted datetime
+ ... 'channel_id': int,
+ ... 'jump_url': str
... }
#### Status codes
@@ -52,6 +92,22 @@ class ReminderViewSet(
- 400: if the body format is invalid
- 404: if no user with the given ID could be found
+ ### PATCH /bot/reminders/<id:int>
+ Update the user with the given `id`.
+ All fields in the request body are optional.
+
+ #### Request body
+ >>> {
+ ... 'mentions': List[int],
+ ... 'content': str,
+ ... 'expiration': str # ISO-formatted datetime
+ ... }
+
+ #### Status codes
+ - 200: 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`.
diff --git a/pydis_site/apps/api/viewsets/bot/tag.py b/pydis_site/apps/api/viewsets/bot/tag.py
deleted file mode 100644
index 7e9ba117..00000000
--- a/pydis_site/apps/api/viewsets/bot/tag.py
+++ /dev/null
@@ -1,105 +0,0 @@
-from rest_framework.viewsets import ModelViewSet
-
-from pydis_site.apps.api.models.bot.tag import Tag
-from pydis_site.apps.api.serializers import TagSerializer
-
-
-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()
diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py
index a407787e..9571b3d7 100644
--- a/pydis_site/apps/api/viewsets/bot/user.py
+++ b/pydis_site/apps/api/viewsets/bot/user.py
@@ -17,7 +17,6 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
>>> [
... {
... 'id': 409107086526644234,
- ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb",
... 'name': "Python",
... 'discriminator': 4329,
... 'roles': [
@@ -39,7 +38,6 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
#### Response format
>>> {
... 'id': 409107086526644234,
- ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb",
... 'name': "Python",
... 'discriminator': 4329,
... 'roles': [
@@ -62,7 +60,6 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
#### Request body
>>> {
... 'id': int,
- ... 'avatar': str,
... 'name': str,
... 'discriminator': int,
... 'roles': List[int],
@@ -83,7 +80,6 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
#### Request body
>>> {
... 'id': int,
- ... 'avatar': str,
... 'name': str,
... 'discriminator': int,
... 'roles': List[int],
@@ -102,7 +98,6 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
#### Request body
>>> {
... 'id': int,
- ... 'avatar': str,
... 'name': str,
... 'discriminator': int,
... 'roles': List[int],
@@ -123,4 +118,4 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):
"""
serializer_class = UserSerializer
- queryset = User.objects.prefetch_related('roles')
+ queryset = User.objects
diff --git a/pydis_site/apps/home/forms/__init__.py b/pydis_site/apps/home/forms/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/pydis_site/apps/home/forms/__init__.py
diff --git a/pydis_site/apps/home/forms/account_deletion.py b/pydis_site/apps/home/forms/account_deletion.py
new file mode 100644
index 00000000..eec70bea
--- /dev/null
+++ b/pydis_site/apps/home/forms/account_deletion.py
@@ -0,0 +1,10 @@
+from django.forms import CharField, Form
+
+
+class AccountDeletionForm(Form):
+ """Account deletion form, to collect username for confirmation of removal."""
+
+ username = CharField(
+ label="Username",
+ required=True
+ )
diff --git a/pydis_site/apps/home/signals.py b/pydis_site/apps/home/signals.py
index 9f286882..8af48c15 100644
--- a/pydis_site/apps/home/signals.py
+++ b/pydis_site/apps/home/signals.py
@@ -1,3 +1,4 @@
+from contextlib import suppress
from typing import List, Optional, Type
from allauth.account.signals import user_logged_in
@@ -8,7 +9,7 @@ from allauth.socialaccount.signals import (
pre_social_login, social_account_added, social_account_removed,
social_account_updated)
from django.contrib.auth.models import Group, User as DjangoUser
-from django.db.models.signals import post_save, pre_delete, pre_save
+from django.db.models.signals import post_delete, post_save, pre_save
from pydis_site.apps.api.models import User as DiscordUser
from pydis_site.apps.staff.models import RoleMapping
@@ -37,7 +38,7 @@ class AllauthSignalListener:
def __init__(self):
post_save.connect(self.user_model_updated, sender=DiscordUser)
- pre_delete.connect(self.mapping_model_deleted, sender=RoleMapping)
+ post_delete.connect(self.mapping_model_deleted, sender=RoleMapping)
pre_save.connect(self.mapping_model_updated, sender=RoleMapping)
pre_social_login.connect(self.social_account_updated)
@@ -133,13 +134,29 @@ class AllauthSignalListener:
Processes deletion signals from the RoleMapping model, removing perms from users.
We need to do this to ensure that users aren't left with permissions groups that
- they shouldn't have assigned to them when a RoleMapping is deleted from the database.
+ they shouldn't have assigned to them when a RoleMapping is deleted from the database,
+ and to remove their staff status if they should no longer have it.
"""
instance: RoleMapping = kwargs["instance"]
for user in instance.group.user_set.all():
+ # Firstly, remove their related user group
user.groups.remove(instance.group)
+ with suppress(SocialAccount.DoesNotExist, DiscordUser.DoesNotExist):
+ # If we get either exception, then the user could not have been assigned staff
+ # with our system in the first place.
+
+ social_account = SocialAccount.objects.get(user=user, provider=DiscordProvider.id)
+ discord_user = DiscordUser.objects.get(id=int(social_account.uid))
+
+ mappings = RoleMapping.objects.filter(role__id__in=discord_user.roles).all()
+ is_staff = any(m.is_staff for m in mappings)
+
+ if user.is_staff != is_staff:
+ user.is_staff = is_staff
+ user.save(update_fields=("is_staff", ))
+
def mapping_model_updated(self, sender: Type[RoleMapping], **kwargs) -> None:
"""
Processes update signals from the RoleMapping model.
@@ -168,12 +185,27 @@ class AllauthSignalListener:
self.mapping_model_deleted(RoleMapping, instance=old_instance)
accounts = SocialAccount.objects.filter(
- uid__in=(u.id for u in instance.role.user_set.all())
+ uid__in=(u.id for u in DiscordUser.objects.filter(roles__contains=[instance.role.id]))
)
for account in accounts:
account.user.groups.add(instance.group)
+ if instance.is_staff and not account.user.is_staff:
+ account.user.is_staff = instance.is_staff
+ account.user.save(update_fields=("is_staff", ))
+ else:
+ discord_user = DiscordUser.objects.get(id=int(account.uid))
+
+ mappings = RoleMapping.objects.filter(
+ role__id__in=discord_user.roles
+ ).exclude(id=instance.id).all()
+ is_staff = any(m.is_staff for m in mappings)
+
+ if account.user.is_staff != is_staff:
+ account.user.is_staff = is_staff
+ account.user.save(update_fields=("is_staff",))
+
def user_model_updated(self, sender: Type[DiscordUser], **kwargs) -> None:
"""
Processes update signals from the Discord User model, assigning perms as required.
@@ -230,31 +262,53 @@ class AllauthSignalListener:
except SocialAccount.user.RelatedObjectDoesNotExist:
return # There's no user account yet, this will be handled by another receiver
+ # Ensure that the username on this account is correct
+ new_username = f"{user.name}#{user.discriminator}"
+
+ if account.user.username != new_username:
+ account.user.username = new_username
+ account.user.first_name = new_username
+
if not user.in_guild:
deletion = True
if deletion:
# They've unlinked Discord or left the server, so we have to remove their groups
+ # and their staff status
- if not current_groups:
- return # They have no groups anyway, no point in processing
+ if current_groups:
+ # They do have groups, so let's remove them
+ account.user.groups.remove(
+ *(mapping.group for mapping in mappings)
+ )
- account.user.groups.remove(
- *(mapping.group for mapping in mappings)
- )
+ if account.user.is_staff:
+ # They're marked as a staff user and they shouldn't be, so let's fix that
+ account.user.is_staff = False
else:
new_groups = []
+ is_staff = False
- for role in user.roles.all():
+ for role in user.roles:
try:
- new_groups.append(mappings.get(role=role).group)
+ mapping = mappings.get(role__id=role)
except RoleMapping.DoesNotExist:
continue # No mapping exists
- account.user.groups.add(
- *[group for group in new_groups if group not in current_groups]
- )
+ new_groups.append(mapping.group)
- account.user.groups.remove(
- *[mapping.group for mapping in mappings if mapping.group not in new_groups]
- )
+ if mapping.is_staff:
+ is_staff = True
+
+ account.user.groups.add(
+ *[group for group in new_groups if group not in current_groups]
+ )
+
+ account.user.groups.remove(
+ *[mapping.group for mapping in mappings if mapping.group not in new_groups]
+ )
+
+ if account.user.is_staff != is_staff:
+ account.user.is_staff = is_staff
+
+ account.user.save()
diff --git a/pydis_site/apps/home/tests/mock_github_api_response.json b/pydis_site/apps/home/tests/mock_github_api_response.json
index 37dc672e..10be4f99 100644
--- a/pydis_site/apps/home/tests/mock_github_api_response.json
+++ b/pydis_site/apps/home/tests/mock_github_api_response.json
@@ -28,7 +28,7 @@
"forks_count": 31
},
{
- "full_name": "python-discord/django-crispy-bulma",
+ "full_name": "python-discord/metricity",
"description": "test",
"stargazers_count": 97,
"language": "Python",
diff --git a/pydis_site/apps/home/tests/test_repodata_helpers.py b/pydis_site/apps/home/tests/test_repodata_helpers.py
index 71bd4f2d..77b1a68d 100644
--- a/pydis_site/apps/home/tests/test_repodata_helpers.py
+++ b/pydis_site/apps/home/tests/test_repodata_helpers.py
@@ -10,7 +10,7 @@ from pydis_site.apps.home.models import RepositoryMetadata
from pydis_site.apps.home.views import HomeView
-def mocked_requests_get(*args, **kwargs) -> "MockResponse": # noqa
+def mocked_requests_get(*args, **kwargs) -> "MockResponse": # noqa: F821
"""A mock version of requests.get, so we don't need to call the API every time we run a test."""
class MockResponse:
def __init__(self, json_data, status_code):
diff --git a/pydis_site/apps/home/tests/test_signal_listener.py b/pydis_site/apps/home/tests/test_signal_listener.py
index 27fc7710..d99d81a5 100644
--- a/pydis_site/apps/home/tests/test_signal_listener.py
+++ b/pydis_site/apps/home/tests/test_signal_listener.py
@@ -67,36 +67,35 @@ class SignalListenerTests(TestCase):
cls.admin_mapping = RoleMapping.objects.create(
role=cls.admin_role,
- group=cls.admin_group
+ group=cls.admin_group,
+ is_staff=True
)
cls.moderator_mapping = RoleMapping.objects.create(
role=cls.moderator_role,
- group=cls.moderator_group
+ group=cls.moderator_group,
+ is_staff=False
)
cls.discord_user = DiscordUser.objects.create(
id=0,
name="user",
discriminator=0,
- avatar_hash=None
)
cls.discord_unmapped = DiscordUser.objects.create(
id=2,
name="unmapped",
discriminator=0,
- avatar_hash=None
)
- cls.discord_unmapped.roles.add(cls.unmapped_role)
+ cls.discord_unmapped.roles.append(cls.unmapped_role.id)
cls.discord_unmapped.save()
cls.discord_not_in_guild = DiscordUser.objects.create(
id=3,
name="not-in-guild",
discriminator=0,
- avatar_hash=None,
in_guild=False
)
@@ -104,20 +103,18 @@ class SignalListenerTests(TestCase):
id=1,
name="admin",
discriminator=0,
- avatar_hash=None
)
- cls.discord_admin.roles.set([cls.admin_role])
+ cls.discord_admin.roles = [cls.admin_role.id]
cls.discord_admin.save()
cls.discord_moderator = DiscordUser.objects.create(
id=4,
name="admin",
discriminator=0,
- avatar_hash=None
)
- cls.discord_moderator.roles.set([cls.moderator_role])
+ cls.discord_moderator.roles = [cls.moderator_role.id]
cls.discord_moderator.save()
cls.django_user_discordless = DjangoUser.objects.create(username="no-discord")
@@ -166,7 +163,7 @@ class SignalListenerTests(TestCase):
cls.django_moderator = DjangoUser.objects.create(
username="moderator",
- is_staff=True,
+ is_staff=False,
is_superuser=False
)
@@ -336,9 +333,36 @@ class SignalListenerTests(TestCase):
handler._apply_groups(self.discord_admin, self.social_admin)
self.assertEqual(self.django_user_discordless.groups.all().count(), 0)
- self.discord_admin.roles.add(self.admin_role)
+ self.discord_admin.roles.append(self.admin_role.id)
self.discord_admin.save()
+ def test_apply_groups_moderator(self):
+ """Test application of groups by role, relating to a non-`is_staff` moderator user."""
+ handler = AllauthSignalListener()
+
+ self.assertEqual(self.django_user_discordless.groups.all().count(), 0)
+
+ # Apply groups based on moderator role being present on Discord
+ handler._apply_groups(self.discord_moderator, self.social_moderator)
+ self.assertTrue(self.moderator_group in self.django_moderator.groups.all())
+
+ # Remove groups based on the user apparently leaving the server
+ handler._apply_groups(self.discord_moderator, self.social_moderator, True)
+ self.assertEqual(self.django_user_discordless.groups.all().count(), 0)
+
+ # Apply the moderator role again
+ handler._apply_groups(self.discord_moderator, self.social_moderator)
+
+ # Remove all of the roles from the user
+ self.discord_moderator.roles.clear()
+
+ # Remove groups based on the user no longer having the moderator role on Discord
+ handler._apply_groups(self.discord_moderator, self.social_moderator)
+ self.assertEqual(self.django_user_discordless.groups.all().count(), 0)
+
+ self.discord_moderator.roles.append(self.moderator_role.id)
+ self.discord_moderator.save()
+
def test_apply_groups_other(self):
"""Test application of groups by role, relating to non-standard cases."""
handler = AllauthSignalListener()
@@ -373,10 +397,25 @@ class SignalListenerTests(TestCase):
self.assertEqual(self.django_moderator.groups.all().count(), 1)
self.assertEqual(self.django_admin.groups.all().count(), 1)
+ # Test is_staff changes
+ self.admin_mapping.is_staff = False
+ self.admin_mapping.save()
+
+ self.assertFalse(self.django_moderator.is_staff)
+ self.assertFalse(self.django_admin.is_staff)
+
+ self.admin_mapping.is_staff = True
+ self.admin_mapping.save()
+
+ self.django_admin.refresh_from_db(fields=("is_staff", ))
+ self.assertTrue(self.django_admin.is_staff)
+
# Test mapping deletion
self.admin_mapping.delete()
+ self.django_admin.refresh_from_db(fields=("is_staff",))
self.assertEqual(self.django_admin.groups.all().count(), 0)
+ self.assertFalse(self.django_admin.is_staff)
# Test mapping update
self.moderator_mapping.group = self.admin_group
@@ -388,12 +427,30 @@ class SignalListenerTests(TestCase):
# Test mapping creation
new_mapping = RoleMapping.objects.create(
role=self.admin_role,
- group=self.moderator_group
+ group=self.moderator_group,
+ is_staff=True
+ )
+
+ self.assertEqual(self.django_admin.groups.all().count(), 1)
+ self.assertTrue(self.moderator_group in self.django_admin.groups.all())
+
+ self.django_admin.refresh_from_db(fields=("is_staff",))
+ self.assertTrue(self.django_admin.is_staff)
+
+ new_mapping.delete()
+
+ # Test mapping creation (without is_staff)
+ new_mapping = RoleMapping.objects.create(
+ role=self.admin_role,
+ group=self.moderator_group,
)
self.assertEqual(self.django_admin.groups.all().count(), 1)
self.assertTrue(self.moderator_group in self.django_admin.groups.all())
+ self.django_admin.refresh_from_db(fields=("is_staff",))
+ self.assertFalse(self.django_admin.is_staff)
+
# Test that nothing happens when fixtures are loaded
pre_save.send(RoleMapping, instance=new_mapping, raw=True)
diff --git a/pydis_site/apps/home/tests/test_views.py b/pydis_site/apps/home/tests/test_views.py
index 7aeaddd2..572317a7 100644
--- a/pydis_site/apps/home/tests/test_views.py
+++ b/pydis_site/apps/home/tests/test_views.py
@@ -1,5 +1,198 @@
+from allauth.socialaccount.models import SocialAccount
+from django.contrib.auth.models import User
+from django.http import HttpResponseRedirect
from django.test import TestCase
-from django_hosts.resolvers import reverse
+from django_hosts.resolvers import get_host, reverse, reverse_host
+
+
+def check_redirect_url(
+ response: HttpResponseRedirect, reversed_url: str, strip_params=True
+) -> bool:
+ """
+ Check whether a given redirect response matches a specific reversed URL.
+
+ Arguments:
+ * `response`: The HttpResponseRedirect returned by the test client
+ * `reversed_url`: The URL returned by `reverse()`
+ * `strip_params`: Whether to strip URL parameters (following a "?") from the URL given in the
+ `response` object
+ """
+ host = get_host(None)
+ hostname = reverse_host(host)
+
+ redirect_url = response.url
+
+ if strip_params and "?" in redirect_url:
+ redirect_url = redirect_url.split("?", 1)[0]
+
+ result = reversed_url == f"//{hostname}{redirect_url}"
+ return result
+
+
+class TestAccountDeleteView(TestCase):
+ def setUp(self) -> None:
+ """Create an authorized Django user for testing purposes."""
+ self.user = User.objects.create(
+ username="user#0000"
+ )
+
+ def test_redirect_when_logged_out(self):
+ """Test that the user is redirected to the homepage when not logged in."""
+ url = reverse("account_delete")
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, reverse("home")))
+
+ def test_get_when_logged_in(self):
+ """Test that the view returns a HTTP 200 when the user is logged in."""
+ url = reverse("account_delete")
+
+ self.client.force_login(self.user)
+ resp = self.client.get(url)
+ self.client.logout()
+
+ self.assertEqual(resp.status_code, 200)
+
+ def test_post_invalid(self):
+ """Test that the user is redirected when the form is filled out incorrectly."""
+ url = reverse("account_delete")
+
+ self.client.force_login(self.user)
+
+ resp = self.client.post(url, {})
+ self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, url))
+
+ resp = self.client.post(url, {"username": "user"})
+ self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, url))
+
+ self.client.logout()
+
+ def test_post_valid(self):
+ """Test that the account is deleted when the form is filled out correctly.."""
+ url = reverse("account_delete")
+
+ self.client.force_login(self.user)
+
+ resp = self.client.post(url, {"username": "user#0000"})
+ self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, reverse("home")))
+
+ with self.assertRaises(User.DoesNotExist):
+ User.objects.get(username=self.user.username)
+
+ self.client.logout()
+
+
+class TestAccountSettingsView(TestCase):
+ def setUp(self) -> None:
+ """Create an authorized Django user for testing purposes."""
+ self.user = User.objects.create(
+ username="user#0000"
+ )
+
+ self.user_unlinked = User.objects.create(
+ username="user#9999"
+ )
+
+ self.user_unlinked_discord = User.objects.create(
+ username="user#1234"
+ )
+
+ self.user_unlinked_github = User.objects.create(
+ username="user#1111"
+ )
+
+ self.github_account = SocialAccount.objects.create(
+ user=self.user,
+ provider="github",
+ uid="0"
+ )
+
+ self.discord_account = SocialAccount.objects.create(
+ user=self.user,
+ provider="discord",
+ uid="0000"
+ )
+
+ self.github_account_secondary = SocialAccount.objects.create(
+ user=self.user_unlinked_discord,
+ provider="github",
+ uid="1"
+ )
+
+ self.discord_account_secondary = SocialAccount.objects.create(
+ user=self.user_unlinked_github,
+ provider="discord",
+ uid="1111"
+ )
+
+ def test_redirect_when_logged_out(self):
+ """Check that the user is redirected to the homepage when not logged in."""
+ url = reverse("account_settings")
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, reverse("home")))
+
+ def test_get_when_logged_in(self):
+ """Test that the view returns a HTTP 200 when the user is logged in."""
+ url = reverse("account_settings")
+
+ self.client.force_login(self.user)
+ resp = self.client.get(url)
+ self.client.logout()
+
+ self.assertEqual(resp.status_code, 200)
+
+ self.client.force_login(self.user_unlinked)
+ resp = self.client.get(url)
+ self.client.logout()
+
+ self.assertEqual(resp.status_code, 200)
+
+ self.client.force_login(self.user_unlinked_discord)
+ resp = self.client.get(url)
+ self.client.logout()
+
+ self.assertEqual(resp.status_code, 200)
+
+ self.client.force_login(self.user_unlinked_github)
+ resp = self.client.get(url)
+ self.client.logout()
+
+ self.assertEqual(resp.status_code, 200)
+
+ def test_post_invalid(self):
+ """Test the behaviour of invalid POST submissions."""
+ url = reverse("account_settings")
+
+ self.client.force_login(self.user_unlinked)
+
+ resp = self.client.post(url, {"provider": "discord"})
+ self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, reverse("home")))
+
+ resp = self.client.post(url, {"provider": "github"})
+ self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, reverse("home")))
+
+ self.client.logout()
+
+ def test_post_valid(self):
+ """Ensure that GitHub is unlinked with a valid POST submission."""
+ url = reverse("account_settings")
+
+ self.client.force_login(self.user)
+
+ resp = self.client.post(url, {"provider": "github"})
+ self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, reverse("home")))
+
+ with self.assertRaises(SocialAccount.DoesNotExist):
+ SocialAccount.objects.get(user=self.user, provider="github")
+
+ self.client.logout()
class TestIndexReturns200(TestCase):
@@ -16,6 +209,7 @@ class TestLoginCancelledReturns302(TestCase):
url = reverse('socialaccount_login_cancelled')
resp = self.client.get(url)
self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, reverse("home")))
class TestLoginErrorReturns302(TestCase):
@@ -24,3 +218,4 @@ class TestLoginErrorReturns302(TestCase):
url = reverse('socialaccount_login_error')
resp = self.client.get(url)
self.assertEqual(resp.status_code, 302)
+ self.assertTrue(check_redirect_url(resp, reverse("home")))
diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py
index 211a7ad1..61e87a39 100644
--- a/pydis_site/apps/home/urls.py
+++ b/pydis_site/apps/home/urls.py
@@ -1,5 +1,4 @@
from allauth.account.views import LogoutView
-from allauth.socialaccount.views import ConnectionsView
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
@@ -7,14 +6,18 @@ from django.contrib.messages import ERROR
from django.urls import include, path
from pydis_site.utils.views import MessageRedirectView
-from .views import HomeView
+from .views import AccountDeleteView, AccountSettingsView, HomeView
app_name = 'home'
urlpatterns = [
+ # We do this twice because Allauth expects specific view names to exist
path('', HomeView.as_view(), name='home'),
+ path('', HomeView.as_view(), name='socialaccount_connections'),
+
path('pages/', include('wiki.urls')),
path('accounts/', include('allauth.socialaccount.providers.discord.urls')),
+ path('accounts/', include('allauth.socialaccount.providers.github.urls')),
path(
'accounts/login/cancelled', MessageRedirectView.as_view(
@@ -28,7 +31,9 @@ urlpatterns = [
), name='socialaccount_login_error'
),
- path('connections', ConnectionsView.as_view()),
+ path('accounts/settings', AccountSettingsView.as_view(), name="account_settings"),
+ path('accounts/delete', AccountDeleteView.as_view(), name="account_delete"),
+
path('logout', LogoutView.as_view(), name="logout"),
path('admin/', admin.site.urls),
diff --git a/pydis_site/apps/home/views/__init__.py b/pydis_site/apps/home/views/__init__.py
index 971d73a3..801fd398 100644
--- a/pydis_site/apps/home/views/__init__.py
+++ b/pydis_site/apps/home/views/__init__.py
@@ -1,3 +1,4 @@
+from .account import DeleteView as AccountDeleteView, SettingsView as AccountSettingsView
from .home import HomeView
-__all__ = ["HomeView"]
+__all__ = ["AccountDeleteView", "AccountSettingsView", "HomeView"]
diff --git a/pydis_site/apps/home/views/account/__init__.py b/pydis_site/apps/home/views/account/__init__.py
new file mode 100644
index 00000000..3b3250ea
--- /dev/null
+++ b/pydis_site/apps/home/views/account/__init__.py
@@ -0,0 +1,4 @@
+from .delete import DeleteView
+from .settings import SettingsView
+
+__all__ = ["DeleteView", "SettingsView"]
diff --git a/pydis_site/apps/home/views/account/delete.py b/pydis_site/apps/home/views/account/delete.py
new file mode 100644
index 00000000..798b8a33
--- /dev/null
+++ b/pydis_site/apps/home/views/account/delete.py
@@ -0,0 +1,37 @@
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.messages import ERROR, INFO, add_message
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect, render
+from django.urls import reverse
+from django.views import View
+
+from pydis_site.apps.home.forms.account_deletion import AccountDeletionForm
+
+
+class DeleteView(LoginRequiredMixin, View):
+ """Account deletion view, for removing linked user accounts from the DB."""
+
+ def __init__(self, *args, **kwargs):
+ self.login_url = reverse("home")
+ super().__init__(*args, **kwargs)
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ """HTTP GET: Return the view template."""
+ return render(
+ request, "home/account/delete.html",
+ context={"form": AccountDeletionForm()}
+ )
+
+ def post(self, request: HttpRequest) -> HttpResponse:
+ """HTTP POST: Process the deletion, as requested by the user."""
+ form = AccountDeletionForm(request.POST)
+
+ if not form.is_valid() or request.user.username != form.cleaned_data["username"]:
+ add_message(request, ERROR, "Please enter your username exactly as shown.")
+
+ return redirect(reverse("account_delete"))
+
+ request.user.delete()
+ add_message(request, INFO, "Your account has been deleted.")
+
+ return redirect(reverse("home"))
diff --git a/pydis_site/apps/home/views/account/settings.py b/pydis_site/apps/home/views/account/settings.py
new file mode 100644
index 00000000..3a817dbc
--- /dev/null
+++ b/pydis_site/apps/home/views/account/settings.py
@@ -0,0 +1,59 @@
+from allauth.socialaccount.models import SocialAccount
+from allauth.socialaccount.providers import registry
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib.messages import ERROR, INFO, add_message
+from django.http import HttpRequest, HttpResponse
+from django.shortcuts import redirect, render
+from django.urls import reverse
+from django.views import View
+
+
+class SettingsView(LoginRequiredMixin, View):
+ """
+ Account settings view, for managing and deleting user accounts and connections.
+
+ This view actually renders a template with a bare modal, and is intended to be
+ inserted into another template using JavaScript.
+ """
+
+ def __init__(self, *args, **kwargs):
+ self.login_url = reverse("home")
+ super().__init__(*args, **kwargs)
+
+ def get(self, request: HttpRequest) -> HttpResponse:
+ """HTTP GET: Return the view template."""
+ context = {
+ "groups": request.user.groups.all(),
+
+ "discord": None,
+ "github": None,
+
+ "discord_provider": registry.provider_map.get("discord"),
+ "github_provider": registry.provider_map.get("github"),
+ }
+
+ for account in SocialAccount.objects.filter(user=request.user).all():
+ if account.provider == "discord":
+ context["discord"] = account
+
+ if account.provider == "github":
+ context["github"] = account
+
+ return render(request, "home/account/settings.html", context=context)
+
+ def post(self, request: HttpRequest) -> HttpResponse:
+ """HTTP POST: Process account disconnections."""
+ provider = request.POST["provider"]
+
+ if provider == "github":
+ try:
+ account = SocialAccount.objects.get(user=request.user, provider=provider)
+ except SocialAccount.DoesNotExist:
+ add_message(request, ERROR, "You do not have a GitHub account linked.")
+ else:
+ account.delete()
+ add_message(request, INFO, "The social account has been disconnected.")
+ else:
+ add_message(request, ERROR, f"Unknown provider: {provider}")
+
+ return redirect(reverse("home"))
diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py
index 4cf22594..3b5cd5ac 100644
--- a/pydis_site/apps/home/views/home.py
+++ b/pydis_site/apps/home/views/home.py
@@ -23,8 +23,8 @@ class HomeView(View):
"python-discord/bot",
"python-discord/snekbox",
"python-discord/seasonalbot",
+ "python-discord/metricity",
"python-discord/django-simple-bulma",
- "python-discord/django-crispy-bulma",
]
def _get_api_data(self) -> Dict[str, Dict[str, str]]:
@@ -61,7 +61,7 @@ class HomeView(View):
# Try to get new data from the API. If it fails, return the cached data.
try:
api_repositories = self._get_api_data()
- except TypeError:
+ except (TypeError, ConnectionError):
return RepositoryMetadata.objects.all()
database_repositories = []
diff --git a/pydis_site/apps/staff/migrations/0002_add_is_staff_to_role_mappings.py b/pydis_site/apps/staff/migrations/0002_add_is_staff_to_role_mappings.py
new file mode 100644
index 00000000..0404d270
--- /dev/null
+++ b/pydis_site/apps/staff/migrations/0002_add_is_staff_to_role_mappings.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.6 on 2019-10-20 14:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('staff', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='rolemapping',
+ name='is_staff',
+ field=models.BooleanField(default=False, help_text='Whether this role mapping relates to a Django staff group'),
+ ),
+ ]
diff --git a/pydis_site/apps/staff/models/role_mapping.py b/pydis_site/apps/staff/models/role_mapping.py
index 10c09cf1..8a1fac2e 100644
--- a/pydis_site/apps/staff/models/role_mapping.py
+++ b/pydis_site/apps/staff/models/role_mapping.py
@@ -21,6 +21,11 @@ class RoleMapping(models.Model):
unique=True, # Unique in order to simplify group assignment logic
)
+ is_staff = models.BooleanField(
+ help_text="Whether this role mapping relates to a Django staff group",
+ default=False
+ )
+
def __str__(self):
"""Returns the mapping, for display purposes."""
return f"@{self.role.name} -> {self.group.name}"
diff --git a/pydis_site/apps/staff/templatetags/deletedmessage_filters.py b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py
index f950870f..8e14ced6 100644
--- a/pydis_site/apps/staff/templatetags/deletedmessage_filters.py
+++ b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py
@@ -7,11 +7,22 @@ register = template.Library()
@register.filter
def hex_colour(color: int) -> str:
- """Converts an integer representation of a colour to the RGB hex value."""
- return f"#{color:0>6X}"
+ """
+ Converts an integer representation of a colour to the RGB hex value.
+
+ As we are using a Discord dark theme analogue, black colours are returned as white instead.
+ """
+ colour = f"#{color:0>6X}"
+ return colour if colour != "#000000" else "#FFFFFF"
@register.filter
def footer_datetime(timestamp: str) -> datetime:
"""Takes an embed timestamp and returns a timezone-aware datetime object."""
return datetime.fromisoformat(timestamp)
+
+
+def visible_newlines(text: str) -> str:
+ """Takes an embed timestamp and returns a timezone-aware datetime object."""
+ return text.replace("\n", " <span class='has-text-grey'>↵</span><br>")
diff --git a/pydis_site/apps/staff/tests/test_deletedmessage_filters.py b/pydis_site/apps/staff/tests/test_deletedmessage_filters.py
index d9179044..31215784 100644
--- a/pydis_site/apps/staff/tests/test_deletedmessage_filters.py
+++ b/pydis_site/apps/staff/tests/test_deletedmessage_filters.py
@@ -18,16 +18,49 @@ class Colour(enum.IntEnum):
class DeletedMessageFilterTests(TestCase):
def test_hex_colour_filter(self):
- self.assertEqual(deletedmessage_filters.hex_colour(Colour.BLACK), "#000000")
- self.assertEqual(deletedmessage_filters.hex_colour(Colour.BLUE), "#0000FF")
- self.assertEqual(deletedmessage_filters.hex_colour(Colour.GREEN), "#00FF00")
- self.assertEqual(deletedmessage_filters.hex_colour(Colour.RED), "#FF0000")
- self.assertEqual(deletedmessage_filters.hex_colour(Colour.WHITE), "#FFFFFF")
+ """The filter should produce the correct hex values from the integer representations."""
+ test_values = (
+ (Colour.BLUE, "#0000FF"),
+ (Colour.GREEN, "#00FF00"),
+ (Colour.RED, "#FF0000"),
+ (Colour.WHITE, "#FFFFFF"),
+
+ # Since we're using a "Discord dark theme"-like front-end, show black text as white.
+ (Colour.BLACK, "#FFFFFF"),
+ )
+
+ for colour, hex_value in test_values:
+ with self.subTest(colour=colour, hex_value=hex_value):
+ self.assertEqual(deletedmessage_filters.hex_colour(colour), hex_value)
def test_footer_datetime_filter(self):
+ """The filter should parse the ISO-datetime string and return a timezone-aware datetime."""
datetime_aware = timezone.now()
iso_string = datetime_aware.isoformat()
datetime_returned = deletedmessage_filters.footer_datetime(iso_string)
self.assertTrue(timezone.is_aware(datetime_returned))
self.assertEqual(datetime_aware, datetime_returned)
+
+ def test_visual_newlines_filter(self):
+ """The filter should replace newline characters by newline character and html linebreak."""
+ html_br = " <span class='has-text-grey'>↵</span><br>"
+
+ test_values = (
+ (
+ "Hello, this line does not contain a linebreak",
+ "Hello, this line does not contain a linebreak"
+ ),
+ (
+ "A single linebreak\nin a string",
+ f"A single linebreak{html_br}in a string"
+ ),
+ (
+ "Consecutive linebreaks\n\n\nwork, too",
+ f"Consecutive linebreaks{html_br}{html_br}{html_br}work, too"
+ )
+ )
+
+ for input_, expected_output in test_values:
+ with self.subTest(input=input_, expected_output=expected_output):
+ self.assertEqual(deletedmessage_filters.visible_newlines(input_), expected_output)
diff --git a/pydis_site/apps/staff/tests/test_logs_view.py b/pydis_site/apps/staff/tests/test_logs_view.py
index 5036363b..00e0ab2f 100644
--- a/pydis_site/apps/staff/tests/test_logs_view.py
+++ b/pydis_site/apps/staff/tests/test_logs_view.py
@@ -21,10 +21,9 @@ class TestLogsView(TestCase):
id=12345678901,
name='Alan Turing',
discriminator=1912,
- avatar_hash=None
)
- cls.author.roles.add(cls.developers_role)
+ cls.author.roles.append(cls.developers_role.id)
cls.deletion_context = MessageDeletionContext.objects.create(
actor=cls.actor,
@@ -37,6 +36,7 @@ class TestLogsView(TestCase):
channel_id=1984,
content='<em>I think my tape has run out...</em>',
embeds=[],
+ attachments=[],
deletion_context=cls.deletion_context,
)
@@ -101,6 +101,7 @@ class TestLogsView(TestCase):
channel_id=1984,
content='Does that mean this thing will halt?',
embeds=[cls.embed_one, cls.embed_two],
+ attachments=['https://http.cat/100', 'https://http.cat/402'],
deletion_context=cls.deletion_context,
)
@@ -149,6 +150,21 @@ class TestLogsView(TestCase):
self.assertInHTML(embed_colour_needle.format(colour=embed_one_colour), html_response)
self.assertInHTML(embed_colour_needle.format(colour=embed_two_colour), html_response)
+ def test_if_both_attachments_are_included_html_response(self):
+ url = reverse('logs', host="staff", args=(self.deletion_context.id,))
+ response = self.client.get(url)
+
+ html_response = response.content.decode()
+ attachment_needle = '<img alt="Attachment" class="discord-attachment" src="{url}">'
+ self.assertInHTML(
+ attachment_needle.format(url=self.deleted_message_two.attachments[0]),
+ html_response
+ )
+ self.assertInHTML(
+ attachment_needle.format(url=self.deleted_message_two.attachments[1]),
+ html_response
+ )
+
def test_if_html_in_content_is_properly_escaped(self):
url = reverse('logs', host="staff", args=(self.deletion_context.id,))
response = self.client.get(url)
diff --git a/pydis_site/constants.py b/pydis_site/constants.py
new file mode 100644
index 00000000..0b76694a
--- /dev/null
+++ b/pydis_site/constants.py
@@ -0,0 +1,5 @@
+import git
+
+# Git SHA
+repo = git.Repo(search_parent_directories=True)
+GIT_SHA = repo.head.object.hexsha
diff --git a/pydis_site/context_processors.py b/pydis_site/context_processors.py
new file mode 100644
index 00000000..6937a3db
--- /dev/null
+++ b/pydis_site/context_processors.py
@@ -0,0 +1,8 @@
+from django.template import RequestContext
+
+from pydis_site.constants import GIT_SHA
+
+
+def git_sha_processor(_: RequestContext) -> dict:
+ """Expose the git SHA for this repo to all views."""
+ return {'git_sha': GIT_SHA}
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index 56ac0a1d..1f042c1b 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -16,14 +16,26 @@ import sys
import typing
import environ
+import sentry_sdk
from django.contrib.messages import constants as messages
+from sentry_sdk.integrations.django import DjangoIntegration
+
+from pydis_site.constants import GIT_SHA
if typing.TYPE_CHECKING:
from django.contrib.auth.models import User
from wiki.models import Article
env = environ.Env(
- DEBUG=(bool, False)
+ DEBUG=(bool, False),
+ SITE_SENTRY_DSN=(str, "")
+)
+
+sentry_sdk.init(
+ dsn=env('SITE_SENTRY_DSN'),
+ integrations=[DjangoIntegration()],
+ send_default_pii=True,
+ release=f"pydis-site@{GIT_SHA}"
)
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
@@ -42,13 +54,15 @@ if DEBUG:
'api.pythondiscord.local',
'admin.pythondiscord.local',
'staff.pythondiscord.local',
+ '0.0.0.0', # noqa: S104
+ 'localhost',
'web',
'api.web',
'admin.web',
'staff.web'
]
)
- SECRET_KEY = secrets.token_urlsafe(32)
+ SECRET_KEY = "yellow polkadot bikini" # noqa: S105
elif 'CI' in os.environ:
ALLOWED_HOSTS = ['*']
@@ -92,9 +106,8 @@ INSTALLED_APPS = [
'allauth.socialaccount',
'allauth.socialaccount.providers.discord',
+ 'allauth.socialaccount.providers.github',
- 'crispy_forms',
- 'django_crispy_bulma',
'django_hosts',
'django_filters',
'django_nyt.apps.DjangoNytConfig',
@@ -146,8 +159,8 @@ TEMPLATES = [
'django.template.context_processors.static',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
-
"sekizai.context_processors.sekizai",
+ "pydis_site.context_processors.git_sha_processor"
],
},
},
@@ -196,7 +209,7 @@ STATICFILES_DIRS = [os.path.join(BASE_DIR, 'pydis_site', 'static')]
STATIC_ROOT = env('STATIC_ROOT', default='/app/staticfiles')
MEDIA_URL = '/media/'
-MEDIA_ROOT = env('MEDIA_ROOT', default='/app/media')
+MEDIA_ROOT = env('MEDIA_ROOT', default='/site/media')
STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder',
@@ -277,7 +290,6 @@ LOGGING = {
}
# Django Messages framework config
-
MESSAGE_TAGS = {
messages.DEBUG: 'primary',
messages.INFO: 'info',
@@ -286,30 +298,19 @@ MESSAGE_TAGS = {
messages.ERROR: 'danger',
}
-# 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": { # If you update these colours, please update the notification.css file
"primary": "#7289DA", # Discord blurple
- "orange": "#ffb39b", # Bulma default, but at a saturation of 100
- "yellow": "#ffea9b", # Bulma default, but at a saturation of 100
- "green": "#7fd19c", # Bulma default, but at a saturation of 100
- "turquoise": "#7289DA", # Blurple, because Bulma uses this as the default primary
- "cyan": "#91cbee", # Bulma default, but at a saturation of 100
- "blue": "#86a7dc", # Bulma default, but at a saturation of 100
- "purple": "#b86bff", # Bulma default, but at a saturation of 100
- "red": "#ffafc2", # Bulma default, but at a saturation of 80
+ # "orange": "", # Apparently unused, but the default is fine
+ # "yellow": "", # The default yellow looks pretty good
+ "green": "#32ac66", # Colour picked after Discord discussion
+ "turquoise": "#7289DA", # Blurple, because Bulma uses this regardless of `primary` above
+ "blue": "#2482c1", # Colour picked after Discord discussion
+ "cyan": "#2482c1", # Colour picked after Discord discussion (matches the blue)
+ "purple": "#aa55e4", # Apparently unused, but changed for consistency
+ "red": "#d63852", # Colour picked after Discord discussion
"link": "$primary",
@@ -359,24 +360,7 @@ WIKI_MESSAGE_TAG_CSS_CLASS = {
messages.WARNING: "is-warning",
}
-WIKI_MARKDOWN_HTML_STYLES = [
- 'max-width',
- 'min-width',
- 'margin',
- 'padding',
- 'width',
- 'height',
-]
-
-WIKI_MARKDOWN_HTML_ATTRIBUTES = {
- 'img': ['class', 'id', 'src', 'alt', 'width', 'height'],
- 'section': ['class', 'id'],
- 'article': ['class', 'id'],
-}
-
-WIKI_MARKDOWN_HTML_WHITELIST = [
- 'article', 'section', 'button'
-]
+WIKI_MARKDOWN_SANITIZE_HTML = False
# Wiki permissions
@@ -407,5 +391,13 @@ AUTHENTICATION_BACKENDS = (
'allauth.account.auth_backends.AuthenticationBackend',
)
+ACCOUNT_ADAPTER = "pydis_site.utils.account.AccountAdapter"
+ACCOUNT_EMAIL_REQUIRED = False # Undocumented allauth setting; don't require emails
ACCOUNT_EMAIL_VERIFICATION = "none" # No verification required; we don't use emails for anything
+
+# We use this validator because Allauth won't let us actually supply a list with no validators
+# in it, and we can't just give it a lambda - that'd be too easy, I suppose.
+ACCOUNT_USERNAME_VALIDATORS = "pydis_site.VALIDATORS"
+
LOGIN_REDIRECT_URL = "home"
+SOCIALACCOUNT_ADAPTER = "pydis_site.utils.account.SocialAccountAdapter"
diff --git a/pydis_site/static/css/base/base.css b/pydis_site/static/css/base/base.css
index 3ca6b2a7..dc7c504d 100644
--- a/pydis_site/static/css/base/base.css
+++ b/pydis_site/static/css/base/base.css
@@ -84,7 +84,30 @@ div.card.has-equal-height {
/* Fix for logout form submit button in navbar */
-button.is-size-navbar-menu {
+button.is-size-navbar-menu, a.is-size-navbar-menu {
font-size: 14px;
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
}
+@media screen and (min-width: 1088px) {
+ button.is-size-navbar-menu, a.is-size-navbar-menu {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+}
+
+/* Fix for modals being behind the navbar */
+
+.modal * {
+ z-index: 1020;
+}
+
+.modal-background {
+ z-index: 1010;
+}
+
+/* Wiki style tweaks */
+.codehilite-wrap {
+ margin-bottom: 1em;
+}
diff --git a/pydis_site/static/css/home/index.css b/pydis_site/static/css/home/index.css
index 4c36031b..ba856a8e 100644
--- a/pydis_site/static/css/home/index.css
+++ b/pydis_site/static/css/home/index.css
@@ -85,4 +85,3 @@ span.repo-language-dot.javascript {
max-width: none;
}
}
-
diff --git a/pydis_site/static/css/staff/logs.css b/pydis_site/static/css/staff/logs.css
index d7bb04cf..acf4f1f7 100644
--- a/pydis_site/static/css/staff/logs.css
+++ b/pydis_site/static/css/staff/logs.css
@@ -39,7 +39,6 @@ main.site-content {
}
.discord-message-metadata {
- color: hsla(0, 0%, 100%, .2);
font-size: 0.75rem;
font-weight: 400;
margin: 0 .3rem;
diff --git a/pydis_site/static/favicons/safari-pinned-tab.svg b/pydis_site/static/favicons/safari-pinned-tab.svg
index 3a7ec4a7..32064879 100644
--- a/pydis_site/static/favicons/safari-pinned-tab.svg
+++ b/pydis_site/static/favicons/safari-pinned-tab.svg
@@ -1 +1 @@
-<svg version="1" xmlns="http://www.w3.org/2000/svg" width="933.333" height="933.333" viewBox="0 0 700.000000 700.000000"><path d="M321.8.5c-.1.1-5.1.6-10.9.9-5.8.4-11.5.8-12.5 1-1 .2-5.5.7-9.9 1.1-4.4.4-8.9.9-10 1-31.2 5.2-44.4 8.9-60.5 16.7-13.5 6.7-22.9 15.5-28.2 26.8-3 6.3-5.4 13.2-4.8 14.2.1.2-.3 1.5-.8 2.9-1.8 4.6-2.5 19.7-2.6 56.1l-.1 35.8 3 .1c1.6 0 39.3.1 83.8.1H349l.1 2.9c0 1.6.1 5.8.1 9.4 0 3.6-.1 7.1-.1 7.8-.1 1-24.3 1.2-118.3 1.3-65.1 0-121.4.3-125.3.7-22.8 2.2-45.8 13.2-62.1 29.5-18.7 18.8-28.9 42.9-36 84.7-1.9 11.8-2.4 14.6-3 19.5-.3 3-.8 7.1-1.1 9-.9 6.9-1 40.7-.2 49 1.6 16.2 4.8 36.3 7.4 47 .8 3 1.6 6.6 1.8 8 2 10.6 7.7 28.1 12.8 38.8C38 492 56 508 81.8 515.2c7.2 2 10.1 2.2 43.2 2.2l35.5.1.2-42.5c.1-23.4.4-44.1.7-46 5.8-39.1 34.7-73.8 71.6-85.8 16.7-5.4 16.2-5.4 119-5.7 50.9-.1 94.1-.6 96-1 7.7-1.5 20.6-6.6 28.1-11.1 18.5-11.2 31.9-29.6 38.1-52.1 2.1-7.7 2.1-9.2 2.3-97.5.2-93.5.1-95.2-4.1-107.6-7.7-22.9-29.1-44.1-54.7-54.4-6.8-2.7-17.3-5.8-21.7-6.3-1.9-.3-5.7-.9-8.5-1.5-2.7-.5-7.2-1.2-10-1.6-2.7-.3-6.1-.7-7.5-.9-1.4-.2-6.1-.7-10.5-1.1-4.4-.4-9.3-.9-11-1.1-3.6-.5-66.2-1.3-66.7-.8zm-59.1 51.9c6.3 1.7 10.7 4.3 14.9 8.8 9.2 9.8 11.4 23.5 5.8 35.5-3.1 6.5-8 11.4-14.9 15.1-4.2 2.2-6.3 2.7-13 2.7s-8.9-.4-13.5-2.7c-9.4-4.6-14.9-11.1-17.3-20.8-4.4-17.1 6.4-34.9 23.7-39.1 4.9-1.1 8.7-1 14.3.5z"/><path d="M538.6 178.9c-.3.4-.6 19.5-.7 42.3-.1 36.1-.4 42.7-2 50.4-3.3 16.1-8.7 28.6-17.7 41.3-6.9 9.7-11.8 14.9-21.2 22.2-4.1 3.3-8 6.3-8.6 6.8-.6.5-5.8 3.2-11.5 5.9-10.7 5.2-21.3 8.5-30.9 9.8-3 .4-46.9.8-97.5.9-50.6.1-93.6.5-95.5.9-22.7 4.5-40 15.1-53.5 32.6-7.9 10.3-14.8 25.5-16 35.7-.4 2.6-.9 5.6-1.1 6.6-1.1 3.7-.5 174.1.6 180.2 1.4 7.7 1.2 7 3.9 14 10.2 27 40.7 48.6 85.7 60.6 12.3 3.3 12.2 3.2 23.4 5.5 25.9 5.3 63.4 6.6 89 3 29.7-4.1 58.5-12.3 79.5-22.6 29.8-14.5 46.8-34.3 51.3-59.5.4-2.2.7-20.4.7-40.5v-36.4l-83.5-.1h-83.5v-9.8c0-5.7.4-10.1 1-10.5.5-.4 59-.7 130-.8 71 0 131-.4 133.4-.8 20.3-3.4 35.2-13.4 47.8-32.4 5.8-8.6 14.4-26.8 18.8-39.5 1.3-4 3.2-9.2 4.1-11.7 4.9-13.7 10.4-40.1 12-57.5.8-9 .8-34.1 0-42.5-1.3-14.1-1.8-17.9-2.6-23-.5-3-1.1-7.1-1.4-9-.8-5.2-4.3-22.3-6.6-32-1.1-4.7-2.3-9.4-2.5-10.5-3.1-13.5-13.8-38-20.9-47.8-6.8-9.6-19.3-21.7-22.4-21.7-.6 0-1.2-.4-1.4-.8-.7-1.9-13.6-6.7-23.3-8.8-4.3-.9-76.4-1.4-76.9-.5zm-81.2 405.7c6.8 3.3 12.5 9.3 15.4 16.4 3.2 7.7 2.6 19.6-1.3 26.9-5.8 10.6-15.5 16.4-27.5 16.5-9 .1-16.7-3-22.5-9.1-9.1-9.3-11.6-24-6.3-35.5 7.5-16.2 26.3-23 42.2-15.2z"/></svg> \ No newline at end of file
+<svg version="1" xmlns="http://www.w3.org/2000/svg" width="933.333" height="933.333" viewBox="0 0 700.000000 700.000000"><path d="M321.8.5c-.1.1-5.1.6-10.9.9-5.8.4-11.5.8-12.5 1-1 .2-5.5.7-9.9 1.1-4.4.4-8.9.9-10 1-31.2 5.2-44.4 8.9-60.5 16.7-13.5 6.7-22.9 15.5-28.2 26.8-3 6.3-5.4 13.2-4.8 14.2.1.2-.3 1.5-.8 2.9-1.8 4.6-2.5 19.7-2.6 56.1l-.1 35.8 3 .1c1.6 0 39.3.1 83.8.1H349l.1 2.9c0 1.6.1 5.8.1 9.4 0 3.6-.1 7.1-.1 7.8-.1 1-24.3 1.2-118.3 1.3-65.1 0-121.4.3-125.3.7-22.8 2.2-45.8 13.2-62.1 29.5-18.7 18.8-28.9 42.9-36 84.7-1.9 11.8-2.4 14.6-3 19.5-.3 3-.8 7.1-1.1 9-.9 6.9-1 40.7-.2 49 1.6 16.2 4.8 36.3 7.4 47 .8 3 1.6 6.6 1.8 8 2 10.6 7.7 28.1 12.8 38.8C38 492 56 508 81.8 515.2c7.2 2 10.1 2.2 43.2 2.2l35.5.1.2-42.5c.1-23.4.4-44.1.7-46 5.8-39.1 34.7-73.8 71.6-85.8 16.7-5.4 16.2-5.4 119-5.7 50.9-.1 94.1-.6 96-1 7.7-1.5 20.6-6.6 28.1-11.1 18.5-11.2 31.9-29.6 38.1-52.1 2.1-7.7 2.1-9.2 2.3-97.5.2-93.5.1-95.2-4.1-107.6-7.7-22.9-29.1-44.1-54.7-54.4-6.8-2.7-17.3-5.8-21.7-6.3-1.9-.3-5.7-.9-8.5-1.5-2.7-.5-7.2-1.2-10-1.6-2.7-.3-6.1-.7-7.5-.9-1.4-.2-6.1-.7-10.5-1.1-4.4-.4-9.3-.9-11-1.1-3.6-.5-66.2-1.3-66.7-.8zm-59.1 51.9c6.3 1.7 10.7 4.3 14.9 8.8 9.2 9.8 11.4 23.5 5.8 35.5-3.1 6.5-8 11.4-14.9 15.1-4.2 2.2-6.3 2.7-13 2.7s-8.9-.4-13.5-2.7c-9.4-4.6-14.9-11.1-17.3-20.8-4.4-17.1 6.4-34.9 23.7-39.1 4.9-1.1 8.7-1 14.3.5z"/><path d="M538.6 178.9c-.3.4-.6 19.5-.7 42.3-.1 36.1-.4 42.7-2 50.4-3.3 16.1-8.7 28.6-17.7 41.3-6.9 9.7-11.8 14.9-21.2 22.2-4.1 3.3-8 6.3-8.6 6.8-.6.5-5.8 3.2-11.5 5.9-10.7 5.2-21.3 8.5-30.9 9.8-3 .4-46.9.8-97.5.9-50.6.1-93.6.5-95.5.9-22.7 4.5-40 15.1-53.5 32.6-7.9 10.3-14.8 25.5-16 35.7-.4 2.6-.9 5.6-1.1 6.6-1.1 3.7-.5 174.1.6 180.2 1.4 7.7 1.2 7 3.9 14 10.2 27 40.7 48.6 85.7 60.6 12.3 3.3 12.2 3.2 23.4 5.5 25.9 5.3 63.4 6.6 89 3 29.7-4.1 58.5-12.3 79.5-22.6 29.8-14.5 46.8-34.3 51.3-59.5.4-2.2.7-20.4.7-40.5v-36.4l-83.5-.1h-83.5v-9.8c0-5.7.4-10.1 1-10.5.5-.4 59-.7 130-.8 71 0 131-.4 133.4-.8 20.3-3.4 35.2-13.4 47.8-32.4 5.8-8.6 14.4-26.8 18.8-39.5 1.3-4 3.2-9.2 4.1-11.7 4.9-13.7 10.4-40.1 12-57.5.8-9 .8-34.1 0-42.5-1.3-14.1-1.8-17.9-2.6-23-.5-3-1.1-7.1-1.4-9-.8-5.2-4.3-22.3-6.6-32-1.1-4.7-2.3-9.4-2.5-10.5-3.1-13.5-13.8-38-20.9-47.8-6.8-9.6-19.3-21.7-22.4-21.7-.6 0-1.2-.4-1.4-.8-.7-1.9-13.6-6.7-23.3-8.8-4.3-.9-76.4-1.4-76.9-.5zm-81.2 405.7c6.8 3.3 12.5 9.3 15.4 16.4 3.2 7.7 2.6 19.6-1.3 26.9-5.8 10.6-15.5 16.4-27.5 16.5-9 .1-16.7-3-22.5-9.1-9.1-9.3-11.6-24-6.3-35.5 7.5-16.2 26.3-23 42.2-15.2z"/></svg>
diff --git a/pydis_site/static/images/events/summer_code_jam_2020.png b/pydis_site/static/images/events/summer_code_jam_2020.png
new file mode 100644
index 00000000..63c311b0
--- /dev/null
+++ b/pydis_site/static/images/events/summer_code_jam_2020.png
Binary files differ
diff --git a/pydis_site/static/images/sponsors/adafruit.png b/pydis_site/static/images/sponsors/adafruit.png
index 27cd9953..eb14cf5d 100644
--- a/pydis_site/static/images/sponsors/adafruit.png
+++ b/pydis_site/static/images/sponsors/adafruit.png
Binary files differ
diff --git a/pydis_site/static/images/sponsors/jetbrains.png b/pydis_site/static/images/sponsors/jetbrains.png
index 0b21c2c8..b79e110a 100644
--- a/pydis_site/static/images/sponsors/jetbrains.png
+++ b/pydis_site/static/images/sponsors/jetbrains.png
Binary files differ
diff --git a/pydis_site/static/images/sponsors/sentry.png b/pydis_site/static/images/sponsors/sentry.png
new file mode 100644
index 00000000..ce185da2
--- /dev/null
+++ b/pydis_site/static/images/sponsors/sentry.png
Binary files differ
diff --git a/pydis_site/static/js/base/modal.js b/pydis_site/static/js/base/modal.js
new file mode 100644
index 00000000..eccc8845
--- /dev/null
+++ b/pydis_site/static/js/base/modal.js
@@ -0,0 +1,100 @@
+/*
+ modal.js: A simple way to wire up Bulma modals.
+
+ This library is intended to be used with Bulma's modals, as described in the
+ official Bulma documentation. It's based on the JavaScript that Bulma
+ themselves use for this purpose on the modals documentation page.
+
+ Note that, just like that piece of JavaScript, this library assumes that
+ you will only ever want to have one modal open at once.
+ */
+
+"use strict";
+
+// Event handler for the "esc" key, for closing modals.
+
+document.addEventListener("keydown", (event) => {
+ const e = event || window.event;
+
+ if (e.code === "Escape" || e.keyCode === 27) {
+ closeModals();
+ }
+});
+
+// An array of all the modal buttons we've already set up
+
+const modal_buttons = [];
+
+// Public API functions
+
+function setupModal(target) {
+ // Set up a modal's events, given a DOM element. This can be
+ // used later in order to set up a modal that was added after
+ // this library has been run.
+
+ // We need to collect a bunch of elements to work with
+ const modal_background = Array.from(target.getElementsByClassName("modal-background"));
+ const modal_close = Array.from(target.getElementsByClassName("modal-close"));
+
+ const modal_head = Array.from(target.getElementsByClassName("modal-card-head"));
+ const modal_foot = Array.from(target.getElementsByClassName("modal-card-foot"));
+
+ const modal_delete = [];
+ const modal_button = [];
+
+ modal_head.forEach((element) => modal_delete.concat(Array.from(element.getElementsByClassName("delete"))));
+ modal_foot.forEach((element) => modal_button.concat(Array.from(element.getElementsByClassName("button"))));
+
+ // Collect all the elements that can be used to close modals
+ const modal_closers = modal_background.concat(modal_close).concat(modal_delete).concat(modal_button);
+
+ // Assign click events for closing modals
+ modal_closers.forEach((element) => {
+ element.addEventListener("click", () => {
+ closeModals();
+ });
+ });
+
+ setupOpeningButtons();
+}
+
+function setupOpeningButtons() {
+ // Wire up all the opening buttons, avoiding buttons we've already wired up.
+ const modal_opening_buttons = Array.from(document.getElementsByClassName("modal-button"));
+
+ modal_opening_buttons.forEach((element) => {
+ if (!modal_buttons.includes(element)) {
+ element.addEventListener("click", () => {
+ openModal(element.dataset.target);
+ });
+
+ modal_buttons.push(element);
+ }
+ });
+}
+
+function openModal(target) {
+ // Open a modal, given a string ID
+ const element = document.getElementById(target);
+
+ document.documentElement.classList.add("is-clipped");
+ element.classList.add("is-active");
+}
+
+function closeModals() {
+ // Close all open modals
+ const modals = Array.from(document.getElementsByClassName("modal"));
+ document.documentElement.classList.remove("is-clipped");
+
+ modals.forEach((element) => {
+ element.classList.remove("is-active");
+ });
+}
+
+(function () {
+ // Set up all the modals currently on the page
+ const modals = Array.from(document.getElementsByClassName("modal"));
+
+ modals.forEach((modal) => setupModal(modal));
+ setupOpeningButtons();
+}());
diff --git a/pydis_site/templates/base/base.html b/pydis_site/templates/base/base.html
index a9b31c0f..70426dc1 100644
--- a/pydis_site/templates/base/base.html
+++ b/pydis_site/templates/base/base.html
@@ -28,6 +28,7 @@
{# Font-awesome here is defined explicitly so that we can have Pro #}
<script src="https://kit.fontawesome.com/ae6a3152d8.js"></script>
+ <script src="{% static "js/base/modal.js" %}"></script>
<link rel="stylesheet" href="{% static "css/base/base.css" %}">
<link rel="stylesheet" href="{% static "css/base/notification.css" %}">
@@ -36,6 +37,7 @@
{% render_block "css" %}
</head>
<body class="site">
+ <!-- Git hash for this release: {{ git_sha }} -->
<main class="site-content">
{% if messages %}
diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html
index 8cdac0de..c2915025 100644
--- a/pydis_site/templates/base/navbar.html
+++ b/pydis_site/templates/base/navbar.html
@@ -63,9 +63,9 @@
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="{% url 'wiki:get' path="resources/" %}">
- Learning Resources
+ Resources
</a>
- <a class="navbar-item" href="{% url 'wiki:get' path="tools/" %}">
+ <a class="navbar-item" href="{% url 'wiki:get' path="resources/tools/" %}">
Tools
</a>
<a class="navbar-item" href="{% url 'wiki:get' path="contributing/" %}">
@@ -84,8 +84,14 @@
Privacy
</a>
<hr class="navbar-divider">
- <a class="navbar-item" href="{% url 'wiki:get' path="code-jams/" %}">
- Code Jams
+ <div class="navbar-item">
+ <strong>Events</strong>
+ </div>
+ <a class="navbar-item" href="{% url 'wiki:get' path="code-jams/code-jam-7/" %}">
+ Most Recent: Code Jam 7
+ </a>
+ <a class="navbar-item" href="{% url 'wiki:get' path="events/" %}">
+ All events
</a>
<hr class="navbar-divider">
@@ -102,7 +108,15 @@
{% else %}
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
- <button type="submit" class="navbar-item button is-white is-inline is-fullwidth has-text-left is-size-navbar-menu has-text-grey-dark">Logout</button>
+
+ <div class="field navbar-item is-paddingless is-fullwidth is-grouped">
+ <button type="submit" class="button is-white is-inline is-fullwidth has-text-left is-size-navbar-menu has-text-grey-dark">Logout</button>
+ <a title="Settings" class="button is-white is-inline has-text-right is-size-navbar-menu has-text-grey-dark modal-button" data-target="account-modal">
+ <span class="is-icon">
+ <i class="fas fa-cog"></i>
+ </span>
+ </a>
+ </div>
</form>
{% endif %}
@@ -116,3 +130,24 @@
</a>
</div>
</nav>
+
+{% if user.is_authenticated %}
+ <script defer type="text/javascript">
+ // Script which loads and sets up the account settings modal.
+ // This script must be placed in a template, or rewritten to take the fetch
+ // URL as a function argument, in order to be used.
+
+ "use strict";
+
+ // Create and prepend a new div for this modal
+ let element = document.createElement("div");
+ document.body.prepend(element);
+
+ fetch("{% url "account_settings" %}") // Fetch the URL
+ .then((response) => response.text()) // Read in the data stream as text
+ .then((text) => {
+ element.outerHTML = text; // Replace the div's HTML with the loaded modal HTML
+ setupModal(document.getElementById("account-modal")); // Set up the modal
+ });
+ </script>
+{% endif %}
diff --git a/pydis_site/templates/home/account/delete.html b/pydis_site/templates/home/account/delete.html
new file mode 100644
index 00000000..0d44e32a
--- /dev/null
+++ b/pydis_site/templates/home/account/delete.html
@@ -0,0 +1,47 @@
+{% extends 'base/base.html' %}
+{% load static %}
+
+{% block title %}Delete Account{% endblock %}
+
+{% block content %}
+ {% include "base/navbar.html" %}
+
+ <section class="section content">
+ <div class="container">
+ <h2 class="is-size-2 has-text-centered">Account Deletion</h2>
+
+ <div class="columns is-centered">
+ <div class="column is-half-desktop is-full-tablet is-full-mobile">
+
+ <article class="message is-danger">
+ <div class="message-body">
+ <p>
+ You have requested to delete the account with username
+ <strong><span class="has-text-dark is-family-monospace">{{ user.username }}</span></strong>.
+ </p>
+
+ <p>
+ Please note that this <strong>cannot be undone</strong>.
+ </p>
+
+ <p>
+ To verify that you'd like to remove your account, please type your username into the box below.
+ </p>
+ </div>
+ </article>
+ </div>
+ </div>
+
+ <div class="columns is-centered">
+ <div class="column is-half-desktop is-full-tablet is-full-mobile">
+ <form method="post">
+ {% csrf_token %}
+ <label for="id_username" class="label requiredField">Username</label>
+ <input id="id_username" class="input" type="text" required name="username">
+ <input style="margin-top: 1em;" type="submit" value="I understand, delete my account" class="button is-primary">
+ </form>
+ </div>
+ </div>
+ </div>
+ </section>
+{% endblock %}
diff --git a/pydis_site/templates/home/account/settings.html b/pydis_site/templates/home/account/settings.html
new file mode 100644
index 00000000..ed59b052
--- /dev/null
+++ b/pydis_site/templates/home/account/settings.html
@@ -0,0 +1,136 @@
+{% load socialaccount %}
+
+{# This template is just for a modal, which is actually inserted into the navbar #}
+{# template. Take a look at `navbar.html` to see how it's inserted. #}
+
+<div class="modal" id="account-modal">
+ <div class="modal-background"></div>
+ <div class="modal-card">
+ <div class="modal-card-head">
+ <div class="modal-card-title">Settings for {{ user.username }}</div>
+
+ {% if groups %}
+ <div>
+ {% for group in groups %}
+ <span class="tag is-primary">{{ group.name }}</span>
+ {% endfor %}
+ </div>
+ {% else %}
+ <span class="tag is-dark">No groups</span>
+ {% endif %}
+ </div>
+ <div class="modal-card-body">
+ <h3 class="title">Connections</h3>
+ <div class="columns">
+ {% if discord_provider is not None %}
+ <div class="column">
+ <div class="box">
+ {% if not discord %}
+ <div class="media">
+ <div class="media-left">
+ <div class="image">
+ <i class="fab fa-discord fa-3x has-text-primary"></i>
+ </div>
+ </div>
+ <div class="media-content">
+ <div class="title is-5">Discord</div>
+ <div class="subtitle is-6">Not connected</div>
+ </div>
+ </div>
+ <div>
+ <br />
+ <a class="button is-primary" href="{% provider_login_url "discord" process="connect" %}">
+ <span class="icon">
+ <i class="fad fa-link"></i>
+ </span>
+ <span>Connect</span>
+ </a>
+ </div>
+ {% else %}
+ <div class="media">
+ <div class="media-left">
+ <div class="image">
+ <i class="fab fa-discord fa-3x has-text-primary"></i>
+ </div>
+ </div>
+ <div class="media-content">
+ <div class="title is-5">Discord</div>
+ <div class="subtitle is-6">{{ user.username }}</div>
+ </div>
+ </div>
+ <div>
+ <br />
+ <button class="button" disabled>
+ <span class="icon">
+ <i class="fas fa-check"></i>
+ </span>
+ <span>Connected</span>
+ </button>
+ </div>
+ {% endif %}
+ </div>
+ </div>
+ {% endif %}
+
+ {% if github_provider is not None %}
+ <div class="column">
+ <div class="box">
+ {% if not github %}
+ <div class="media">
+ <div class="media-left">
+ <div class="image">
+ <i class="fab fa-github fa-3x"></i>
+ </div>
+ </div>
+ <div class="media-content">
+ <div class="title is-5">GitHub</div>
+ <div class="subtitle is-6">Not connected</div>
+ </div>
+ </div>
+ <div>
+ <br />
+ <a class="button is-primary" href="{% provider_login_url "github" process="connect" %}">
+ <span class="icon">
+ <i class="fad fa-link"></i>
+ </span>
+ <span>Connect</span>
+ </a>
+ </div>
+ {% else %}
+ <div class="media">
+ <div class="media-left">
+ <div class="image">
+ <i class="fab fa-github fa-3x"></i>
+ </div>
+ </div>
+ <div class="media-content">
+ <div class="title is-5">GitHub</div>
+ <div class="subtitle is-6">{{ github.extra_data.name }}</div>
+ </div>
+ </div>
+ <div>
+ <form method="post" action="{% url "account_settings" %}" type="submit">
+ {% csrf_token %}
+
+ <input type="hidden" name="provider" value="github" />
+
+ <br />
+ <button type="submit" class="button is-danger">
+ <span class="icon">
+ <i class="fas fa-times"></i>
+ </span>
+ <span>Disconnect</span>
+ </button>
+ </form>
+ </div>
+ {% endif %}
+ </div>
+ </div>
+ {% endif %}
+ </div>
+ </div>
+ <div class="modal-card-foot">
+ <a class="button is-danger" href="{% url "account_delete" %}">Delete Account</a>
+ </div>
+ </div>
+</div>
diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html
index 0fa2f67c..3e96cc91 100644
--- a/pydis_site/templates/home/index.html
+++ b/pydis_site/templates/home/index.html
@@ -16,7 +16,7 @@
<h1 class="is-size-1">Who are we?</h1>
<br>
<div class="columns is-desktop">
- <div class="column is-half-desktop">
+ <div class="column is-half-desktop content">
<p>
We're a large community focused around the Python programming language.
We believe anyone can learn to code, and are very dedicated to helping
@@ -37,11 +37,11 @@
</p>
</div>
- {# Intro video #}
- <div class="column is-half-desktop video-container">
- <iframe src="https://www.youtube.com/embed/DIBXg8Qh7bA" frameborder="0"
- allow="accelerometer; encrypted-media; gyroscope; picture-in-picture"
- allowfullscreen></iframe>
+ {# Right column container #}
+ <div class="column is-half-desktop">
+ <a href="https://pythondiscord.com/pages/code-jams/code-jam-7/">
+ <img src="{% static "images/events/summer_code_jam_2020.png" %}">
+ </a>
</div>
</div>
@@ -92,10 +92,12 @@
<a href="https://adafruit.com" class="column is-narrow">
<img src="{% static "images/sponsors/adafruit.png" %}" alt="Adafruit"/>
</a>
+ <a href="https://sentry.io" class="column is-narrow">
+ <img src="{% static "images/sponsors/sentry.png" %}" alt="Sentry"/>
+ </a>
</div>
</div>
</div>
</section>
{% endblock %}
-
diff --git a/pydis_site/templates/staff/logs.html b/pydis_site/templates/staff/logs.html
index 9c8ed7d3..8c92836a 100644
--- a/pydis_site/templates/staff/logs.html
+++ b/pydis_site/templates/staff/logs.html
@@ -19,10 +19,15 @@
<div class="discord-message-header">
<span class="discord-username"
style="color: {{ message.author.top_role.colour | hex_colour }}">{{ message.author }}</span><span
- class="discord-message-metadata">{{ message.timestamp }} | User ID: {{ message.author.id }}</span>
+ class="discord-message-metadata has-text-grey">{{ message.timestamp }} | User ID: {{ message.author.id }}</span>
</div>
<div class="discord-message-content">
- {{ message.content|linebreaks }}
+ {{ message.content | escape | visible_newlines | safe }}
+ </div>
+ <div class="discord-message-attachments">
+ {% for attachment in message.attachments %}
+ <img alt="Attachment" class="discord-attachment" src="{{ attachment }}">
+ {% endfor %}
</div>
{% for embed in message.embeds %}
<div class="discord-embed is-size-7">
diff --git a/pydis_site/templates/wiki/base.html b/pydis_site/templates/wiki/base.html
index 9f904324..846492ab 100644
--- a/pydis_site/templates/wiki/base.html
+++ b/pydis_site/templates/wiki/base.html
@@ -7,7 +7,7 @@
{% block head %}
{{ block.super }}
- <script src="{% static "wiki/js/jquery-3.3.1.min.js" %}" type="text/javascript"></script>
+ <script src="{% static "wiki/js/jquery-3.4.1.min.js" %}" type="text/javascript"></script>
<script src="{% static "wiki/js/core.js" %}" type="text/javascript"></script>
<script src="{% static "js/wiki/simplemde.min.js" %}" type="text/javascript"></script>
diff --git a/pydis_site/templates/wiki/history.html b/pydis_site/templates/wiki/history.html
index 3788385f..ee297bdd 100644
--- a/pydis_site/templates/wiki/history.html
+++ b/pydis_site/templates/wiki/history.html
@@ -124,5 +124,3 @@
<script src="{% static "js/wiki/modal.js" %}" type="text/javascript"></script>
<script src="{% static "js/wiki/history.js" %}" type="text/javascript"></script>
{% endblock %}
-
-
diff --git a/pydis_site/templates/wiki/includes/breadcrumbs.html b/pydis_site/templates/wiki/includes/breadcrumbs.html
index 791beb90..1b268e11 100644
--- a/pydis_site/templates/wiki/includes/breadcrumbs.html
+++ b/pydis_site/templates/wiki/includes/breadcrumbs.html
@@ -10,13 +10,13 @@
{# Continue, we don't want to show the root element #}
{% else %}
<li>
- <a href="{% url 'wiki:get' path=ancestor.path %}">{{ ancestor.article.current_revision.title|truncatechars:25 }}</a>
+ <a href="{% url 'wiki:get' path=ancestor.path %}">{{ ancestor.article.current_revision.title }}</a>
</li>
{% endif %}
{% endfor %}
<li class="is-active">
- <a href="{% url 'wiki:get' path=article.path %}">{{ article.current_revision.title|truncatechars:25 }}</a>
+ <a href="{% url 'wiki:get' path=article.path %}">{{ article.current_revision.title }}</a>
</li>
</ul>
</nav>
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
new file mode 100644
index 00000000..6f8338b4
--- /dev/null
+++ b/pydis_site/tests/test_utils_account.py
@@ -0,0 +1,139 @@
+from unittest.mock import patch
+
+from allauth.exceptions import ImmediateHttpResponse
+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 RequestFactory, TestCase
+
+from pydis_site.apps.api.models import Role, User as DiscordUser
+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_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",
+ discriminator=0
+ )
+
+ self.discord_user_role = DiscordUser.objects.create(
+ id=1,
+ name="user present",
+ discriminator=0,
+ roles=[everyone_role.id]
+ )
+
+ self.discord_user_two_roles = DiscordUser.objects.create(
+ id=2,
+ name="user with both roles",
+ discriminator=0,
+ roles=[everyone_role.id, developers_role.id]
+ )
+
+ self.request_factory = RequestFactory()
+
+ def test_account_adapter(self):
+ """Test that our Allauth account adapter functions correctly."""
+ adapter = AccountAdapter()
+
+ self.assertFalse(adapter.is_open_for_signup(HttpRequest()))
+
+ def test_social_account_adapter_signup(self):
+ """Test that our Allauth social account adapter correctly handles signups."""
+ adapter = SocialAccountAdapter()
+
+ discord_login = SocialLogin(account=self.discord_account)
+ 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 = self.request_factory.get("/")
+ messages_request._messages = BaseStorage(messages_request)
+
+ with patch("pydis_site.utils.account.reverse") as mock_reverse:
+ with patch("pydis_site.utils.account.redirect") as mock_redirect:
+ with self.assertRaises(ImmediateHttpResponse):
+ adapter.is_open_for_signup(messages_request, github_login)
+
+ with self.assertRaises(ImmediateHttpResponse):
+ adapter.is_open_for_signup(messages_request, discord_login)
+
+ with self.assertRaises(ImmediateHttpResponse):
+ adapter.is_open_for_signup(messages_request, discord_login_role)
+
+ 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)
+
+ def test_social_account_adapter_populate(self):
+ """Test that our Allauth social account adapter correctly handles data population."""
+ adapter = SocialAccountAdapter()
+
+ discord_login = SocialLogin(
+ account=self.discord_account,
+ user=self.django_user
+ )
+ discord_login.account.extra_data["discriminator"] = "0000"
+
+ 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")
+
+ 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")
diff --git a/pydis_site/utils/account.py b/pydis_site/utils/account.py
new file mode 100644
index 00000000..b4e41198
--- /dev/null
+++ b/pydis_site/utils/account.py
@@ -0,0 +1,79 @@
+from typing import Any, Dict
+
+from allauth.account.adapter import DefaultAccountAdapter
+from allauth.exceptions import ImmediateHttpResponse
+from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
+from allauth.socialaccount.models import SocialLogin
+from django.contrib.auth.models import User as DjangoUser
+from django.contrib.messages import ERROR, add_message
+from django.http import HttpRequest
+from django.shortcuts import redirect
+from django.urls import reverse
+
+from pydis_site.apps.api.models import User as DiscordUser
+
+ERROR_CONNECT_DISCORD = ("You must login with Discord before connecting another account. "
+ "Your account details have not been saved.")
+ERROR_JOIN_DISCORD = ("Please join the Discord server and verify that you accept the rules and "
+ "privacy policy.")
+
+
+class AccountAdapter(DefaultAccountAdapter):
+ """An Allauth account adapter that prevents signups via form submission."""
+
+ def is_open_for_signup(self, request: HttpRequest) -> bool:
+ """
+ Checks whether or not the site is open for signups.
+
+ We override this to always return False so that users may never sign up using
+ Allauth's signup form endpoints, to be on the safe side - since we only want users
+ to sign up using their Discord account.
+ """
+ return False
+
+
+class SocialAccountAdapter(DefaultSocialAccountAdapter):
+ """An Allauth SocialAccount adapter that prevents signups via non-Discord connections."""
+
+ def is_open_for_signup(self, request: HttpRequest, social_login: SocialLogin) -> bool:
+ """
+ Checks whether or not the site is open for signups.
+
+ We override this method in order to prevent users from creating a new account using
+ a non-Discord connection, as we require this connection for our users.
+ """
+ if social_login.account.provider != "discord":
+ add_message(request, ERROR, ERROR_CONNECT_DISCORD)
+
+ raise ImmediateHttpResponse(redirect(reverse("home")))
+
+ try:
+ user = DiscordUser.objects.get(id=int(social_login.account.uid))
+ except DiscordUser.DoesNotExist:
+ add_message(request, ERROR, ERROR_JOIN_DISCORD)
+
+ raise ImmediateHttpResponse(redirect(reverse("home")))
+
+ if len(user.roles) <= 1:
+ add_message(request, ERROR, ERROR_JOIN_DISCORD)
+
+ raise ImmediateHttpResponse(redirect(reverse("home")))
+
+ return True
+
+ def populate_user(self, request: HttpRequest,
+ social_login: SocialLogin,
+ data: Dict[str, Any]) -> DjangoUser:
+ """
+ Method used to populate a Django User with data.
+
+ We override this so that the Django user is created with the username#discriminator,
+ instead of just the username, as Django users must have unique usernames. For display
+ purposes, we also set the `name` key, which is used for `first_name` in the database.
+ """
+ if social_login.account.provider == "discord":
+ discriminator = social_login.account.extra_data["discriminator"]
+ data["username"] = f"{data['username']}#{discriminator:0>4}"
+ data["name"] = data["username"]
+
+ return super().populate_user(request, social_login, data)