aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site/apps/api
diff options
context:
space:
mode:
authorGravatar Rohan Reddy Alleti <[email protected]>2023-05-14 23:54:51 +0530
committerGravatar GitHub <[email protected]>2023-05-14 23:54:51 +0530
commita241a397f966a4265935dfd5c92a84fdf95c52c8 (patch)
tree83f6eb572e26e64e6ca18642013abf60f1b23d8b /pydis_site/apps/api
parentUpdate pydis_site/apps/content/resources/guides/python-guides/subclassing_bot.md (diff)
parentMerge pull request #972 from python-discord/fix-psycopg3-compatibility-in-met... (diff)
Merge branch 'main' into subclassing_bot
Diffstat (limited to 'pydis_site/apps/api')
-rw-r--r--pydis_site/apps/api/__init__.py1
-rw-r--r--pydis_site/apps/api/admin.py41
-rw-r--r--pydis_site/apps/api/github_utils.py210
-rw-r--r--pydis_site/apps/api/migrations/0013_specialsnake_image.py3
-rw-r--r--pydis_site/apps/api/migrations/0019_deletedmessage.py2
-rw-r--r--pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py3
-rw-r--r--pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py3
-rw-r--r--pydis_site/apps/api/migrations/0080_add_aoc_tables.py32
-rw-r--r--pydis_site/apps/api/migrations/0081_bumpedthread.py22
-rw-r--r--pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py19
-rw-r--r--pydis_site/apps/api/migrations/0083_remove_embed_validation.py19
-rw-r--r--pydis_site/apps/api/migrations/0084_infraction_last_applied.py26
-rw-r--r--pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py18
-rw-r--r--pydis_site/apps/api/migrations/0086_infraction_jump_url.py18
-rw-r--r--pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py25
-rw-r--r--pydis_site/apps/api/migrations/0088_new_filter_schema.py171
-rw-r--r--pydis_site/apps/api/migrations/0089_unique_constraint_filters.py26
-rw-r--r--pydis_site/apps/api/migrations/0090_unique_filter_list.py102
-rw-r--r--pydis_site/apps/api/migrations/0091_antispam_filter_list.py52
-rw-r--r--pydis_site/apps/api/models/__init__.py4
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py5
-rw-r--r--pydis_site/apps/api/models/bot/aoc_completionist_block.py26
-rw-r--r--pydis_site/apps/api/models/bot/aoc_link.py21
-rw-r--r--pydis_site/apps/api/models/bot/bumped_thread.py22
-rw-r--r--pydis_site/apps/api/models/bot/documentation_link.py8
-rw-r--r--pydis_site/apps/api/models/bot/filter_list.py42
-rw-r--r--pydis_site/apps/api/models/bot/filters.py261
-rw-r--r--pydis_site/apps/api/models/bot/infraction.py33
-rw-r--r--pydis_site/apps/api/models/bot/message.py23
-rw-r--r--pydis_site/apps/api/models/bot/message_deletion_context.py10
-rw-r--r--pydis_site/apps/api/models/bot/metricity.py56
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py14
-rw-r--r--pydis_site/apps/api/models/bot/off_topic_channel_name.py2
-rw-r--r--pydis_site/apps/api/models/bot/role.py10
-rw-r--r--pydis_site/apps/api/models/utils.py172
-rw-r--r--pydis_site/apps/api/pagination.py5
-rw-r--r--pydis_site/apps/api/serializers.py341
-rw-r--r--pydis_site/apps/api/tests/base.py3
-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_bumped_threads.py63
-rw-r--r--pydis_site/apps/api/tests/test_deleted_messages.py11
-rw-r--r--pydis_site/apps/api/tests/test_documentation_links.py2
-rw-r--r--pydis_site/apps/api/tests/test_filterlists.py122
-rw-r--r--pydis_site/apps/api/tests/test_filters.py352
-rw-r--r--pydis_site/apps/api/tests/test_github_utils.py289
-rw-r--r--pydis_site/apps/api/tests/test_infractions.py149
-rw-r--r--pydis_site/apps/api/tests/test_models.py27
-rw-r--r--pydis_site/apps/api/tests/test_nominations.py34
-rw-r--r--pydis_site/apps/api/tests/test_off_topic_channel_names.py2
-rw-r--r--pydis_site/apps/api/tests/test_offensive_message.py10
-rw-r--r--pydis_site/apps/api/tests/test_reminders.py4
-rw-r--r--pydis_site/apps/api/tests/test_roles.py2
-rw-r--r--pydis_site/apps/api/tests/test_rules.py40
-rw-r--r--pydis_site/apps/api/tests/test_users.py109
-rw-r--r--pydis_site/apps/api/tests/test_validators.py233
-rw-r--r--pydis_site/apps/api/urls.py35
-rw-r--r--pydis_site/apps/api/views.py108
-rw-r--r--pydis_site/apps/api/viewsets/__init__.py7
-rw-r--r--pydis_site/apps/api/viewsets/bot/__init__.py8
-rw-r--r--pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py73
-rw-r--r--pydis_site/apps/api/viewsets/bot/aoc_link.py71
-rw-r--r--pydis_site/apps/api/viewsets/bot/bumped_thread.py66
-rw-r--r--pydis_site/apps/api/viewsets/bot/filter_list.py98
-rw-r--r--pydis_site/apps/api/viewsets/bot/filters.py499
-rw-r--r--pydis_site/apps/api/viewsets/bot/infraction.py27
-rw-r--r--pydis_site/apps/api/viewsets/bot/nomination.py23
-rw-r--r--pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py7
-rw-r--r--pydis_site/apps/api/viewsets/bot/reminder.py2
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py64
72 files changed, 3461 insertions, 1661 deletions
diff --git a/pydis_site/apps/api/__init__.py b/pydis_site/apps/api/__init__.py
index afa5b4d5..e69de29b 100644
--- a/pydis_site/apps/api/__init__.py
+++ b/pydis_site/apps/api/__init__.py
@@ -1 +0,0 @@
-default_app_config = 'pydis_site.apps.api.apps.ApiConfig'
diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py
index 2aca38a1..f3cc0405 100644
--- a/pydis_site/apps/api/admin.py
+++ b/pydis_site/apps/api/admin.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import json
-from typing import Iterable, Optional, Tuple
+from collections.abc import Iterable
from django import urls
from django.contrib import admin
@@ -13,6 +13,8 @@ from .models import (
BotSetting,
DeletedMessage,
DocumentationLink,
+ Filter,
+ FilterList,
Infraction,
MessageDeletionContext,
Nomination,
@@ -60,16 +62,16 @@ class InfractionActorFilter(admin.SimpleListFilter):
title = "Actor"
parameter_name = "actor"
- def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]:
+ def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[tuple[int, str]]:
"""Selectable values for viewer to filter by."""
actor_ids = Infraction.objects.order_by().values_list("actor").distinct()
actors = User.objects.filter(id__in=actor_ids)
return ((a.id, a.username) for a in actors)
- def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]:
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None:
"""Query to filter the list of Users against."""
if not self.value():
- return
+ return None
return queryset.filter(actor__id=self.value())
@@ -147,7 +149,7 @@ class DeletedMessageAdmin(admin.ModelAdmin):
list_display = ("id", "author", "channel_id")
- def embed_data(self, message: DeletedMessage) -> Optional[str]:
+ def embed_data(self, message: DeletedMessage) -> str | None:
"""Format embed data in a code block for better readability."""
if message.embeds:
return format_html(
@@ -155,6 +157,7 @@ class DeletedMessageAdmin(admin.ModelAdmin):
"<code>{0}</code></pre>",
json.dumps(message.embeds, indent=4)
)
+ return None
embed_data.short_description = "Embeds"
@@ -194,6 +197,16 @@ class DeletedMessageInline(admin.TabularInline):
model = DeletedMessage
[email protected](FilterList)
+class FilterListAdmin(admin.ModelAdmin):
+ """Admin formatting for the FilterList model."""
+
+
+class FilterAdmin(admin.ModelAdmin):
+ """Admin formatting for the Filter model."""
+
+
@admin.register(MessageDeletionContext)
class MessageDeletionContextAdmin(admin.ModelAdmin):
"""Admin formatting for the MessageDeletionContext model."""
@@ -217,16 +230,16 @@ class NominationActorFilter(admin.SimpleListFilter):
title = "Actor"
parameter_name = "actor"
- def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]:
+ def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[tuple[int, str]]:
"""Selectable values for viewer to filter by."""
actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct()
actors = User.objects.filter(id__in=actor_ids)
return ((a.id, a.username) for a in actors)
- def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]:
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None:
"""Query to filter the list of Users against."""
if not self.value():
- return
+ return None
nomination_ids = NominationEntry.objects.filter(
actor__id=self.value()
).values_list("nomination_id").distinct()
@@ -280,16 +293,16 @@ class NominationEntryActorFilter(admin.SimpleListFilter):
title = "Actor"
parameter_name = "actor"
- def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]:
+ def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[tuple[int, str]]:
"""Selectable values for viewer to filter by."""
actor_ids = NominationEntry.objects.order_by().values_list("actor").distinct()
actors = User.objects.filter(id__in=actor_ids)
return ((a.id, a.username) for a in actors)
- def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]:
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None:
"""Query to filter the list of Users against."""
if not self.value():
- return
+ return None
return queryset.filter(actor__id=self.value())
@@ -413,15 +426,15 @@ class UserRoleFilter(admin.SimpleListFilter):
title = "Role"
parameter_name = "role"
- def lookups(self, request: HttpRequest, model: UserAdmin) -> Iterable[Tuple[str, str]]:
+ def lookups(self, request: HttpRequest, model: UserAdmin) -> Iterable[tuple[str, str]]:
"""Selectable values for viewer to filter by."""
roles = Role.objects.all()
return ((r.name, r.name) for r in roles)
- def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]:
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet | None:
"""Query to filter the list of Users against."""
if not self.value():
- return
+ return None
role = Role.objects.get(name=self.value())
return queryset.filter(roles__contains=[role.id])
diff --git a/pydis_site/apps/api/github_utils.py b/pydis_site/apps/api/github_utils.py
new file mode 100644
index 00000000..af659195
--- /dev/null
+++ b/pydis_site/apps/api/github_utils.py
@@ -0,0 +1,210 @@
+"""Utilities for working with the GitHub API."""
+import dataclasses
+import datetime
+import math
+import typing
+
+import httpx
+import jwt
+
+from pydis_site import settings
+
+MAX_RUN_TIME = datetime.timedelta(minutes=10)
+"""The maximum time allowed before an action is declared timed out."""
+
+
+class ArtifactProcessingError(Exception):
+ """Base exception for other errors related to processing a GitHub artifact."""
+
+ status: int
+
+
+class UnauthorizedError(ArtifactProcessingError):
+ """The application does not have permission to access the requested repo."""
+
+ status = 401
+
+
+class NotFoundError(ArtifactProcessingError):
+ """The requested resource could not be found."""
+
+ status = 404
+
+
+class ActionFailedError(ArtifactProcessingError):
+ """The requested workflow did not conclude successfully."""
+
+ status = 400
+
+
+class RunTimeoutError(ArtifactProcessingError):
+ """The requested workflow run was not ready in time."""
+
+ status = 408
+
+
+class RunPendingError(ArtifactProcessingError):
+ """The requested workflow run is still pending, try again later."""
+
+ status = 202
+
+
[email protected](frozen=True)
+class WorkflowRun:
+ """
+ A workflow run from the GitHub API.
+
+ https://docs.github.com/en/rest/actions/workflow-runs#get-a-workflow-run
+ """
+
+ name: str
+ head_sha: str
+ created_at: str
+ status: str
+ conclusion: str
+ artifacts_url: str
+
+ @classmethod
+ def from_raw(cls, data: dict[str, typing.Any]):
+ """Create an instance using the raw data from the API, discarding unused fields."""
+ return cls(**{
+ key.name: data[key.name] for key in dataclasses.fields(cls)
+ })
+
+
+def generate_token() -> str:
+ """
+ Generate a JWT token to access the GitHub API.
+
+ The token is valid for roughly 10 minutes after generation, before the API starts
+ returning 401s.
+
+ Refer to:
+ https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app
+ """
+ now = datetime.datetime.now(tz=datetime.timezone.utc)
+ return jwt.encode(
+ {
+ "iat": math.floor((now - datetime.timedelta(seconds=60)).timestamp()), # Issued at
+ "exp": math.floor((now + datetime.timedelta(minutes=9)).timestamp()), # Expires at
+ "iss": settings.GITHUB_APP_ID,
+ },
+ settings.GITHUB_APP_KEY,
+ algorithm="RS256"
+ )
+
+
+def authorize(owner: str, repo: str) -> httpx.Client:
+ """
+ Get an access token for the requested repository.
+
+ The process is roughly:
+ - GET app/installations to get a list of all app installations
+ - POST <app_access_token> to get a token to access the given app
+ - GET installation/repositories and check if the requested one is part of those
+ """
+ client = httpx.Client(
+ base_url=settings.GITHUB_API,
+ headers={"Authorization": f"bearer {generate_token()}"},
+ timeout=10,
+ )
+
+ try:
+ # Get a list of app installations we have access to
+ apps = client.get("app/installations")
+ apps.raise_for_status()
+
+ for app in apps.json():
+ # Look for an installation with the right owner
+ if app["account"]["login"] != owner:
+ continue
+
+ # Get the repositories of the specified owner
+ app_token = client.post(app["access_tokens_url"])
+ app_token.raise_for_status()
+ client.headers["Authorization"] = f"bearer {app_token.json()['token']}"
+
+ repos = client.get("installation/repositories")
+ repos.raise_for_status()
+
+ # Search for the request repository
+ for accessible_repo in repos.json()["repositories"]:
+ if accessible_repo["name"] == repo:
+ # We've found the correct repository, and it's accessible with the current auth
+ return client
+
+ raise NotFoundError(
+ "Could not find the requested repository. Make sure the application can access it."
+ )
+
+ except BaseException as e:
+ # Close the client if we encountered an unexpected exception
+ client.close()
+ raise e
+
+
+def check_run_status(run: WorkflowRun) -> str:
+ """Check if the provided run has been completed, otherwise raise an exception."""
+ created_at = (
+ datetime.datetime
+ .strptime(run.created_at, settings.GITHUB_TIMESTAMP_FORMAT)
+ .replace(tzinfo=datetime.timezone.utc)
+ )
+ run_time = datetime.datetime.now(tz=datetime.timezone.utc) - created_at
+
+ if run.status != "completed":
+ if run_time <= MAX_RUN_TIME:
+ raise RunPendingError(
+ f"The requested run is still pending. It was created "
+ f"{run_time.seconds // 60}:{run_time.seconds % 60 :>02} minutes ago."
+ )
+ raise RunTimeoutError("The requested workflow was not ready in time.")
+
+ if run.conclusion != "success":
+ # The action failed, or did not run
+ raise ActionFailedError(f"The requested workflow ended with: {run.conclusion}")
+
+ # The requested action is ready
+ return run.artifacts_url
+
+
+def get_artifact(owner: str, repo: str, sha: str, action_name: str, artifact_name: str) -> str:
+ """Get a download URL for a build artifact."""
+ client = authorize(owner, repo)
+
+ try:
+ # Get the workflow runs for this repository
+ runs = client.get(f"/repos/{owner}/{repo}/actions/runs", params={"per_page": 100})
+ runs.raise_for_status()
+ runs = runs.json()
+
+ # Filter the runs for the one associated with the given SHA
+ for run in runs["workflow_runs"]:
+ run = WorkflowRun.from_raw(run)
+ if run.name == action_name and sha == run.head_sha:
+ break
+ else:
+ raise NotFoundError(
+ "Could not find a run matching the provided settings in the previous hundred runs."
+ )
+
+ # Check the workflow status
+ url = check_run_status(run)
+
+ # Filter the artifacts, and return the download URL
+ artifacts = client.get(url)
+ artifacts.raise_for_status()
+
+ for artifact in artifacts.json()["artifacts"]:
+ if artifact["name"] == artifact_name:
+ data = client.get(artifact["archive_download_url"])
+ if data.status_code == 302:
+ return str(data.next_request.url)
+
+ # The following line is left untested since it should in theory be impossible
+ data.raise_for_status() # pragma: no cover
+
+ raise NotFoundError("Could not find an artifact matching the provided name.")
+
+ finally:
+ client.close()
diff --git a/pydis_site/apps/api/migrations/0013_specialsnake_image.py b/pydis_site/apps/api/migrations/0013_specialsnake_image.py
index a0d0d318..8ba3432f 100644
--- a/pydis_site/apps/api/migrations/0013_specialsnake_image.py
+++ b/pydis_site/apps/api/migrations/0013_specialsnake_image.py
@@ -2,7 +2,6 @@
import datetime
from django.db import migrations, models
-from django.utils.timezone import utc
class Migration(migrations.Migration):
@@ -15,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='specialsnake',
name='image',
- field=models.URLField(default=datetime.datetime(2018, 10, 23, 11, 51, 23, 703868, tzinfo=utc)),
+ field=models.URLField(default=datetime.datetime(2018, 10, 23, 11, 51, 23, 703868, tzinfo=datetime.timezone.utc)),
preserve_default=False,
),
]
diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py
index 6b848d64..25d04434 100644
--- a/pydis_site/apps/api/migrations/0019_deletedmessage.py
+++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py
@@ -18,7 +18,7 @@ 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.utils.validate_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=[]), 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')),
],
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
index 124c6a57..622f21d1 100644
--- a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
+++ b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py
@@ -3,7 +3,6 @@
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):
@@ -16,6 +15,6 @@ class Migration(migrations.Migration):
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),
+ field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[]), blank=True, help_text='Embeds attached to this message.', size=None),
),
]
diff --git a/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py b/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py
index 9e8f2fb9..95ef5850 100644
--- a/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py
+++ b/pydis_site/apps/api/migrations/0077_use_generic_jsonfield.py
@@ -2,7 +2,6 @@
import django.contrib.postgres.fields
from django.db import migrations, models
-import pydis_site.apps.api.models.utils
class Migration(migrations.Migration):
@@ -20,6 +19,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='deletedmessage',
name='embeds',
- field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None),
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(validators=[]), blank=True, help_text='Embeds attached to this message.', size=None),
),
]
diff --git a/pydis_site/apps/api/migrations/0080_add_aoc_tables.py b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
new file mode 100644
index 00000000..2c0c689a
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0080_add_aoc_tables.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.1.14 on 2022-03-06 16:07
+
+from django.db import migrations, models
+import django.db.models.deletion
+import pydis_site.apps.api.models.mixins
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0079_merge_20220125_2022'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='AocAccountLink',
+ fields=[
+ ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='api.user')),
+ ('aoc_username', models.CharField(help_text='The AoC username associated with the Discord User.', max_length=120)),
+ ],
+ bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name='AocCompletionistBlock',
+ fields=[
+ ('user', models.OneToOneField(help_text='The user that is blocked from getting the AoC Completionist Role', on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='api.user')),
+ ('is_blocked', models.BooleanField(default=True, help_text='Whether this user is actively being blocked from getting the AoC Completionist Role', verbose_name='Blocked')),
+ ('reason', models.TextField(help_text='The reason for the AoC Completionist Role Block.', null=True)),
+ ],
+ bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0081_bumpedthread.py b/pydis_site/apps/api/migrations/0081_bumpedthread.py
new file mode 100644
index 00000000..03e66cc1
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0081_bumpedthread.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.1.14 on 2022-02-19 16:26
+
+import django.core.validators
+from django.db import migrations, models
+import pydis_site.apps.api.models.mixins
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0080_add_aoc_tables'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BumpedThread',
+ fields=[
+ ('thread_id', models.BigIntegerField(help_text='The thread ID that should be bumped.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Thread IDs cannot be negative.')], verbose_name='Thread ID')),
+ ],
+ bases=(pydis_site.apps.api.models.mixins.ModelReprMixin, models.Model),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py b/pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py
new file mode 100644
index 00000000..abbb98ec
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0082_otn_allow_big_solidus.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1.14 on 2022-04-21 23:29
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0081_bumpedthread'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='offtopicchannelname',
+ name='name',
+ field=models.CharField(help_text='The actual channel name that will be used on our Discord server.', max_length=96, primary_key=True, serialize=False, validators=[django.core.validators.RegexValidator(regex="^[a-z0-9\\U0001d5a0-\\U0001d5b9-ǃ?’'<>⧹⧸]+$")]),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0083_remove_embed_validation.py b/pydis_site/apps/api/migrations/0083_remove_embed_validation.py
new file mode 100644
index 00000000..e835bb66
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0083_remove_embed_validation.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1.14 on 2022-06-30 09:41
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0082_otn_allow_big_solidus'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='deletedmessage',
+ name='embeds',
+ field=django.contrib.postgres.fields.ArrayField(base_field=models.JSONField(), blank=True, help_text='Embeds attached to this message.', size=None),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0084_infraction_last_applied.py b/pydis_site/apps/api/migrations/0084_infraction_last_applied.py
new file mode 100644
index 00000000..7704ddb8
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0084_infraction_last_applied.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.0.6 on 2022-07-27 20:32
+
+import django.utils.timezone
+from django.db import migrations, models
+from django.apps.registry import Apps
+
+
+def set_last_applied_to_inserted_at(apps: Apps, schema_editor):
+ Infractions = apps.get_model("api", "infraction")
+ Infractions.objects.all().update(last_applied=models.F("inserted_at"))
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0083_remove_embed_validation'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='infraction',
+ name='last_applied',
+ field=models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time of when this infraction was last applied.'),
+ ),
+ migrations.RunPython(set_last_applied_to_inserted_at)
+ ]
diff --git a/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py b/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py
new file mode 100644
index 00000000..56a24cc3
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0085_add_thread_id_to_nominations.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.2 on 2022-11-12 14:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0084_infraction_last_applied'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='nomination',
+ name='thread_id',
+ field=models.BigIntegerField(help_text="The nomination vote's thread id.", null=True),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0086_infraction_jump_url.py b/pydis_site/apps/api/migrations/0086_infraction_jump_url.py
new file mode 100644
index 00000000..7ae65751
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0086_infraction_jump_url.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.1.7 on 2023-03-10 17:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0085_add_thread_id_to_nominations'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='infraction',
+ name='jump_url',
+ field=models.URLField(default=None, help_text='The jump url to message invoking the infraction.', max_length=88, null=True),
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py b/pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py
new file mode 100644
index 00000000..8a826ba5
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0087_alter_mute_to_timeout.py
@@ -0,0 +1,25 @@
+from django.apps.registry import Apps
+from django.db import migrations, models
+
+import pydis_site.apps.api.models
+
+
+def rename_type(apps: Apps, _) -> None:
+ infractions: pydis_site.apps.api.models.Infraction = apps.get_model("api", "Infraction")
+ infractions.objects.filter(type="mute").update(type="timeout")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0086_infraction_jump_url'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='infraction',
+ name='type',
+ field=models.CharField(choices=[('note', 'Note'), ('warning', 'Warning'), ('watch', 'Watch'), ('timeout', 'Timeout'), ('kick', 'Kick'), ('ban', 'Ban'), ('superstar', 'Superstar'), ('voice_ban', 'Voice Ban'), ('voice_mute', 'Voice Mute')], help_text='The type of the infraction.', max_length=10),
+ ),
+ migrations.RunPython(rename_type, migrations.RunPython.noop)
+ ]
diff --git a/pydis_site/apps/api/migrations/0088_new_filter_schema.py b/pydis_site/apps/api/migrations/0088_new_filter_schema.py
new file mode 100644
index 00000000..9bc40779
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0088_new_filter_schema.py
@@ -0,0 +1,171 @@
+"""Modified migration file to migrate existing filters to the new system."""
+from datetime import timedelta
+
+import django.contrib.postgres.fields
+from django.apps.registry import Apps
+from django.core.validators import MinValueValidator
+from django.db import migrations, models
+import django.db.models.deletion
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+
+import pydis_site.apps.api.models
+
+OLD_LIST_NAMES = (('GUILD_INVITE', True), ('GUILD_INVITE', False), ('FILE_FORMAT', True), ('DOMAIN_NAME', False), ('FILTER_TOKEN', False), ('REDIRECT', False))
+change_map = {
+ "FILTER_TOKEN": "token",
+ "DOMAIN_NAME": "domain",
+ "GUILD_INVITE": "invite",
+ "FILE_FORMAT": "extension",
+ "REDIRECT": "redirect"
+}
+
+
+def forward(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+ filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+ filter_list_old = apps.get_model("api", "FilterListOld")
+
+ for name, type_ in OLD_LIST_NAMES:
+ objects = filter_list_old.objects.filter(type=name, allowed=type_)
+ if name == "DOMAIN_NAME":
+ dm_content = "Your message has been removed because it contained a blocked domain: `{domain}`."
+ elif name == "GUILD_INVITE":
+ dm_content = "Per Rule 6, your invite link has been removed. " \
+ "Our server rules can be found here: https://pythondiscord.com/pages/rules"
+ else:
+ dm_content = ""
+
+ list_ = filter_list.objects.create(
+ name=change_map[name],
+ list_type=int(type_),
+ guild_pings=(["Moderators"] if name != "FILE_FORMAT" else []),
+ filter_dm=True,
+ dm_pings=[],
+ remove_context=(True if name != "FILTER_TOKEN" else False),
+ bypass_roles=["Helpers"],
+ enabled=True,
+ dm_content=dm_content,
+ dm_embed="" if name != "FILE_FORMAT" else "*Defined at runtime.*",
+ infraction_type="NONE",
+ infraction_reason="",
+ infraction_duration=timedelta(seconds=0),
+ infraction_channel=0,
+ disabled_channels=[],
+ disabled_categories=(["CODE JAM"] if name in ("FILE_FORMAT", "GUILD_INVITE") else []),
+ enabled_channels=[],
+ enabled_categories=[],
+ send_alert=(name in ('GUILD_INVITE', 'DOMAIN_NAME', 'FILTER_TOKEN'))
+ )
+
+ for object_ in objects:
+ new_object = filter_.objects.create(
+ content=object_.content,
+ created_at=object_.created_at,
+ updated_at=object_.updated_at,
+ filter_list=list_,
+ description=object_.comment,
+ additional_settings={},
+ guild_pings=None,
+ filter_dm=None,
+ dm_pings=None,
+ remove_context=None,
+ bypass_roles=None,
+ enabled=None,
+ dm_content=None,
+ dm_embed=None,
+ infraction_type=None,
+ infraction_reason=None,
+ infraction_duration=None,
+ infraction_channel=None,
+ disabled_channels=None,
+ disabled_categories=None,
+ enabled_channels=None,
+ enabled_categories=None,
+ send_alert=None,
+ )
+ new_object.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0087_alter_mute_to_timeout'),
+ ]
+
+ operations = [
+ migrations.RenameModel(
+ old_name='FilterList',
+ new_name='FilterListOld'
+ ),
+ migrations.CreateModel(
+ name='Filter',
+ 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)),
+ ('content', models.TextField(help_text='The definition of this filter.')),
+ ('description', models.TextField(help_text='Why this filter has been added.', null=True)),
+ ('additional_settings', models.JSONField(help_text='Additional settings which are specific to this filter.', default=dict)),
+ ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None, null=True)),
+ ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.', null=True)),
+ ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None, null=True)),
+ ('remove_context', models.BooleanField(help_text='Whether this filter should remove the context (such as a message) triggering it.', null=True)),
+ ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None, null=True)),
+ ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.', null=True)),
+ ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, null=True, blank=True)),
+ ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, null=True, blank=True)),
+ ('infraction_type', models.CharField(choices=[('NONE', 'None'), ('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('TIMEOUT', 'Timeout'), ('KICK', 'Kick'), ('BAN', 'Ban'), ('SUPERSTAR', 'Superstar'), ('VOICE_BAN', 'Voice Ban'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10, null=True)),
+ ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, null=True, blank=True)),
+ ('infraction_duration', models.DurationField(help_text='The duration of the infraction. 0 for permanent.', null=True)),
+ ('infraction_channel', models.BigIntegerField(validators=(MinValueValidator(limit_value=0, message="Channel IDs cannot be negative."),), help_text="Channel in which to send the infraction.", null=True)),
+ ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter even if it's enabled in the category.", null=True, size=None)),
+ ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", null=True, size=None)),
+ ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", null=True, size=None)),
+ ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", null=True, size=None)),
+ ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.', null=True)),
+ ],
+ ),
+ 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)),
+ ('name', models.CharField(help_text='The unique name of this list.', max_length=50)),
+ ('list_type', models.IntegerField(choices=[(1, 'Allow'), (0, 'Deny')], help_text='Whether this list is an allowlist or denylist')),
+ ('guild_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers.', size=None)),
+ ('filter_dm', models.BooleanField(help_text='Whether DMs should be filtered.')),
+ ('dm_pings', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Who to ping when this filter triggers on a DM.', size=None)),
+ ('remove_context', models.BooleanField(help_text='Whether this filter should remove the context (such as a message) triggering it.')),
+ ('bypass_roles', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text='Roles and users who can bypass this filter.', size=None)),
+ ('enabled', models.BooleanField(help_text='Whether this filter is currently enabled.')),
+ ('dm_content', models.CharField(help_text='The DM to send to a user triggering this filter.', max_length=1000, blank=True)),
+ ('dm_embed', models.CharField(help_text='The content of the DM embed', max_length=2000, blank=True)),
+ ('infraction_type', models.CharField(choices=[('NONE', 'None'), ('NOTE', 'Note'), ('WARNING', 'Warning'), ('WATCH', 'Watch'), ('TIMEOUT', 'Timeout'), ('KICK', 'Kick'), ('BAN', 'Ban'), ('SUPERSTAR', 'Superstar'), ('VOICE_BAN', 'Voice Ban'), ('VOICE_MUTE', 'Voice Mute')], help_text='The infraction to apply to this user.', max_length=10)),
+ ('infraction_reason', models.CharField(help_text='The reason to give for the infraction.', max_length=1000, blank=True)),
+ ('infraction_duration', models.DurationField(help_text='The duration of the infraction. 0 for permanent.')),
+ ('infraction_channel', models.BigIntegerField(validators=(MinValueValidator(limit_value=0, message="Channel IDs cannot be negative."),), help_text="Channel in which to send the infraction.")),
+ ('disabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to not run the filter even if it's enabled in the category.", size=None)),
+ ('disabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Categories in which to not run the filter.", size=None)),
+ ('enabled_channels', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="Channels in which to run the filter even if it's disabled in the category.", size=None)),
+ ('enabled_categories', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), help_text="The only categories in which to run the filter.", size=None)),
+ ('send_alert', models.BooleanField(help_text='Whether an alert should be sent.')),
+ ],
+ ),
+ migrations.AddField(
+ model_name='filter',
+ name='filter_list',
+ field=models.ForeignKey(help_text='The filter list containing this filter.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='api.FilterList'),
+ ),
+ migrations.AddConstraint(
+ model_name='filterlist',
+ constraint=models.UniqueConstraint(fields=('name', 'list_type'), name='unique_name_type'),
+ ),
+ migrations.RunPython(
+ code=forward, # Core of the migration
+ reverse_code=lambda *_: None
+ ),
+ migrations.DeleteModel(
+ name='FilterListOld'
+ )
+ ]
diff --git a/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py
new file mode 100644
index 00000000..cb230a27
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0089_unique_constraint_filters.py
@@ -0,0 +1,26 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0088_new_filter_schema'),
+ ]
+
+ operations = [
+ migrations.RunSQL(
+ "ALTER TABLE api_filter "
+ "ADD CONSTRAINT unique_filters UNIQUE NULLS NOT DISTINCT "
+ "(content, additional_settings, filter_list_id, dm_content, dm_embed, infraction_type, infraction_reason, infraction_duration, infraction_channel, guild_pings, filter_dm, dm_pings, remove_context, bypass_roles, enabled, send_alert, enabled_channels, disabled_channels, enabled_categories, disabled_categories)",
+ reverse_sql="ALTER TABLE api_filter DROP CONSTRAINT unique_filters",
+ state_operations=[
+ migrations.AddConstraint(
+ model_name='filter',
+ constraint=models.UniqueConstraint(
+ fields=('content', 'additional_settings', 'filter_list', 'dm_content', 'dm_embed', 'infraction_type', 'infraction_reason', 'infraction_duration', 'infraction_channel', 'guild_pings', 'filter_dm', 'dm_pings', 'remove_context', 'bypass_roles', 'enabled', 'send_alert', 'enabled_channels', 'disabled_channels', 'enabled_categories', 'disabled_categories'),
+ name='unique_filters'
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0090_unique_filter_list.py b/pydis_site/apps/api/migrations/0090_unique_filter_list.py
new file mode 100644
index 00000000..cef2faa3
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0090_unique_filter_list.py
@@ -0,0 +1,102 @@
+from datetime import timedelta
+
+from django.apps.registry import Apps
+from django.db import migrations
+
+import pydis_site.apps.api.models.bot.filters
+
+
+def create_unique_list(apps: Apps, _):
+ """Create the 'unique' FilterList and its related Filters."""
+ filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+ filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+
+ list_ = filter_list.objects.create(
+ name="unique",
+ list_type=0,
+ guild_pings=[],
+ filter_dm=True,
+ dm_pings=[],
+ remove_context=False,
+ bypass_roles=[],
+ enabled=True,
+ dm_content="",
+ dm_embed="",
+ infraction_type="NONE",
+ infraction_reason="",
+ infraction_duration=timedelta(seconds=0),
+ infraction_channel=0,
+ disabled_channels=[],
+ disabled_categories=[],
+ enabled_channels=[],
+ enabled_categories=[],
+ send_alert=True
+ )
+
+ everyone = filter_.objects.create(
+ content="everyone",
+ filter_list=list_,
+ description="",
+ remove_context=True,
+ bypass_roles=["Helpers"],
+ dm_content=(
+ "Please don't try to ping `@everyone` or `@here`. Your message has been removed. "
+ "If you believe this was a mistake, please let staff know!"
+ ),
+ disabled_categories=["CODE JAM"]
+ )
+ everyone.save()
+
+ webhook = filter_.objects.create(
+ content="webhook",
+ filter_list=list_,
+ description="",
+ remove_context=True,
+ dm_content=(
+ "Looks like you posted a Discord webhook URL. "
+ "Therefore, your message has been removed, and your webhook has been deleted. "
+ "You can re-create it if you wish to. "
+ "If you believe this was a mistake, please let us know."
+ ),
+ )
+ webhook.save()
+
+ rich_embed = filter_.objects.create(
+ content="rich_embed",
+ filter_list=list_,
+ description="",
+ guild_pings=["Moderators"],
+ dm_pings=["Moderators"]
+ )
+ rich_embed.save()
+
+ discord_token = filter_.objects.create(
+ content="discord_token",
+ filter_list=list_,
+ filter_dm=False,
+ remove_context=True,
+ dm_content=(
+ "I noticed you posted a seemingly valid Discord API "
+ "token in your message and have removed your message. "
+ "This means that your token has been **compromised**. "
+ "Please change your token **immediately** at: "
+ "<https://discord.com/developers/applications>\n\n"
+ "Feel free to re-post it with the token removed. "
+ "If you believe this was a mistake, please let us know!"
+ )
+ )
+ discord_token.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0089_unique_constraint_filters'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=create_unique_list,
+ reverse_code=None
+ ),
+ ]
diff --git a/pydis_site/apps/api/migrations/0091_antispam_filter_list.py b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py
new file mode 100644
index 00000000..7c233142
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0091_antispam_filter_list.py
@@ -0,0 +1,52 @@
+from datetime import timedelta
+
+from django.apps.registry import Apps
+from django.db import migrations
+
+import pydis_site.apps.api.models.bot.filters
+
+
+def create_antispam_list(apps: Apps, _):
+ """Create the 'antispam' FilterList and its related Filters."""
+ filter_list: pydis_site.apps.api.models.FilterList = apps.get_model("api", "FilterList")
+ filter_: pydis_site.apps.api.models.Filter = apps.get_model("api", "Filter")
+
+ list_ = filter_list.objects.create(
+ name="antispam",
+ list_type=0,
+ guild_pings=["Moderators"],
+ filter_dm=False,
+ dm_pings=[],
+ remove_context=True,
+ bypass_roles=["Helpers"],
+ enabled=True,
+ dm_content="",
+ dm_embed="",
+ infraction_type="TIMEOUT",
+ infraction_reason="",
+ infraction_duration=timedelta(seconds=600),
+ infraction_channel=0,
+ disabled_channels=[],
+ disabled_categories=["CODE JAM"],
+ enabled_channels=[],
+ enabled_categories=[],
+ send_alert=True
+ )
+
+ rules = ("duplicates", "attachments", "burst", "chars", "emoji", "links", "mentions", "newlines", "role_mentions")
+
+ filter_.objects.bulk_create([filter_(content=rule, filter_list=list_) for rule in rules])
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0090_unique_filter_list'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=create_antispam_list,
+ reverse_code=None
+ ),
+ ]
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
index fd5bf220..fee4c8d5 100644
--- a/pydis_site/apps/api/models/__init__.py
+++ b/pydis_site/apps/api/models/__init__.py
@@ -1,7 +1,9 @@
# flake8: noqa
from .bot import (
FilterList,
+ Filter,
BotSetting,
+ BumpedThread,
DocumentationLink,
DeletedMessage,
Infraction,
@@ -10,6 +12,8 @@ from .bot import (
Nomination,
NominationEntry,
OffensiveMessage,
+ AocAccountLink,
+ AocCompletionistBlock,
OffTopicChannelName,
Reminder,
Role,
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
index ac864de3..6f09473d 100644
--- a/pydis_site/apps/api/models/bot/__init__.py
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -1,10 +1,13 @@
# flake8: noqa
-from .filter_list import FilterList
+from .filters import FilterList, Filter
from .bot_setting import BotSetting
+from .bumped_thread import BumpedThread
from .deleted_message import DeletedMessage
from .documentation_link import DocumentationLink
from .infraction import Infraction
from .message import Message
+from .aoc_completionist_block import AocCompletionistBlock
+from .aoc_link import AocAccountLink
from .message_deletion_context import MessageDeletionContext
from .nomination import Nomination, NominationEntry
from .off_topic_channel_name import OffTopicChannelName
diff --git a/pydis_site/apps/api/models/bot/aoc_completionist_block.py b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
new file mode 100644
index 00000000..acbc0eba
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/aoc_completionist_block.py
@@ -0,0 +1,26 @@
+from django.db import models
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+class AocCompletionistBlock(ModelReprMixin, models.Model):
+ """A Discord user blocked from getting the AoC completionist Role."""
+
+ user = models.OneToOneField(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The user that is blocked from getting the AoC Completionist Role",
+ primary_key=True
+ )
+
+ is_blocked = models.BooleanField(
+ default=True,
+ help_text="Whether this user is actively being blocked "
+ "from getting the AoC Completionist Role",
+ verbose_name="Blocked"
+ )
+ reason = models.TextField(
+ null=True,
+ help_text="The reason for the AoC Completionist Role Block."
+ )
diff --git a/pydis_site/apps/api/models/bot/aoc_link.py b/pydis_site/apps/api/models/bot/aoc_link.py
new file mode 100644
index 00000000..4e9d4882
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/aoc_link.py
@@ -0,0 +1,21 @@
+from django.db import models
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+class AocAccountLink(ModelReprMixin, models.Model):
+ """An AoC account link for a Discord User."""
+
+ user = models.OneToOneField(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The user that is blocked from getting the AoC Completionist Role",
+ primary_key=True
+ )
+
+ aoc_username = models.CharField(
+ max_length=120,
+ help_text="The AoC username associated with the Discord User.",
+ blank=False
+ )
diff --git a/pydis_site/apps/api/models/bot/bumped_thread.py b/pydis_site/apps/api/models/bot/bumped_thread.py
new file mode 100644
index 00000000..cdf9a950
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/bumped_thread.py
@@ -0,0 +1,22 @@
+from django.core.validators import MinValueValidator
+from django.db import models
+
+from pydis_site.apps.api.models.mixins import ModelReprMixin
+
+
+class BumpedThread(ModelReprMixin, models.Model):
+ """A list of thread IDs to be bumped."""
+
+ thread_id = models.BigIntegerField(
+ primary_key=True,
+ help_text=(
+ "The thread ID that should be bumped."
+ ),
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Thread IDs cannot be negative."
+ ),
+ ),
+ verbose_name="Thread ID",
+ )
diff --git a/pydis_site/apps/api/models/bot/documentation_link.py b/pydis_site/apps/api/models/bot/documentation_link.py
index 9941907c..7f3b4ca5 100644
--- a/pydis_site/apps/api/models/bot/documentation_link.py
+++ b/pydis_site/apps/api/models/bot/documentation_link.py
@@ -37,11 +37,11 @@ class DocumentationLink(ModelReprMixin, models.Model):
help_text="The URL at which the Sphinx inventory is available for this package."
)
- def __str__(self):
- """Returns the package and URL for the current documentation link, for display purposes."""
- return f"{self.package} - {self.base_url}"
-
class Meta:
"""Defines the meta options for the documentation link model."""
ordering = ['package']
+
+ def __str__(self):
+ """Returns the package and URL for the current documentation link, for display purposes."""
+ return f"{self.package} - {self.base_url}"
diff --git a/pydis_site/apps/api/models/bot/filter_list.py b/pydis_site/apps/api/models/bot/filter_list.py
deleted file mode 100644
index d30f7213..00000000
--- a/pydis_site/apps/api/models/bot/filter_list.py
+++ /dev/null
@@ -1,42 +0,0 @@
-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 '
- 'REDIRECT '
- )
- 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/filters.py b/pydis_site/apps/api/models/bot/filters.py
new file mode 100644
index 00000000..6d5188e4
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/filters.py
@@ -0,0 +1,261 @@
+from django.contrib.postgres.fields import ArrayField
+from django.core.validators import MinValueValidator
+from django.db import models
+from django.db.models import UniqueConstraint
+
+# Must be imported that way to avoid circular imports
+from pydis_site.apps.api.models.mixins import ModelReprMixin, ModelTimestampMixin
+from .infraction import Infraction
+
+
+class FilterListType(models.IntegerChoices):
+ """Choice between allow or deny for a list type."""
+
+ ALLOW = 1
+ DENY = 0
+
+
+class FilterList(ModelTimestampMixin, ModelReprMixin, models.Model):
+ """Represent a list in its allow or deny form."""
+
+ name = models.CharField(max_length=50, help_text="The unique name of this list.")
+ list_type = models.IntegerField(
+ choices=FilterListType.choices,
+ help_text="Whether this list is an allowlist or denylist"
+ )
+ dm_content = models.CharField(
+ max_length=1000,
+ null=False,
+ blank=True,
+ help_text="The DM to send to a user triggering this filter."
+ )
+ dm_embed = models.CharField(
+ max_length=2000,
+ help_text="The content of the DM embed",
+ null=False,
+ blank=True
+ )
+ infraction_type = models.CharField(
+ choices=[
+ (choices[0].upper(), choices[1])
+ for choices in [("NONE", "None"), *Infraction.TYPE_CHOICES]
+ ],
+ max_length=10,
+ null=False,
+ help_text="The infraction to apply to this user."
+ )
+ infraction_reason = models.CharField(
+ max_length=1000,
+ help_text="The reason to give for the infraction.",
+ blank=True,
+ null=False
+ )
+ infraction_duration = models.DurationField(
+ null=False,
+ help_text="The duration of the infraction. 0 for permanent."
+ )
+ infraction_channel = models.BigIntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Channel IDs cannot be negative."
+ ),
+ ),
+ help_text="Channel in which to send the infraction.",
+ null=False
+ )
+ guild_pings = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Who to ping when this filter triggers.",
+ null=False
+ )
+ filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=False)
+ dm_pings = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Who to ping when this filter triggers on a DM.",
+ null=False
+ )
+ remove_context = models.BooleanField(
+ help_text=(
+ "Whether this filter should remove the context (such as a message) "
+ "triggering it."
+ ),
+ null=False
+ )
+ bypass_roles = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Roles and users who can bypass this filter.",
+ null=False
+ )
+ enabled = models.BooleanField(
+ help_text="Whether this filter is currently enabled.",
+ null=False
+ )
+ send_alert = models.BooleanField(
+ help_text="Whether an alert should be sent.",
+ )
+ # Where a filter should apply.
+ enabled_channels = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Channels in which to run the filter even if it's disabled in the category."
+ )
+ disabled_channels = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Channels in which to not run the filter even if it's enabled in the category."
+ )
+ enabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="The only categories in which to run the filter."
+ )
+ disabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Categories in which to not run the filter."
+ )
+
+ class Meta:
+ """Constrain name and list_type unique."""
+
+ constraints = (
+ UniqueConstraint(fields=("name", "list_type"), name="unique_name_type"),
+ )
+
+ def __str__(self) -> str:
+ return f"Filter {FilterListType(self.list_type).label}list {self.name!r}"
+
+
+class FilterBase(ModelTimestampMixin, ModelReprMixin, models.Model):
+ """One specific trigger of a list."""
+
+ content = models.TextField(help_text="The definition of this filter.")
+ description = models.TextField(
+ help_text="Why this filter has been added.", null=True
+ )
+ additional_settings = models.JSONField(
+ help_text="Additional settings which are specific to this filter.", default=dict
+ )
+ filter_list = models.ForeignKey(
+ FilterList, models.CASCADE, related_name="filters",
+ help_text="The filter list containing this filter."
+ )
+ dm_content = models.CharField(
+ max_length=1000,
+ null=True,
+ blank=True,
+ help_text="The DM to send to a user triggering this filter."
+ )
+ dm_embed = models.CharField(
+ max_length=2000,
+ help_text="The content of the DM embed",
+ null=True,
+ blank=True
+ )
+ infraction_type = models.CharField(
+ choices=[
+ (choices[0].upper(), choices[1])
+ for choices in [("NONE", "None"), *Infraction.TYPE_CHOICES]
+ ],
+ max_length=10,
+ null=True,
+ help_text="The infraction to apply to this user."
+ )
+ infraction_reason = models.CharField(
+ max_length=1000,
+ help_text="The reason to give for the infraction.",
+ blank=True,
+ null=True
+ )
+ infraction_duration = models.DurationField(
+ null=True,
+ help_text="The duration of the infraction. 0 for permanent."
+ )
+ infraction_channel = models.BigIntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Channel IDs cannot be negative."
+ ),
+ ),
+ help_text="Channel in which to send the infraction.",
+ null=True
+ )
+ guild_pings = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Who to ping when this filter triggers.",
+ null=True
+ )
+ filter_dm = models.BooleanField(help_text="Whether DMs should be filtered.", null=True)
+ dm_pings = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Who to ping when this filter triggers on a DM.",
+ null=True
+ )
+ remove_context = models.BooleanField(
+ help_text=(
+ "Whether this filter should remove the context (such as a message) "
+ "triggering it."
+ ),
+ null=True
+ )
+ bypass_roles = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Roles and users who can bypass this filter.",
+ null=True
+ )
+ enabled = models.BooleanField(
+ help_text="Whether this filter is currently enabled.",
+ null=True
+ )
+ send_alert = models.BooleanField(
+ help_text="Whether an alert should be sent.",
+ null=True
+ )
+
+ enabled_channels = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Channels in which to run the filter even if it's disabled in the category.",
+ null=True
+ )
+ disabled_channels = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Channels in which to not run the filter even if it's enabled in the category.",
+ null=True
+ )
+ enabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="The only categories in which to run the filter.",
+ null=True
+ )
+ disabled_categories = ArrayField(
+ models.CharField(max_length=100),
+ help_text="Categories in which to not run the filter.",
+ null=True
+ )
+
+ class Meta:
+ """Metaclass for FilterBase to make it abstract model."""
+
+ abstract = True
+
+ def __str__(self) -> str:
+ return f"Filter {self.content!r}"
+
+
+class Filter(FilterBase):
+ """
+ The main Filter models based on `FilterBase`.
+
+ The purpose to have this model is to have access to the Fields of the Filter model
+ and set the unique constraint based on those fields.
+ """
+
+ class Meta:
+ """Metaclass Filter to set the unique constraint."""
+
+ constraints = (
+ UniqueConstraint(
+ fields=tuple(
+ field.name for field in FilterBase._meta.fields
+ if field.name not in ("id", "description", "created_at", "updated_at")
+ ),
+ name="unique_filters"),
+ )
diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py
index c9303024..b304c6d4 100644
--- a/pydis_site/apps/api/models/bot/infraction.py
+++ b/pydis_site/apps/api/models/bot/infraction.py
@@ -12,7 +12,7 @@ class Infraction(ModelReprMixin, models.Model):
("note", "Note"),
("warning", "Warning"),
("watch", "Watch"),
- ("mute", "Mute"),
+ ("timeout", "Timeout"),
("kick", "Kick"),
("ban", "Ban"),
("superstar", "Superstar"),
@@ -23,6 +23,12 @@ class Infraction(ModelReprMixin, models.Model):
default=timezone.now,
help_text="The date and time of the creation of this infraction."
)
+ last_applied = models.DateTimeField(
+ # This default is for backwards compatibility with bot versions
+ # that don't explicitly give a value.
+ default=timezone.now,
+ help_text="The date and time of when this infraction was last applied."
+ )
expires_at = models.DateTimeField(
null=True,
help_text=(
@@ -63,14 +69,14 @@ class Infraction(ModelReprMixin, models.Model):
help_text="Whether a DM was sent to the user when infraction was applied."
)
- def __str__(self):
- """Returns some info on the current infraction, for display purposes."""
- s = f"#{self.id}: {self.type} on {self.user_id}"
- if self.expires_at:
- s += f" until {self.expires_at}"
- if self.hidden:
- s += " (hidden)"
- return s
+ jump_url = models.URLField(
+ default=None,
+ null=True,
+ max_length=88,
+ help_text=(
+ "The jump url to message invoking the infraction."
+ )
+ )
class Meta:
"""Defines the meta options for the infraction model."""
@@ -83,3 +89,12 @@ class Infraction(ModelReprMixin, models.Model):
name="unique_active_infraction_per_type_per_user"
),
)
+
+ def __str__(self):
+ """Returns some info on the current infraction, for display purposes."""
+ s = f"#{self.id}: {self.type} on {self.user_id}"
+ if self.expires_at:
+ s += f" until {self.expires_at}"
+ if self.hidden:
+ s += " (hidden)"
+ return s
diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py
index bab3368d..fb3c47fc 100644
--- a/pydis_site/apps/api/models/bot/message.py
+++ b/pydis_site/apps/api/models/bot/message.py
@@ -1,13 +1,11 @@
-from datetime import datetime
+import datetime
from django.contrib.postgres import fields as pgfields
from django.core.validators import MinValueValidator
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.mixins import ModelReprMixin
-from pydis_site.apps.api.models.utils import validate_embed
class Message(ModelReprMixin, models.Model):
@@ -48,9 +46,7 @@ class Message(ModelReprMixin, models.Model):
blank=True
)
embeds = pgfields.ArrayField(
- models.JSONField(
- validators=(validate_embed,)
- ),
+ models.JSONField(),
blank=True,
help_text="Embeds attached to this message."
)
@@ -62,14 +58,15 @@ class Message(ModelReprMixin, models.Model):
help_text="Attachments attached to this message."
)
- @property
- def timestamp(self) -> datetime:
- """Attribute that represents the message timestamp as derived from the snowflake id."""
- tz_naive_datetime = datetime.utcfromtimestamp(((self.id >> 22) + 1420070400000) / 1000)
- tz_aware_datetime = timezone.make_aware(tz_naive_datetime, timezone=timezone.utc)
- return tz_aware_datetime
-
class Meta:
"""Metadata provided for Django's ORM."""
abstract = True
+
+ @property
+ def timestamp(self) -> datetime.datetime:
+ """Attribute that represents the message timestamp as derived from the snowflake id."""
+ return datetime.datetime.fromtimestamp(
+ ((self.id >> 22) + 1420070400000) / 1000,
+ tz=datetime.timezone.utc,
+ )
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 25741266..207bc4bc 100644
--- a/pydis_site/apps/api/models/bot/message_deletion_context.py
+++ b/pydis_site/apps/api/models/bot/message_deletion_context.py
@@ -30,12 +30,12 @@ class MessageDeletionContext(ModelReprMixin, models.Model):
help_text="When this deletion took place."
)
- @property
- def log_url(self) -> str:
- """Create the url for the deleted message logs."""
- return reverse('staff:logs', args=(self.id,))
-
class Meta:
"""Set the ordering for list views to newest first."""
ordering = ("-creation",)
+
+ @property
+ def log_url(self) -> str:
+ """Create the url for the deleted message logs."""
+ return reverse('staff:logs', args=(self.id,))
diff --git a/pydis_site/apps/api/models/bot/metricity.py b/pydis_site/apps/api/models/bot/metricity.py
index abd25ef0..f1277b21 100644
--- a/pydis_site/apps/api/models/bot/metricity.py
+++ b/pydis_site/apps/api/models/bot/metricity.py
@@ -1,19 +1,18 @@
-from typing import List, Tuple
from django.db import connections
BLOCK_INTERVAL = 10 * 60 # 10 minute blocks
-EXCLUDE_CHANNELS = (
+# This needs to be a list due to psycopg3 type adaptions.
+EXCLUDE_CHANNELS = [
"267659945086812160", # Bot commands
"607247579608121354" # SeasonalBot commands
-)
+]
-class NotFoundError(Exception): # noqa: N818
+class NotFoundError(Exception):
"""Raised when an entity cannot be found."""
- pass
class Metricity:
@@ -31,15 +30,14 @@ class Metricity:
def user(self, user_id: str) -> dict:
"""Query a user's data."""
# TODO: Swap this back to some sort of verified at date
- columns = ["joined_at"]
- query = f"SELECT {','.join(columns)} FROM users WHERE id = '%s'"
+ query = "SELECT joined_at FROM users WHERE id = '%s'"
self.cursor.execute(query, [user_id])
values = self.cursor.fetchone()
if not values:
- raise NotFoundError()
+ raise NotFoundError
- return dict(zip(columns, values))
+ return {'joined_at': values[0]}
def total_messages(self, user_id: str) -> int:
"""Query total number of messages for a user."""
@@ -51,14 +49,14 @@ class Metricity:
WHERE
author_id = '%s'
AND NOT is_deleted
- AND channel_id NOT IN %s
+ AND channel_id != ANY(%s)
""",
[user_id, EXCLUDE_CHANNELS]
)
values = self.cursor.fetchone()
if not values:
- raise NotFoundError()
+ raise NotFoundError
return values[0]
@@ -79,7 +77,7 @@ class Metricity:
WHERE
author_id='%s'
AND NOT is_deleted
- AND channel_id NOT IN %s
+ AND channel_id != ANY(%s)
GROUP BY interval
) block_query;
""",
@@ -88,11 +86,11 @@ class Metricity:
values = self.cursor.fetchone()
if not values:
- raise NotFoundError()
+ raise NotFoundError
return values[0]
- def top_channel_activity(self, user_id: str) -> List[Tuple[str, int]]:
+ def top_channel_activity(self, user_id: str) -> list[tuple[str, int]]:
"""
Query the top three channels in which the user is most active.
@@ -127,6 +125,34 @@ class Metricity:
values = self.cursor.fetchall()
if not values:
- raise NotFoundError()
+ raise NotFoundError
+
+ return values
+
+ def total_messages_in_past_n_days(
+ self,
+ user_ids: list[str],
+ days: int
+ ) -> list[tuple[str, int]]:
+ """
+ Query activity by a list of users in the past `days` days.
+
+ Returns a list of (user_id, message_count) tuples.
+ """
+ self.cursor.execute(
+ """
+ SELECT
+ author_id, COUNT(*)
+ FROM messages
+ WHERE
+ author_id = ANY(%s)
+ AND NOT is_deleted
+ AND channel_id != ANY(%s)
+ AND created_at > now() - interval '%s days'
+ GROUP BY author_id
+ """,
+ [user_ids, EXCLUDE_CHANNELS, days]
+ )
+ values = self.cursor.fetchall()
return values
diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py
index 221d8534..2f8e305c 100644
--- a/pydis_site/apps/api/models/bot/nomination.py
+++ b/pydis_site/apps/api/models/bot/nomination.py
@@ -35,17 +35,21 @@ class Nomination(ModelReprMixin, models.Model):
default=False,
help_text="Whether a review was made."
)
-
- def __str__(self):
- """Representation that makes the target and state of the nomination immediately evident."""
- status = "active" if self.active else "ended"
- return f"Nomination of {self.user} ({status})"
+ thread_id = models.BigIntegerField(
+ help_text="The nomination vote's thread id.",
+ null=True,
+ )
class Meta:
"""Set the ordering of nominations to most recent first."""
ordering = ("-inserted_at",)
+ def __str__(self):
+ """Representation that makes the target and state of the nomination immediately evident."""
+ status = "active" if self.active else "ended"
+ return f"Nomination of {self.user} ({status})"
+
class NominationEntry(ModelReprMixin, models.Model):
"""A nomination entry created by a single staff member."""
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 e9fec114..b380efad 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
@@ -11,7 +11,7 @@ class OffTopicChannelName(ModelReprMixin, models.Model):
primary_key=True,
max_length=96,
validators=(
- RegexValidator(regex=r"^[a-z0-9\U0001d5a0-\U0001d5b9-ǃ?’'<>]+$"),
+ RegexValidator(regex=r"^[a-z0-9\U0001d5a0-\U0001d5b9-ǃ?’'<>⧹⧸]+$"),
),
help_text="The actual channel name that will be used on our Discord server."
)
diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py
index 733a8e08..e37f3ccd 100644
--- a/pydis_site/apps/api/models/bot/role.py
+++ b/pydis_site/apps/api/models/bot/role.py
@@ -51,6 +51,11 @@ class Role(ModelReprMixin, models.Model):
help_text="The position of the role in the role hierarchy of the Discord Guild."
)
+ class Meta:
+ """Set role ordering from highest to lowest position."""
+
+ ordering = ("-position",)
+
def __str__(self) -> str:
"""Returns the name of the current role, for display purposes."""
return self.name
@@ -62,8 +67,3 @@ class Role(ModelReprMixin, models.Model):
def __le__(self, other: Role) -> bool:
"""Compares the roles based on their position in the role hierarchy of the guild."""
return self.position <= other.position
-
- class Meta:
- """Set role ordering from highest to lowest position."""
-
- ordering = ("-position",)
diff --git a/pydis_site/apps/api/models/utils.py b/pydis_site/apps/api/models/utils.py
deleted file mode 100644
index 859394d2..00000000
--- a/pydis_site/apps/api/models/utils.py
+++ /dev/null
@@ -1,172 +0,0 @@
-from collections.abc import Mapping
-from typing import Any, Dict
-
-from django.core.exceptions import ValidationError
-from django.core.validators import MaxLengthValidator, MinLengthValidator
-
-
-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)}."
- )
-
- 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.db import models
- >>> from pydis_site.apps.api.models.utils import validate_embed
- >>> class MyMessage(models.Model):
- ... embed = models.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=4096),),
- '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/pagination.py b/pydis_site/apps/api/pagination.py
index 2a325460..61707d33 100644
--- a/pydis_site/apps/api/pagination.py
+++ b/pydis_site/apps/api/pagination.py
@@ -1,7 +1,6 @@
-import typing
-
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.response import Response
+from rest_framework.utils.serializer_helpers import ReturnList
class LimitOffsetPaginationExtended(LimitOffsetPagination):
@@ -44,6 +43,6 @@ class LimitOffsetPaginationExtended(LimitOffsetPagination):
default_limit = 100
- def get_paginated_response(self, data: typing.Any) -> Response:
+ def get_paginated_response(self, data: ReturnList) -> Response:
"""Override to skip metadata i.e. `count`, `next`, and `previous`."""
return Response(data)
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 745aff42..2186b02c 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -1,4 +1,7 @@
"""Converters from Django models to data interchange formats and back."""
+from datetime import timedelta
+from typing import Any
+
from django.db.models.query import QuerySet
from django.db.utils import IntegrityError
from rest_framework.exceptions import NotFound
@@ -13,9 +16,13 @@ from rest_framework.settings import api_settings
from rest_framework.validators import UniqueTogetherValidator
from .models import (
+ AocAccountLink,
+ AocCompletionistBlock,
BotSetting,
+ BumpedThread,
DeletedMessage,
DocumentationLink,
+ Filter,
FilterList,
Infraction,
MessageDeletionContext,
@@ -39,6 +46,32 @@ class BotSettingSerializer(ModelSerializer):
fields = ('name', 'data')
+class ListBumpedThreadSerializer(ListSerializer):
+ """Custom ListSerializer to override to_representation() when list views are triggered."""
+
+ def to_representation(self, objects: list[BumpedThread]) -> int:
+ """
+ Used by the `ListModelMixin` to return just the list of bumped thread ids.
+
+ Only the thread_id field is useful, hence it is unnecessary to create a nested dictionary.
+
+ Additionally, this allows bumped thread routes to simply return an
+ array of thread_id ints instead of objects, saving on bandwidth.
+ """
+ return [obj.thread_id for obj in objects]
+
+
+class BumpedThreadSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `BumpedThread` instances."""
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ list_serializer_class = ListBumpedThreadSerializer
+ model = BumpedThread
+ fields = ('thread_id',)
+
+
class DeletedMessageSerializer(ModelSerializer):
"""
A class providing (de-)serialization of `DeletedMessage` instances.
@@ -112,30 +145,286 @@ class DocumentationLinkSerializer(ModelSerializer):
fields = ('package', 'base_url', 'inventory_url')
+# region: filters serializers
+
+SETTINGS_FIELDS = (
+ 'dm_content',
+ 'dm_embed',
+ 'infraction_type',
+ 'infraction_reason',
+ 'infraction_duration',
+ 'infraction_channel',
+ 'guild_pings',
+ 'filter_dm',
+ 'dm_pings',
+ 'remove_context',
+ 'send_alert',
+ 'bypass_roles',
+ 'enabled',
+ 'enabled_channels',
+ 'disabled_channels',
+ 'enabled_categories',
+ 'disabled_categories',
+)
+
+ALLOW_BLANK_SETTINGS = (
+ 'dm_content',
+ 'dm_embed',
+ 'infraction_reason',
+)
+
+ALLOW_EMPTY_SETTINGS = (
+ 'enabled_channels',
+ 'disabled_channels',
+ 'enabled_categories',
+ 'disabled_categories',
+ 'guild_pings',
+ 'dm_pings',
+ 'bypass_roles',
+)
+
+# Required fields for custom JSON representation purposes
+BASE_FILTER_FIELDS = (
+ 'id', 'created_at', 'updated_at', 'content', 'description', 'additional_settings'
+)
+BASE_FILTERLIST_FIELDS = ('id', 'created_at', 'updated_at', 'name', 'list_type')
+BASE_SETTINGS_FIELDS = (
+ 'bypass_roles',
+ 'filter_dm',
+ 'enabled',
+ 'remove_context',
+ 'send_alert'
+)
+INFRACTION_AND_NOTIFICATION_FIELDS = (
+ 'infraction_type',
+ 'infraction_reason',
+ 'infraction_duration',
+ 'infraction_channel',
+ 'dm_content',
+ 'dm_embed'
+)
+CHANNEL_SCOPE_FIELDS = (
+ 'disabled_channels',
+ 'disabled_categories',
+ 'enabled_channels',
+ 'enabled_categories'
+)
+MENTIONS_FIELDS = ('guild_pings', 'dm_pings')
+
+MAX_TIMEOUT_DURATION = timedelta(days=28)
+
+
+def _create_meta_extra_kwargs(*, for_filter: bool) -> dict[str, dict[str, bool]]:
+ """Create the extra kwargs for the Meta classes of the Filter and FilterList serializers."""
+ extra_kwargs = {}
+ for field in SETTINGS_FIELDS:
+ field_args = {'required': False, 'allow_null': True} if for_filter else {}
+ if field in ALLOW_BLANK_SETTINGS:
+ field_args['allow_blank'] = True
+ if field in ALLOW_EMPTY_SETTINGS:
+ field_args['allow_empty'] = True
+ extra_kwargs[field] = field_args
+ return extra_kwargs
+
+
+def get_field_value(data: dict, field_name: str) -> Any:
+ """Get the value directly from the key, or from the filter list if it's missing or is None."""
+ if data.get(field_name) is not None:
+ return data[field_name]
+ return getattr(data['filter_list'], field_name)
+
+
+class FilterSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `Filter` instances."""
+
+ def validate(self, data: dict) -> dict:
+ """Perform infraction data + allowed and disallowed lists validation."""
+ infraction_type = get_field_value(data, 'infraction_type')
+ infraction_duration = get_field_value(data, 'infraction_duration')
+ if (
+ (get_field_value(data, 'infraction_reason') or infraction_duration)
+ and infraction_type == 'NONE'
+ ):
+ raise ValidationError(
+ "Infraction type is required with infraction duration or reason."
+ )
+
+ if (
+ infraction_type == 'TIMEOUT'
+ and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION)
+ ):
+ raise ValidationError(
+ f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days."
+ )
+
+ common_channels = (
+ set(get_field_value(data, 'disabled_channels'))
+ & set(get_field_value(data, 'enabled_channels'))
+ )
+ if common_channels:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled channels lists:"
+ f" {', '.join(repr(channel) for channel in common_channels)}."
+ )
+
+ common_categories = (
+ set(get_field_value(data, 'disabled_categories'))
+ & set(get_field_value(data, 'enabled_categories'))
+ )
+ if common_categories:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled categories lists:"
+ f" {', '.join(repr(category) for category in common_categories)}."
+ )
+
+ return data
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = Filter
+ fields = (
+ 'id',
+ 'created_at',
+ 'updated_at',
+ 'content',
+ 'description',
+ 'additional_settings',
+ 'filter_list'
+ ) + SETTINGS_FIELDS
+ extra_kwargs = _create_meta_extra_kwargs(for_filter=True)
+
+ def create(self, validated_data: dict) -> User:
+ """Override the create method to catch violations of the custom uniqueness constraint."""
+ try:
+ return super().create(validated_data)
+ except IntegrityError:
+ raise ValidationError(
+ "Check if a filter with this combination of content "
+ "and settings already exists in this filter list."
+ )
+
+ def to_representation(self, instance: Filter) -> dict:
+ """
+ Provides a custom JSON representation to the Filter Serializers.
+
+ This representation restructures how the Filter is represented.
+ It groups the Infraction, Channel and Mention related fields into their own separated group.
+
+ Furthermore, it puts the fields that meant to represent Filter settings,
+ into a sub-field called `settings`.
+ """
+ settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
+ settings['infraction_and_notification'] = {
+ name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS
+ }
+ settings['channel_scope'] = {
+ name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS
+ }
+ settings['mentions'] = {
+ name: getattr(instance, name) for name in MENTIONS_FIELDS
+ }
+
+ schema = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS}
+ schema['filter_list'] = instance.filter_list.id
+ schema['settings'] = settings
+ return schema
+
+
class FilterListSerializer(ModelSerializer):
"""A class providing (de-)serialization of `FilterList` instances."""
+ filters = FilterSerializer(many=True, read_only=True)
+
+ def validate(self, data: dict) -> dict:
+ """Perform infraction data + allow and disallowed lists validation."""
+ infraction_duration = data.get('infraction_duration')
+ if (
+ data.get('infraction_reason') or infraction_duration
+ ) and not data.get('infraction_type'):
+ raise ValidationError("Infraction type is required with infraction duration or reason")
+
+ if (
+ data.get('disabled_channels') is not None
+ and data.get('enabled_channels') is not None
+ ):
+ common_channels = set(data['disabled_channels']) & set(data['enabled_channels'])
+ if common_channels:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled channels lists:"
+ f" {', '.join(repr(channel) for channel in common_channels)}."
+ )
+
+ if (
+ data.get('infraction_type') == 'TIMEOUT'
+ and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION)
+ ):
+ raise ValidationError(
+ f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days."
+ )
+
+ if (
+ data.get('disabled_categories') is not None
+ and data.get('enabled_categories') is not None
+ ):
+ common_categories = set(data['disabled_categories']) & set(data['enabled_categories'])
+ if common_categories:
+ raise ValidationError(
+ "You can't have the same value in both enabled and disabled categories lists:"
+ f" {', '.join(repr(category) for category in common_categories)}."
+ )
+
+ return data
+
class Meta:
"""Metadata defined for the Django REST Framework."""
model = FilterList
- fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment')
+ fields = (
+ 'id', 'created_at', 'updated_at', 'name', 'list_type', 'filters'
+ ) + SETTINGS_FIELDS
+ extra_kwargs = _create_meta_extra_kwargs(for_filter=False)
- # 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.
+ # Ensure there can only be one filter list with the same name and type.
validators = [
UniqueTogetherValidator(
queryset=FilterList.objects.all(),
- fields=['content', 'type'],
+ fields=('name', 'list_type'),
message=(
- "A filterlist for this item already exists. "
- "Please note that you cannot add the same item to both allow and deny."
+ "A filterlist with the same name and type already exists."
)
),
]
+ def to_representation(self, instance: FilterList) -> dict:
+ """
+ Provides a custom JSON representation to the FilterList Serializers.
+
+ This representation restructures how the Filter is represented.
+ It groups the Infraction, Channel, and Mention related fields
+ into their own separated groups.
+
+ Furthermore, it puts the fields that are meant to represent FilterList settings,
+ into a sub-field called `settings`.
+ """
+ schema = {name: getattr(instance, name) for name in BASE_FILTERLIST_FIELDS}
+ schema['filters'] = [
+ FilterSerializer(many=False).to_representation(instance=item)
+ for item in Filter.objects.filter(filter_list=instance.id)
+ ]
+
+ settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS}
+ settings['infraction_and_notification'] = {
+ name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS
+ }
+ settings['channel_scope'] = {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS}
+ settings['mentions'] = {name: getattr(instance, name) for name in MENTIONS_FIELDS}
+
+ schema['settings'] = settings
+ return schema
+
+# endregion
+
class InfractionSerializer(ModelSerializer):
"""A class providing (de-)serialization of `Infraction` instances."""
@@ -147,6 +436,7 @@ class InfractionSerializer(ModelSerializer):
fields = (
'id',
'inserted_at',
+ 'last_applied',
'expires_at',
'active',
'user',
@@ -154,7 +444,8 @@ class InfractionSerializer(ModelSerializer):
'type',
'reason',
'hidden',
- 'dm_sent'
+ 'dm_sent',
+ 'jump_url'
)
def validate(self, attrs: dict) -> dict:
@@ -173,7 +464,7 @@ class InfractionSerializer(ModelSerializer):
if hidden and infr_type in ('superstar', 'warning', 'voice_ban', 'voice_mute'):
raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']})
- if not hidden and infr_type in ('note', ):
+ if not hidden and infr_type in ('note',):
raise ValidationError({'hidden': [f'{infr_type} infractions must be hidden.']})
return attrs
@@ -250,6 +541,26 @@ class ReminderSerializer(ModelSerializer):
)
+class AocCompletionistBlockSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `AocCompletionistBlock` instances."""
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = AocCompletionistBlock
+ fields = ("user", "is_blocked", "reason")
+
+
+class AocAccountLinkSerializer(ModelSerializer):
+ """A class providing (de-)serialization of `AocAccountLink` instances."""
+
+ class Meta:
+ """Metadata defined for the Django REST Framework."""
+
+ model = AocAccountLink
+ fields = ("user", "aoc_username")
+
+
class RoleSerializer(ModelSerializer):
"""A class providing (de-)serialization of `Role` instances."""
@@ -382,7 +693,15 @@ class NominationSerializer(ModelSerializer):
model = Nomination
fields = (
- 'id', 'active', 'user', 'inserted_at', 'end_reason', 'ended_at', 'reviewed', 'entries'
+ 'id',
+ 'active',
+ 'user',
+ 'inserted_at',
+ 'end_reason',
+ 'ended_at',
+ 'reviewed',
+ 'entries',
+ 'thread_id'
)
diff --git a/pydis_site/apps/api/tests/base.py b/pydis_site/apps/api/tests/base.py
index c9f3cb7e..704b22cf 100644
--- a/pydis_site/apps/api/tests/base.py
+++ b/pydis_site/apps/api/tests/base.py
@@ -61,6 +61,7 @@ class AuthenticatedAPITestCase(APITestCase):
... self.assertEqual(response.status_code, 200)
"""
- def setUp(self):
+ def setUp(self) -> None:
+ """Bootstrap the user and authenticate it."""
super().setUp()
self.client.force_authenticate(test_user)
diff --git a/pydis_site/apps/api/tests/migrations/__init__.py b/pydis_site/apps/api/tests/migrations/__init__.py
deleted file mode 100644
index 38e42ffc..00000000
--- a/pydis_site/apps/api/tests/migrations/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""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
deleted file mode 100644
index 0c0a5bd0..00000000
--- a/pydis_site/apps/api/tests/migrations/base.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""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
deleted file mode 100644
index 8dc29b34..00000000
--- a/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py
+++ /dev/null
@@ -1,496 +0,0 @@
-"""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
deleted file mode 100644
index f69bc92c..00000000
--- a/pydis_site/apps/api/tests/migrations/test_base.py
+++ /dev/null
@@ -1,135 +0,0 @@
-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_bumped_threads.py b/pydis_site/apps/api/tests/test_bumped_threads.py
new file mode 100644
index 00000000..2e3892c7
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_bumped_threads.py
@@ -0,0 +1,63 @@
+from django.urls import reverse
+
+from .base import AuthenticatedAPITestCase
+from pydis_site.apps.api.models import BumpedThread
+
+
+class UnauthedBumpedThreadAPITests(AuthenticatedAPITestCase):
+ def setUp(self):
+ super().setUp()
+ self.client.force_authenticate(user=None)
+
+ def test_detail_lookup_returns_401(self):
+ url = reverse('api:bot:bumpedthread-detail', args=(1,))
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_list_returns_401(self):
+ url = reverse('api:bot:bumpedthread-list')
+ response = self.client.get(url)
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_create_returns_401(self):
+ url = reverse('api:bot:bumpedthread-list')
+ response = self.client.post(url, {"thread_id": 3})
+
+ self.assertEqual(response.status_code, 401)
+
+ def test_delete_returns_401(self):
+ url = reverse('api:bot:bumpedthread-detail', args=(1,))
+ response = self.client.delete(url)
+
+ self.assertEqual(response.status_code, 401)
+
+
+class BumpedThreadAPITests(AuthenticatedAPITestCase):
+ @classmethod
+ def setUpTestData(cls):
+ cls.thread1 = BumpedThread.objects.create(
+ thread_id=1234,
+ )
+
+ def test_returns_bumped_threads_as_flat_list(self):
+ url = reverse('api:bot:bumpedthread-list')
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), [1234])
+
+ def test_returns_204_for_existing_data(self):
+ url = reverse('api:bot:bumpedthread-detail', args=(1234,))
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 204)
+ self.assertEqual(response.content, b"")
+
+ def test_returns_404_for_non_existing_data(self):
+ url = reverse('api:bot:bumpedthread-detail', args=(42,))
+
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response.json(), {"detail": "Not found."})
diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py
index 1eb535d8..62d17e58 100644
--- a/pydis_site/apps/api/tests/test_deleted_messages.py
+++ b/pydis_site/apps/api/tests/test_deleted_messages.py
@@ -1,10 +1,9 @@
-from datetime import datetime
+from datetime import datetime, timezone
from django.urls import reverse
-from django.utils import timezone
from .base import AuthenticatedAPITestCase
-from ..models import MessageDeletionContext, User
+from pydis_site.apps.api.models import MessageDeletionContext, User
class DeletedMessagesWithoutActorTests(AuthenticatedAPITestCase):
@@ -18,7 +17,7 @@ class DeletedMessagesWithoutActorTests(AuthenticatedAPITestCase):
cls.data = {
'actor': None,
- 'creation': datetime.utcnow().isoformat(),
+ 'creation': datetime.now(tz=timezone.utc).isoformat(),
'deletedmessage_set': [
{
'author': cls.author.id,
@@ -58,7 +57,7 @@ class DeletedMessagesWithActorTests(AuthenticatedAPITestCase):
cls.data = {
'actor': cls.actor.id,
- 'creation': datetime.utcnow().isoformat(),
+ 'creation': datetime.now(tz=timezone.utc).isoformat(),
'deletedmessage_set': [
{
'author': cls.author.id,
@@ -90,7 +89,7 @@ class DeletedMessagesLogURLTests(AuthenticatedAPITestCase):
cls.deletion_context = MessageDeletionContext.objects.create(
actor=cls.actor,
- creation=timezone.now()
+ creation=datetime.now(tz=timezone.utc),
)
def test_valid_log_url(self):
diff --git a/pydis_site/apps/api/tests/test_documentation_links.py b/pydis_site/apps/api/tests/test_documentation_links.py
index 4e238cbb..f4a332cb 100644
--- a/pydis_site/apps/api/tests/test_documentation_links.py
+++ b/pydis_site/apps/api/tests/test_documentation_links.py
@@ -1,7 +1,7 @@
from django.urls import reverse
from .base import AuthenticatedAPITestCase
-from ..models import DocumentationLink
+from pydis_site.apps.api.models import DocumentationLink
class UnauthedDocumentationLinkAPITests(AuthenticatedAPITestCase):
diff --git a/pydis_site/apps/api/tests/test_filterlists.py b/pydis_site/apps/api/tests/test_filterlists.py
deleted file mode 100644
index 5a5bca60..00000000
--- a/pydis_site/apps/api/tests/test_filterlists.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from django.urls import reverse
-
-from pydis_site.apps.api.models import FilterList
-from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase
-
-URL = reverse('api:bot:filterlist-list')
-JPEG_ALLOWLIST = {
- "type": 'FILE_FORMAT',
- "allowed": True,
- "content": ".jpeg",
-}
-PNG_ALLOWLIST = {
- "type": 'FILE_FORMAT',
- "allowed": True,
- "content": ".png",
-}
-
-
-class UnauthenticatedTests(AuthenticatedAPITestCase):
- 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(AuthenticatedAPITestCase):
- @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(AuthenticatedAPITestCase):
- @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(AuthenticatedAPITestCase):
- @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(AuthenticatedAPITestCase):
- @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_filters.py b/pydis_site/apps/api/tests/test_filters.py
new file mode 100644
index 00000000..4cef1c8f
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_filters.py
@@ -0,0 +1,352 @@
+import contextlib
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import Any
+
+from django.db.models import Model
+from django.urls import reverse
+
+from pydis_site.apps.api.models.bot.filters import Filter, FilterList
+from pydis_site.apps.api.tests.base import AuthenticatedAPITestCase
+
+
+@dataclass()
+class TestSequence:
+ model: type[Model]
+ route: str
+ object: dict[str, Any]
+ ignored_fields: tuple[str, ...] = ()
+
+ def url(self, detail: bool = False) -> str:
+ return reverse(f'api:bot:{self.route}-{"detail" if detail else "list"}')
+
+
+FK_FIELDS: dict[type[Model], tuple[str, ...]] = {
+ FilterList: (),
+ Filter: ("filter_list",),
+}
+
+
+def get_test_sequences() -> dict[str, TestSequence]:
+ filter_list1_deny_dict = {
+ "name": "testname",
+ "list_type": 0,
+ "guild_pings": [],
+ "filter_dm": True,
+ "dm_pings": [],
+ "remove_context": False,
+ "bypass_roles": [],
+ "enabled": True,
+ "dm_content": "",
+ "dm_embed": "",
+ "infraction_type": "NONE",
+ "infraction_reason": "",
+ "infraction_duration": timedelta(seconds=0),
+ "infraction_channel": 0,
+ "disabled_channels": [],
+ "disabled_categories": [],
+ "enabled_channels": [],
+ "enabled_categories": [],
+ "send_alert": True
+ }
+ filter_list1_allow_dict = filter_list1_deny_dict.copy()
+ filter_list1_allow_dict["list_type"] = 1
+ filter_list1_allow = FilterList(**filter_list1_allow_dict)
+
+ return {
+ "filter_list1": TestSequence(
+ FilterList,
+ "filterlist",
+ filter_list1_deny_dict,
+ ignored_fields=("filters", "created_at", "updated_at")
+ ),
+ "filter_list2": TestSequence(
+ FilterList,
+ "filterlist",
+ {
+ "name": "testname2",
+ "list_type": 1,
+ "guild_pings": ["Moderators"],
+ "filter_dm": False,
+ "dm_pings": ["here"],
+ "remove_context": True,
+ "bypass_roles": ["123456"],
+ "enabled": False,
+ "dm_content": "testing testing",
+ "dm_embed": "one two three",
+ "infraction_type": "TIMEOUT",
+ "infraction_reason": "stop testing",
+ "infraction_duration": timedelta(seconds=10.5),
+ "infraction_channel": 123,
+ "disabled_channels": ["python-general"],
+ "disabled_categories": ["CODE JAM"],
+ "enabled_channels": ["mighty-mice"],
+ "enabled_categories": ["Lobby"],
+ "send_alert": False
+ },
+ ignored_fields=("filters", "created_at", "updated_at")
+ ),
+ "filter": TestSequence(
+ Filter,
+ "filter",
+ {
+ "content": "bad word",
+ "description": "This is a really bad word.",
+ "additional_settings": "{'hi': 'there'}",
+ "guild_pings": None,
+ "filter_dm": None,
+ "dm_pings": None,
+ "remove_context": None,
+ "bypass_roles": None,
+ "enabled": None,
+ "dm_content": None,
+ "dm_embed": None,
+ "infraction_type": None,
+ "infraction_reason": None,
+ "infraction_duration": None,
+ "infraction_channel": None,
+ "disabled_channels": None,
+ "disabled_categories": None,
+ "enabled_channels": None,
+ "enabled_categories": None,
+ "send_alert": None,
+ "filter_list": filter_list1_allow
+ },
+ ignored_fields=("created_at", "updated_at")
+ ),
+ }
+
+
+def save_nested_objects(object_: Model, save_root: bool = True) -> None:
+ for field in FK_FIELDS.get(object_.__class__, ()):
+ value = getattr(object_, field)
+ save_nested_objects(value)
+
+ if save_root:
+ object_.save()
+
+
+def clean_test_json(json: dict) -> dict:
+ for key, value in json.items():
+ if isinstance(value, Model):
+ json[key] = value.id
+ elif isinstance(value, timedelta):
+ json[key] = str(value.total_seconds())
+
+ return json
+
+
+def clean_api_json(json: dict, sequence: TestSequence) -> dict:
+ for field in sequence.ignored_fields + ("id",):
+ with contextlib.suppress(KeyError):
+ del json[field]
+
+ return json
+
+
+def flatten_settings(json: dict) -> dict:
+ settings = json.pop("settings", {})
+ flattened_settings = {}
+ for entry, value in settings.items():
+ if isinstance(value, dict):
+ flattened_settings.update(value)
+ else:
+ flattened_settings[entry] = value
+
+ json.update(flattened_settings)
+
+ return json
+
+
+class GenericFilterTests(AuthenticatedAPITestCase):
+
+ def test_cannot_read_unauthenticated(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ self.client.force_authenticate(user=None)
+
+ response = self.client.get(sequence.url())
+ self.assertEqual(response.status_code, 401)
+
+ def test_empty_database(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ response = self.client.get(sequence.url())
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json(), [])
+
+ def test_fetch(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ save_nested_objects(sequence.model(**sequence.object))
+
+ response = self.client.get(sequence.url())
+ self.assertDictEqual(
+ clean_test_json(sequence.object),
+ clean_api_json(flatten_settings(response.json()[0]), sequence)
+ )
+
+ def test_fetch_by_id(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ saved = sequence.model(**sequence.object)
+ save_nested_objects(saved)
+
+ response = self.client.get(f"{sequence.url()}/{saved.id}")
+ self.assertDictEqual(
+ clean_test_json(sequence.object),
+ clean_api_json(flatten_settings(response.json()), sequence)
+ )
+
+ def test_fetch_non_existing(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ response = self.client.get(f"{sequence.url()}/42")
+ self.assertEqual(response.status_code, 404)
+ self.assertDictEqual(response.json(), {'detail': 'Not found.'})
+
+ def test_creation(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ save_nested_objects(sequence.model(**sequence.object), False)
+ data = clean_test_json(sequence.object.copy())
+ response = self.client.post(sequence.url(), data=data)
+
+ self.assertEqual(response.status_code, 201)
+ self.assertDictEqual(
+ clean_api_json(flatten_settings(response.json()), sequence),
+ clean_test_json(sequence.object)
+ )
+
+ def test_creation_missing_field(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ ignored_fields = sequence.ignored_fields + ("id", "additional_settings")
+ with self.subTest(name=name):
+ saved = sequence.model(**sequence.object)
+ save_nested_objects(saved)
+ data = clean_test_json(sequence.object.copy())
+
+ for field in sequence.model._meta.get_fields():
+ with self.subTest(field=field):
+ if field.null or field.name in ignored_fields:
+ continue
+
+ test_data = data.copy()
+ del test_data[field.name]
+
+ response = self.client.post(sequence.url(), data=test_data)
+ self.assertEqual(response.status_code, 400)
+
+ def test_deletion(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ saved = sequence.model(**sequence.object)
+ save_nested_objects(saved)
+
+ response = self.client.delete(f"{sequence.url()}/{saved.id}")
+ self.assertEqual(response.status_code, 204)
+
+ def test_deletion_non_existing(self) -> None:
+ for name, sequence in get_test_sequences().items():
+ with self.subTest(name=name):
+ sequence.model.objects.all().delete()
+
+ response = self.client.delete(f"{sequence.url()}/42")
+ self.assertEqual(response.status_code, 404)
+
+
+class FilterValidationTests(AuthenticatedAPITestCase):
+
+ def test_filter_validation(self) -> None:
+ test_sequences = get_test_sequences()
+ base_filter = test_sequences["filter"]
+ base_filter_list = test_sequences["filter_list1"]
+ cases = (
+ ({"infraction_reason": "hi"}, {}, 400),
+ ({"infraction_duration": timedelta(seconds=10)}, {}, 400),
+ ({"infraction_reason": "hi"}, {"infraction_type": "NOTE"}, 200),
+ ({"infraction_type": "TIMEOUT", "infraction_duration": timedelta(days=30)}, {}, 400),
+ ({"infraction_duration": timedelta(seconds=10)}, {"infraction_type": "TIMEOUT"}, 200),
+ ({"enabled_channels": ["admins"]}, {}, 200),
+ ({"disabled_channels": ["123"]}, {}, 200),
+ ({"enabled_categories": ["CODE JAM"]}, {}, 200),
+ ({"disabled_categories": ["CODE JAM"]}, {}, 200),
+ ({"enabled_channels": ["admins"], "disabled_channels": ["123", "admins"]}, {}, 400),
+ ({"enabled_categories": ["admins"], "disabled_categories": ["123", "admins"]}, {}, 400),
+ ({"enabled_channels": ["admins"]}, {"disabled_channels": ["123", "admins"]}, 400),
+ ({"enabled_categories": ["admins"]}, {"disabled_categories": ["123", "admins"]}, 400),
+ )
+
+ for filter_settings, filter_list_settings, response_code in cases:
+ with self.subTest(
+ f_settings=filter_settings, fl_settings=filter_list_settings, response=response_code
+ ):
+ base_filter.model.objects.all().delete()
+ base_filter_list.model.objects.all().delete()
+
+ case_filter_dict = base_filter.object.copy()
+ case_fl_dict = base_filter_list.object.copy()
+ case_fl_dict.update(filter_list_settings)
+
+ case_fl = base_filter_list.model(**case_fl_dict)
+ case_filter_dict["filter_list"] = case_fl
+ case_filter = base_filter.model(**case_filter_dict)
+ save_nested_objects(case_filter)
+
+ filter_settings["filter_list"] = case_fl
+ response = self.client.patch(
+ f"{base_filter.url()}/{case_filter.id}", data=clean_test_json(filter_settings)
+ )
+ self.assertEqual(response.status_code, response_code)
+
+ def test_filter_list_validation(self) -> None:
+ test_sequences = get_test_sequences()
+ base_filter_list = test_sequences["filter_list1"]
+ cases = (
+ ({"infraction_reason": "hi"}, 400),
+ ({"infraction_duration": timedelta(seconds=10)}, 400),
+ ({"infraction_type": "TIMEOUT", "infraction_duration": timedelta(days=30)}, 400),
+ ({"infraction_reason": "hi", "infraction_type": "NOTE"}, 200),
+ ({"infraction_duration": timedelta(seconds=10), "infraction_type": "TIMEOUT"}, 200),
+ ({"enabled_channels": ["admins"]}, 200), ({"disabled_channels": ["123"]}, 200),
+ ({"enabled_categories": ["CODE JAM"]}, 200),
+ ({"disabled_categories": ["CODE JAM"]}, 200),
+ ({"enabled_channels": ["admins"], "disabled_channels": ["123", "admins"]}, 400),
+ ({"enabled_categories": ["admins"], "disabled_categories": ["123", "admins"]}, 400),
+ )
+
+ for filter_list_settings, response_code in cases:
+ with self.subTest(fl_settings=filter_list_settings, response=response_code):
+ base_filter_list.model.objects.all().delete()
+
+ case_fl_dict = base_filter_list.object.copy()
+ case_fl = base_filter_list.model(**case_fl_dict)
+ save_nested_objects(case_fl)
+
+ response = self.client.patch(
+ f"{base_filter_list.url()}/{case_fl.id}",
+ data=clean_test_json(filter_list_settings)
+ )
+ self.assertEqual(response.status_code, response_code)
+
+ def test_filter_unique_constraint(self) -> None:
+ test_filter = get_test_sequences()["filter"]
+ test_filter.model.objects.all().delete()
+ test_filter_object = test_filter.model(**test_filter.object)
+ save_nested_objects(test_filter_object, False)
+
+ response = self.client.post(test_filter.url(), data=clean_test_json(test_filter.object))
+ self.assertEqual(response.status_code, 201)
+
+ response = self.client.post(test_filter.url(), data=clean_test_json(test_filter.object))
+ self.assertEqual(response.status_code, 400)
diff --git a/pydis_site/apps/api/tests/test_github_utils.py b/pydis_site/apps/api/tests/test_github_utils.py
new file mode 100644
index 00000000..34fae875
--- /dev/null
+++ b/pydis_site/apps/api/tests/test_github_utils.py
@@ -0,0 +1,289 @@
+import dataclasses
+import datetime
+import typing
+import unittest
+from unittest import mock
+
+import django.test
+import httpx
+import jwt
+import rest_framework.response
+import rest_framework.test
+from django.urls import reverse
+
+from pydis_site import settings
+from pydis_site.apps.api import github_utils
+
+
+class GeneralUtilityTests(unittest.TestCase):
+ """Test the utility methods which do not fit in another class."""
+
+ def test_token_generation(self):
+ """Test that the a valid JWT token is generated."""
+ def encode(payload: dict, _: str, algorithm: str, *args, **kwargs) -> str:
+ """
+ Intercept the encode method.
+
+ The result is encoded with an algorithm which does not require a PEM key, as it may
+ not be available in testing environments.
+ """
+ self.assertEqual("RS256", algorithm, "The GitHub App JWT must be signed using RS256.")
+ return original_encode(
+ payload, "secret-encoding-key", *args, algorithm="HS256", **kwargs
+ )
+
+ original_encode = jwt.encode
+ with mock.patch("jwt.encode", new=encode):
+ token = github_utils.generate_token()
+ decoded = jwt.decode(token, "secret-encoding-key", algorithms=["HS256"])
+
+ delta = datetime.timedelta(minutes=10)
+ self.assertAlmostEqual(decoded["exp"] - decoded["iat"], delta.total_seconds())
+ then = datetime.datetime.now(tz=datetime.timezone.utc) + delta
+ self.assertLess(decoded["exp"], then.timestamp())
+
+
+class CheckRunTests(unittest.TestCase):
+ """Tests the check_run_status utility."""
+
+ run_kwargs: typing.Mapping = {
+ "name": "run_name",
+ "head_sha": "sha",
+ "status": "completed",
+ "conclusion": "success",
+ "created_at": datetime.datetime.now(tz=datetime.timezone.utc).strftime(settings.GITHUB_TIMESTAMP_FORMAT),
+ "artifacts_url": "url",
+ }
+
+ def test_completed_run(self):
+ """Test that an already completed run returns the correct URL."""
+ final_url = "some_url_string_1234"
+
+ kwargs = dict(self.run_kwargs, artifacts_url=final_url)
+ result = github_utils.check_run_status(github_utils.WorkflowRun(**kwargs))
+ self.assertEqual(final_url, result)
+
+ def test_pending_run(self):
+ """Test that a pending run raises the proper exception."""
+ kwargs = dict(self.run_kwargs, status="pending")
+ with self.assertRaises(github_utils.RunPendingError):
+ github_utils.check_run_status(github_utils.WorkflowRun(**kwargs))
+
+ def test_timeout_error(self):
+ """Test that a timeout is declared after a certain duration."""
+ kwargs = dict(self.run_kwargs, status="pending")
+ # Set the creation time to well before the MAX_RUN_TIME
+ # to guarantee the right conclusion
+ kwargs["created_at"] = (
+ datetime.datetime.now(tz=datetime.timezone.utc)
+ - github_utils.MAX_RUN_TIME - datetime.timedelta(minutes=10)
+ ).strftime(settings.GITHUB_TIMESTAMP_FORMAT)
+
+ with self.assertRaises(github_utils.RunTimeoutError):
+ github_utils.check_run_status(github_utils.WorkflowRun(**kwargs))
+
+ def test_failed_run(self):
+ """Test that a failed run raises the proper exception."""
+ kwargs = dict(self.run_kwargs, conclusion="failed")
+ with self.assertRaises(github_utils.ActionFailedError):
+ github_utils.check_run_status(github_utils.WorkflowRun(**kwargs))
+
+
+def get_response_authorize(_: httpx.Client, request: httpx.Request, **__) -> httpx.Response:
+ """
+ Helper method for the authorize tests.
+
+ Requests are intercepted before being sent out, and the appropriate responses are returned.
+ """
+ path = request.url.path
+ auth = request.headers.get("Authorization")
+
+ if request.method == "GET":
+ if path == "/app/installations":
+ if auth == "bearer JWT initial token":
+ return httpx.Response(200, request=request, json=[{
+ "account": {"login": "VALID_OWNER"},
+ "access_tokens_url": "https://example.com/ACCESS_TOKEN_URL"
+ }])
+ return httpx.Response(
+ 401, json={"error": "auth app/installations"}, request=request
+ )
+
+ elif path == "/installation/repositories": # noqa: RET505
+ if auth == "bearer app access token":
+ return httpx.Response(200, request=request, json={
+ "repositories": [{
+ "name": "VALID_REPO"
+ }]
+ })
+ return httpx.Response( # pragma: no cover
+ 401, json={"error": "auth installation/repositories"}, request=request
+ )
+
+ elif request.method == "POST": # noqa: RET505
+ if path == "/ACCESS_TOKEN_URL":
+ if auth == "bearer JWT initial token":
+ return httpx.Response(200, request=request, json={"token": "app access token"})
+ return httpx.Response(401, json={"error": "auth access_token"}, request=request) # pragma: no cover
+
+ # Reaching this point means something has gone wrong
+ return httpx.Response(500, request=request) # pragma: no cover
+
+
[email protected]("httpx.Client.send", new=get_response_authorize)
[email protected](github_utils, "generate_token", new=mock.Mock(return_value="JWT initial token"))
+class AuthorizeTests(unittest.TestCase):
+ """Test the authorize utility."""
+
+ def test_invalid_apps_auth(self):
+ """Test that an exception is raised if authorization was attempted with an invalid token."""
+ with mock.patch.object(github_utils, "generate_token", return_value="Invalid token"): # noqa: SIM117
+ with self.assertRaises(httpx.HTTPStatusError) as error:
+ github_utils.authorize("VALID_OWNER", "VALID_REPO")
+
+ exception: httpx.HTTPStatusError = error.exception
+ self.assertEqual(401, exception.response.status_code)
+ self.assertEqual("auth app/installations", exception.response.json()["error"])
+
+ def test_missing_repo(self):
+ """Test that an exception is raised when the selected owner or repo are not available."""
+ with self.assertRaises(github_utils.NotFoundError):
+ github_utils.authorize("INVALID_OWNER", "VALID_REPO")
+ with self.assertRaises(github_utils.NotFoundError):
+ github_utils.authorize("VALID_OWNER", "INVALID_REPO")
+
+ def test_valid_authorization(self):
+ """Test that an accessible repository can be accessed."""
+ client = github_utils.authorize("VALID_OWNER", "VALID_REPO")
+ self.assertEqual("bearer app access token", client.headers.get("Authorization"))
+
+
+class ArtifactFetcherTests(unittest.TestCase):
+ """Test the get_artifact utility."""
+
+ @staticmethod
+ def get_response_get_artifact(request: httpx.Request, **_) -> httpx.Response:
+ """
+ Helper method for the get_artifact tests.
+
+ Requests are intercepted before being sent out, and the appropriate responses are returned.
+ """
+ path = request.url.path
+
+ if "force_error" in path:
+ return httpx.Response(404, request=request)
+
+ if request.method == "GET":
+ if path == "/repos/owner/repo/actions/runs":
+ run = github_utils.WorkflowRun(
+ name="action_name",
+ head_sha="action_sha",
+ created_at=(
+ datetime.datetime
+ .now(tz=datetime.timezone.utc)
+ .strftime(settings.GITHUB_TIMESTAMP_FORMAT)
+ ),
+ status="completed",
+ conclusion="success",
+ artifacts_url="artifacts_url"
+ )
+ return httpx.Response(
+ 200, request=request, json={"workflow_runs": [dataclasses.asdict(run)]}
+ )
+ elif path == "/artifact_url": # noqa: RET505
+ return httpx.Response(
+ 200, request=request, json={"artifacts": [{
+ "name": "artifact_name",
+ "archive_download_url": "artifact_download_url"
+ }]}
+ )
+ elif path == "/artifact_download_url":
+ response = httpx.Response(302, request=request)
+ response.next_request = httpx.Request(
+ "GET",
+ httpx.URL("https://final_download.url")
+ )
+ return response
+
+ # Reaching this point means something has gone wrong
+ return httpx.Response(500, request=request) # pragma: no cover
+
+ def setUp(self) -> None:
+ self.call_args = ["owner", "repo", "action_sha", "action_name", "artifact_name"]
+ self.client = httpx.Client(base_url="https://example.com")
+
+ self.patchers = [
+ mock.patch.object(self.client, "send", new=self.get_response_get_artifact),
+ mock.patch.object(github_utils, "authorize", return_value=self.client),
+ mock.patch.object(github_utils, "check_run_status", return_value="artifact_url"),
+ ]
+
+ for patcher in self.patchers:
+ patcher.start()
+
+ def tearDown(self) -> None:
+ for patcher in self.patchers:
+ patcher.stop()
+
+ def test_client_closed_on_errors(self):
+ """Test that the client is terminated even if an error occurs at some point."""
+ self.call_args[0] = "force_error"
+ with self.assertRaises(httpx.HTTPStatusError):
+ github_utils.get_artifact(*self.call_args)
+ self.assertTrue(self.client.is_closed)
+
+ def test_missing(self):
+ """Test that an exception is raised if the requested artifact was not found."""
+ cases = (
+ "invalid sha",
+ "invalid action name",
+ "invalid artifact name",
+ )
+ for i, name in enumerate(cases, 2):
+ with self.subTest(f"Test {name} raises an error"):
+ new_args = self.call_args.copy()
+ new_args[i] = name
+
+ with self.assertRaises(github_utils.NotFoundError):
+ github_utils.get_artifact(*new_args)
+
+ def test_valid(self):
+ """Test that the correct download URL is returned for valid requests."""
+ url = github_utils.get_artifact(*self.call_args)
+ self.assertEqual("https://final_download.url", url)
+ self.assertTrue(self.client.is_closed)
+
+
[email protected](github_utils, "get_artifact")
+class GitHubArtifactViewTests(django.test.TestCase):
+ """Test the GitHub artifact fetch API view."""
+
+ def setUp(self):
+ self.kwargs = {
+ "owner": "test_owner",
+ "repo": "test_repo",
+ "sha": "test_sha",
+ "action_name": "test_action",
+ "artifact_name": "test_artifact",
+ }
+ self.url = reverse("api:github-artifacts", kwargs=self.kwargs)
+
+ def test_correct_artifact(self, artifact_mock: mock.Mock):
+ """Test a proper response is returned with proper input."""
+ artifact_mock.return_value = "final download url"
+ result = self.client.get(self.url)
+
+ self.assertIsInstance(result, rest_framework.response.Response)
+ self.assertEqual({"url": artifact_mock.return_value}, result.data)
+
+ def test_failed_fetch(self, artifact_mock: mock.Mock):
+ """Test that a proper error is returned when the request fails."""
+ artifact_mock.side_effect = github_utils.NotFoundError("Test error message")
+ result = self.client.get(self.url)
+
+ self.assertIsInstance(result, rest_framework.response.Response)
+ self.assertEqual({
+ "error_type": github_utils.NotFoundError.__name__,
+ "error": "Test error message",
+ "requested_resource": "/".join(self.kwargs.values())
+ }, result.data)
diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py
index f1107734..71611ee9 100644
--- a/pydis_site/apps/api/tests/test_infractions.py
+++ b/pydis_site/apps/api/tests/test_infractions.py
@@ -8,8 +8,8 @@ from django.db.utils import IntegrityError
from django.urls import reverse
from .base import AuthenticatedAPITestCase
-from ..models import Infraction, User
-from ..serializers import InfractionSerializer
+from pydis_site.apps.api.models import Infraction, User
+from pydis_site.apps.api.serializers import InfractionSerializer
class UnauthenticatedTests(AuthenticatedAPITestCase):
@@ -56,23 +56,26 @@ class InfractionTests(AuthenticatedAPITestCase):
type='ban',
reason='He terk my jerb!',
hidden=True,
+ inserted_at=dt(2020, 10, 10, 0, 0, 0, tzinfo=timezone.utc),
expires_at=dt(5018, 11, 20, 15, 52, tzinfo=timezone.utc),
- active=True
+ active=True,
)
cls.ban_inactive = Infraction.objects.create(
user_id=cls.user.id,
actor_id=cls.user.id,
type='ban',
reason='James is an ass, and we won\'t be working with him again.',
- active=False
+ active=False,
+ inserted_at=dt(2020, 10, 10, 0, 1, 0, tzinfo=timezone.utc),
)
- cls.mute_permanent = Infraction.objects.create(
+ cls.timeout_permanent = Infraction.objects.create(
user_id=cls.user.id,
actor_id=cls.user.id,
- type='mute',
+ type='timeout',
reason='He has a filthy mouth and I am his soap.',
active=True,
- expires_at=None
+ inserted_at=dt(2020, 10, 10, 0, 2, 0, tzinfo=timezone.utc),
+ expires_at=None,
)
cls.superstar_expires_soon = Infraction.objects.create(
user_id=cls.user.id,
@@ -80,7 +83,8 @@ class InfractionTests(AuthenticatedAPITestCase):
type='superstar',
reason='This one doesn\'t matter anymore.',
active=True,
- expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5)
+ inserted_at=dt(2020, 10, 10, 0, 3, 0, tzinfo=timezone.utc),
+ expires_at=dt.now(timezone.utc) + datetime.timedelta(hours=5),
)
cls.voiceban_expires_later = Infraction.objects.create(
user_id=cls.user.id,
@@ -88,7 +92,8 @@ class InfractionTests(AuthenticatedAPITestCase):
type='voice_ban',
reason='Jet engine mic',
active=True,
- expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5)
+ inserted_at=dt(2020, 10, 10, 0, 4, 0, tzinfo=timezone.utc),
+ expires_at=dt.now(timezone.utc) + datetime.timedelta(days=5),
)
def test_list_all(self):
@@ -102,7 +107,7 @@ class InfractionTests(AuthenticatedAPITestCase):
self.assertEqual(len(infractions), 5)
self.assertEqual(infractions[0]['id'], self.voiceban_expires_later.id)
self.assertEqual(infractions[1]['id'], self.superstar_expires_soon.id)
- self.assertEqual(infractions[2]['id'], self.mute_permanent.id)
+ self.assertEqual(infractions[2]['id'], self.timeout_permanent.id)
self.assertEqual(infractions[3]['id'], self.ban_inactive.id)
self.assertEqual(infractions[4]['id'], self.ban_hidden.id)
@@ -129,7 +134,7 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_filter_permanent_false(self):
url = reverse('api:bot:infraction-list')
- response = self.client.get(f'{url}?type=mute&permanent=false')
+ response = self.client.get(f'{url}?type=timeout&permanent=false')
self.assertEqual(response.status_code, 200)
infractions = response.json()
@@ -138,17 +143,17 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_filter_permanent_true(self):
url = reverse('api:bot:infraction-list')
- response = self.client.get(f'{url}?type=mute&permanent=true')
+ response = self.client.get(f'{url}?type=timeout&permanent=true')
self.assertEqual(response.status_code, 200)
infractions = response.json()
- self.assertEqual(infractions[0]['id'], self.mute_permanent.id)
+ self.assertEqual(infractions[0]['id'], self.timeout_permanent.id)
def test_filter_after(self):
url = reverse('api:bot:infraction-list')
- target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5)
- response = self.client.get(f'{url}?type=superstar&expires_after={target_time.isoformat()}')
+ target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5)
+ response = self.client.get(url, {'type': 'superstar', 'expires_after': target_time.isoformat()})
self.assertEqual(response.status_code, 200)
infractions = response.json()
@@ -156,8 +161,8 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_filter_before(self):
url = reverse('api:bot:infraction-list')
- target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5)
- response = self.client.get(f'{url}?type=superstar&expires_before={target_time.isoformat()}')
+ target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5)
+ response = self.client.get(url, {'type': 'superstar', 'expires_before': target_time.isoformat()})
self.assertEqual(response.status_code, 200)
infractions = response.json()
@@ -180,11 +185,12 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_after_before_before(self):
url = reverse('api:bot:infraction-list')
- target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=4)
- target_time_late = datetime.datetime.utcnow() + datetime.timedelta(hours=6)
+ target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=4)
+ target_time_late = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=6)
response = self.client.get(
- f'{url}?expires_before={target_time_late.isoformat()}'
- f'&expires_after={target_time.isoformat()}'
+ url,
+ {'expires_before': target_time_late.isoformat(),
+ 'expires_after': target_time.isoformat()},
)
self.assertEqual(response.status_code, 200)
@@ -193,11 +199,12 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_after_after_before_invalid(self):
url = reverse('api:bot:infraction-list')
- target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5)
- target_time_late = datetime.datetime.utcnow() + datetime.timedelta(hours=9)
+ target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5)
+ target_time_late = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=9)
response = self.client.get(
- f'{url}?expires_before={target_time.isoformat()}'
- f'&expires_after={target_time_late.isoformat()}'
+ url,
+ {'expires_before': target_time.isoformat(),
+ 'expires_after': target_time_late.isoformat()},
)
self.assertEqual(response.status_code, 400)
@@ -207,8 +214,11 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_permanent_after_invalid(self):
url = reverse('api:bot:infraction-list')
- target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5)
- response = self.client.get(f'{url}?permanent=true&expires_after={target_time.isoformat()}')
+ target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5)
+ response = self.client.get(
+ url,
+ {'permanent': 'true', 'expires_after': target_time.isoformat()},
+ )
self.assertEqual(response.status_code, 400)
errors = list(response.json())
@@ -216,8 +226,11 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_permanent_before_invalid(self):
url = reverse('api:bot:infraction-list')
- target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=5)
- response = self.client.get(f'{url}?permanent=true&expires_before={target_time.isoformat()}')
+ target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=5)
+ response = self.client.get(
+ url,
+ {'permanent': 'true', 'expires_before': target_time.isoformat()},
+ )
self.assertEqual(response.status_code, 400)
errors = list(response.json())
@@ -225,9 +238,10 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_nonpermanent_before(self):
url = reverse('api:bot:infraction-list')
- target_time = datetime.datetime.utcnow() + datetime.timedelta(hours=6)
+ target_time = datetime.datetime.now(tz=timezone.utc) + datetime.timedelta(hours=6)
response = self.client.get(
- f'{url}?permanent=false&expires_before={target_time.isoformat()}'
+ url,
+ {'permanent': 'false', 'expires_before': target_time.isoformat()},
)
self.assertEqual(response.status_code, 200)
@@ -236,7 +250,7 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_filter_manytypes(self):
url = reverse('api:bot:infraction-list')
- response = self.client.get(f'{url}?types=mute,ban')
+ response = self.client.get(f'{url}?types=timeout,ban')
self.assertEqual(response.status_code, 200)
infractions = response.json()
@@ -244,7 +258,7 @@ class InfractionTests(AuthenticatedAPITestCase):
def test_types_type_invalid(self):
url = reverse('api:bot:infraction-list')
- response = self.client.get(f'{url}?types=mute,ban&type=superstar')
+ response = self.client.get(f'{url}?types=timeout,ban&type=superstar')
self.assertEqual(response.status_code, 400)
errors = list(response.json())
@@ -514,42 +528,41 @@ class CreationTests(AuthenticatedAPITestCase):
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('api:bot:infraction-list')
- active_infraction_types = ('mute', 'ban', 'superstar')
+ active_infraction_types = ('timeout', 'ban', 'superstar')
for infraction_type in active_infraction_types:
- with self.subTest(infraction_type=infraction_type):
- with transaction.atomic():
- 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'
- }
+ with self.subTest(infraction_type=infraction_type), transaction.atomic():
+ 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'
+ # 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.'
+ ]
}
- 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."""
@@ -557,7 +570,7 @@ class CreationTests(AuthenticatedAPITestCase):
first_active_infraction = {
'user': self.user.id,
'actor': self.user.id,
- 'type': 'mute',
+ 'type': 'timeout',
'reason': 'Be silent!',
'hidden': True,
'active': True,
@@ -644,9 +657,9 @@ class CreationTests(AuthenticatedAPITestCase):
Infraction.objects.create(
user=self.user,
actor=self.user,
- type="mute",
+ type="timeout",
active=True,
- reason="The first active mute"
+ reason="The first active timeout"
)
def test_unique_constraint_accepts_active_infractions_for_different_users(self):
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index 0fad467c..1cca133d 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -6,8 +6,9 @@ from django.test import SimpleTestCase, TestCase
from pydis_site.apps.api.models import (
DeletedMessage,
DocumentationLink,
+ Filter,
+ FilterList,
Infraction,
- Message,
MessageDeletionContext,
Nomination,
NominationEntry,
@@ -105,10 +106,19 @@ class StringDunderMethodTests(SimpleTestCase):
DocumentationLink(
'test', 'http://example.com', 'http://example.com'
),
+ FilterList(
+ name="forbidden_duckies",
+ list_type=0,
+ ),
+ Filter(
+ content="ducky_nsfw",
+ description="This ducky is totally inappropriate!",
+ additional_settings=None,
+ ),
OffensiveMessage(
id=602951077675139072,
channel_id=291284109232308226,
- delete_date=dt(3000, 1, 1)
+ delete_date=dt(3000, 1, 1, tzinfo=timezone.utc)
),
OffTopicChannelName(name='bob-the-builders-playground'),
Role(
@@ -116,24 +126,13 @@ class StringDunderMethodTests(SimpleTestCase):
colour=0x5, permissions=0,
position=10,
),
- Message(
- id=45,
- author=User(
- id=444,
- name='bill',
- discriminator=5,
- ),
- channel_id=666,
- content="wooey",
- embeds=[]
- ),
MessageDeletionContext(
actor=User(
id=5555,
name='shawn',
discriminator=555,
),
- creation=dt.utcnow()
+ creation=dt.now(tz=timezone.utc)
),
User(
id=5,
diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py
index 62b2314c..ee6b1fbd 100644
--- a/pydis_site/apps/api/tests/test_nominations.py
+++ b/pydis_site/apps/api/tests/test_nominations.py
@@ -3,7 +3,7 @@ from datetime import datetime as dt, timedelta, timezone
from django.urls import reverse
from .base import AuthenticatedAPITestCase
-from ..models import Nomination, NominationEntry, User
+from pydis_site.apps.api.models import Nomination, NominationEntry, User
class CreationTests(AuthenticatedAPITestCase):
@@ -524,3 +524,35 @@ class NominationTests(AuthenticatedAPITestCase):
self.assertEqual(response.json(), {
'actor': ["The actor doesn't exist or has not nominated the user."]
})
+
+ def test_patch_nomination_set_thread_id_of_active_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,))
+ data = {'thread_id': 9876543210}
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 200)
+
+ def test_patch_nomination_set_thread_id_and_reviewed_of_active_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,))
+ data = {'thread_id': 9876543210, "reviewed": True}
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 200)
+
+ def test_modifying_thread_id_when_ending_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,))
+ data = {'thread_id': 9876543210, 'active': False, 'end_reason': "What?"}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'thread_id': ['This field cannot be set when ending a nomination.']
+ })
+
+ def test_patch_thread_id_for_inactive_nomination(self):
+ url = reverse('api:bot:nomination-detail', args=(self.inactive_nomination.id,))
+ data = {'thread_id': 9876543210}
+
+ response = self.client.patch(url, data=data)
+ self.assertEqual(response.status_code, 400)
+ self.assertEqual(response.json(), {
+ 'thread_id': ['This field cannot be set if the nomination is inactive.']
+ })
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 34098c92..315f707d 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
@@ -1,7 +1,7 @@
from django.urls import reverse
from .base import AuthenticatedAPITestCase
-from ..models import OffTopicChannelName
+from pydis_site.apps.api.models import OffTopicChannelName
class UnauthenticatedTests(AuthenticatedAPITestCase):
diff --git a/pydis_site/apps/api/tests/test_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py
index 3cf95b75..53f9cb48 100644
--- a/pydis_site/apps/api/tests/test_offensive_message.py
+++ b/pydis_site/apps/api/tests/test_offensive_message.py
@@ -3,13 +3,13 @@ import datetime
from django.urls import reverse
from .base import AuthenticatedAPITestCase
-from ..models import OffensiveMessage
+from pydis_site.apps.api.models import OffensiveMessage
class CreationTests(AuthenticatedAPITestCase):
def test_accept_valid_data(self):
url = reverse('api:bot:offensivemessage-list')
- delete_at = datetime.datetime.now() + datetime.timedelta(days=1)
+ delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005
data = {
'id': '602951077675139072',
'channel_id': '291284109232308226',
@@ -32,7 +32,7 @@ class CreationTests(AuthenticatedAPITestCase):
def test_returns_400_on_non_future_date(self):
url = reverse('api:bot:offensivemessage-list')
- delete_at = datetime.datetime.now() - datetime.timedelta(days=1)
+ delete_at = datetime.datetime.now() - datetime.timedelta(days=1) # noqa: DTZ005
data = {
'id': '602951077675139072',
'channel_id': '291284109232308226',
@@ -46,7 +46,7 @@ class CreationTests(AuthenticatedAPITestCase):
def test_returns_400_on_negative_id_or_channel_id(self):
url = reverse('api:bot:offensivemessage-list')
- delete_at = datetime.datetime.now() + datetime.timedelta(days=1)
+ delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005
data = {
'id': '602951077675139072',
'channel_id': '291284109232308226',
@@ -72,7 +72,7 @@ class CreationTests(AuthenticatedAPITestCase):
class ListTests(AuthenticatedAPITestCase):
@classmethod
def setUpTestData(cls):
- delete_at = datetime.datetime.now() + datetime.timedelta(days=1)
+ delete_at = datetime.datetime.now() + datetime.timedelta(days=1) # noqa: DTZ005
aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc)
cls.messages = [
diff --git a/pydis_site/apps/api/tests/test_reminders.py b/pydis_site/apps/api/tests/test_reminders.py
index e17569f0..9bb5fe4d 100644
--- a/pydis_site/apps/api/tests/test_reminders.py
+++ b/pydis_site/apps/api/tests/test_reminders.py
@@ -4,7 +4,7 @@ from django.forms.models import model_to_dict
from django.urls import reverse
from .base import AuthenticatedAPITestCase
-from ..models import Reminder, User
+from pydis_site.apps.api.models import Reminder, User
class UnauthedReminderAPITests(AuthenticatedAPITestCase):
@@ -59,7 +59,7 @@ class ReminderCreationTests(AuthenticatedAPITestCase):
data = {
'author': self.author.id,
'content': 'Remember to...wait what was it again?',
- 'expiration': datetime.utcnow().isoformat(),
+ 'expiration': datetime.now(tz=timezone.utc).isoformat(),
'jump_url': "https://www.google.com",
'channel_id': 123,
'mentions': [8888, 9999],
diff --git a/pydis_site/apps/api/tests/test_roles.py b/pydis_site/apps/api/tests/test_roles.py
index 73c80c77..d3031990 100644
--- a/pydis_site/apps/api/tests/test_roles.py
+++ b/pydis_site/apps/api/tests/test_roles.py
@@ -1,7 +1,7 @@
from django.urls import reverse
from .base import AuthenticatedAPITestCase
-from ..models import Role, User
+from pydis_site.apps.api.models import Role, User
class CreationTests(AuthenticatedAPITestCase):
diff --git a/pydis_site/apps/api/tests/test_rules.py b/pydis_site/apps/api/tests/test_rules.py
index d08c5fae..662fb8e9 100644
--- a/pydis_site/apps/api/tests/test_rules.py
+++ b/pydis_site/apps/api/tests/test_rules.py
@@ -1,7 +1,11 @@
+import itertools
+import re
+from pathlib import Path
+
from django.urls import reverse
from .base import AuthenticatedAPITestCase
-from ..views import RulesView
+from pydis_site.apps.api.views import RulesView
class RuleAPITests(AuthenticatedAPITestCase):
@@ -33,3 +37,37 @@ class RuleAPITests(AuthenticatedAPITestCase):
url = reverse('api:rules')
response = self.client.get(url + '?link_format=unknown')
self.assertEqual(response.status_code, 400)
+
+
+class RuleCorrectnessTests(AuthenticatedAPITestCase):
+ """Verifies that the rules from the API and by the static rules in the content app match."""
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.markdown_rule_re = re.compile(r'^> \d+\. (.*)$')
+
+ def test_rules_in_markdown_file_roughly_equal_api_rules(self) -> None:
+ url = reverse('api:rules')
+ api_response = self.client.get(url + '?link_format=md')
+ api_rules = tuple(rule for (rule, _tags) in api_response.json())
+
+ markdown_rules_path = (
+ Path(__file__).parent.parent.parent / 'content' / 'resources' / 'rules.md'
+ )
+
+ markdown_rules = []
+ for line in markdown_rules_path.read_text().splitlines():
+ matches = self.markdown_rule_re.match(line)
+ if matches is not None:
+ markdown_rules.append(matches.group(1))
+
+ zipper = itertools.zip_longest(api_rules, markdown_rules)
+ for idx, (api_rule, markdown_rule) in enumerate(zipper):
+ with self.subTest(f"Rule {idx}"):
+ self.assertIsNotNone(
+ markdown_rule, f"The API has more rules than {markdown_rules_path}"
+ )
+ self.assertIsNotNone(
+ api_rule, f"{markdown_rules_path} has more rules than the API endpoint"
+ )
+ self.assertEqual(markdown_rule, api_rule)
diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py
index 5d10069d..cff4a825 100644
--- a/pydis_site/apps/api/tests/test_users.py
+++ b/pydis_site/apps/api/tests/test_users.py
@@ -4,9 +4,9 @@ from unittest.mock import Mock, patch
from django.urls import reverse
from .base import AuthenticatedAPITestCase
-from ..models import Infraction, Role, User
-from ..models.bot.metricity import NotFoundError
-from ..viewsets.bot.user import UserListPagination
+from pydis_site.apps.api.models import Infraction, Role, User
+from pydis_site.apps.api.models.bot.metricity import NotFoundError
+from pydis_site.apps.api.viewsets.bot.user import UserListPagination
class UnauthedUserAPITests(AuthenticatedAPITestCase):
@@ -469,18 +469,17 @@ class UserMetricityTests(AuthenticatedAPITestCase):
with self.subTest(
voice_infractions=case['voice_infractions'],
voice_gate_blocked=case['voice_gate_blocked']
- ):
- with patch("pydis_site.apps.api.viewsets.bot.user.Infraction.objects.filter") as p:
- p.return_value = case['voice_infractions']
+ ), patch("pydis_site.apps.api.viewsets.bot.user.Infraction.objects.filter") as p:
+ p.return_value = case['voice_infractions']
- url = reverse('api:bot:user-metricity-data', args=[0])
- response = self.client.get(url)
+ url = reverse('api:bot:user-metricity-data', args=[0])
+ response = self.client.get(url)
- self.assertEqual(response.status_code, 200)
- self.assertEqual(
- response.json()["voice_gate_blocked"],
- case["voice_gate_blocked"]
- )
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.json()["voice_gate_blocked"],
+ case["voice_gate_blocked"]
+ )
def test_metricity_review_data(self):
# Given
@@ -502,6 +501,90 @@ class UserMetricityTests(AuthenticatedAPITestCase):
"total_messages": total_messages
})
+ def test_metricity_activity_data(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+ self.metricity.total_messages_in_past_n_days.return_value = [(0, 10)]
+
+ # When
+ url = reverse("api:bot:user-metricity-activity-data")
+ response = self.client.post(
+ url,
+ data=[0, 1],
+ QUERY_STRING="days=10",
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 200)
+ self.metricity.total_messages_in_past_n_days.assert_called_once_with(["0", "1"], 10)
+ self.assertEqual(response.json(), {"0": 10, "1": 0})
+
+ def test_metricity_activity_data_invalid_days(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+
+ # When
+ url = reverse("api:bot:user-metricity-activity-data")
+ response = self.client.post(
+ url,
+ data=[0, 1],
+ QUERY_STRING="days=fifty",
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 400)
+ self.metricity.total_messages_in_past_n_days.assert_not_called()
+ self.assertEqual(response.json(), {"days": ["This query parameter must be an integer."]})
+
+ def test_metricity_activity_data_no_days(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+
+ # When
+ url = reverse('api:bot:user-metricity-activity-data')
+ response = self.client.post(
+ url,
+ data=[0, 1],
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 400)
+ self.metricity.total_messages_in_past_n_days.assert_not_called()
+ self.assertEqual(response.json(), {'days': ["This query parameter is required."]})
+
+ def test_metricity_activity_data_no_users(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+
+ # When
+ url = reverse('api:bot:user-metricity-activity-data')
+ response = self.client.post(
+ url,
+ QUERY_STRING="days=10",
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 400)
+ self.metricity.total_messages_in_past_n_days.assert_not_called()
+ self.assertEqual(response.json(), ['Expected a list of items but got type "dict".'])
+
+ def test_metricity_activity_data_invalid_users(self):
+ # Given
+ self.mock_no_metricity_user() # Other functions shouldn't be used.
+
+ # When
+ url = reverse('api:bot:user-metricity-activity-data')
+ response = self.client.post(
+ url,
+ data=[123, 'username'],
+ QUERY_STRING="days=10",
+ )
+
+ # Then
+ self.assertEqual(response.status_code, 400)
+ self.metricity.total_messages_in_past_n_days.assert_not_called()
+ self.assertEqual(response.json(), {'1': ['A valid integer is required.']})
+
def mock_metricity_user(self, joined_at, total_messages, total_blocks, top_channel_activity):
patcher = patch("pydis_site.apps.api.viewsets.bot.user.Metricity")
self.metricity = patcher.start()
diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py
index 551cc2aa..a7ec6e38 100644
--- a/pydis_site/apps/api/tests/test_validators.py
+++ b/pydis_site/apps/api/tests/test_validators.py
@@ -3,9 +3,8 @@ 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.offensive_message import future_date_validator
-from ..models.utils import validate_embed
+from pydis_site.apps.api.models.bot.bot_setting import validate_bot_setting_name
+from pydis_site.apps.api.models.bot.offensive_message import future_date_validator
REQUIRED_KEYS = (
@@ -22,234 +21,6 @@ class BotSettingValidatorTests(TestCase):
validate_bot_setting_name('bad name')
-class TagEmbedValidatorTests(TestCase):
- def test_rejects_non_mapping(self):
- with self.assertRaises(ValidationError):
- validate_embed('non-empty non-mapping')
-
- def test_rejects_missing_required_keys(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'unknown': "key"
- })
-
- def test_rejects_one_correct_one_incorrect(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'provider': "??",
- 'title': ""
- })
-
- def test_rejects_empty_required_key(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'title': ''
- })
-
- def test_rejects_list_as_embed(self):
- with self.assertRaises(ValidationError):
- validate_embed([])
-
- def test_rejects_required_keys_and_unknown_keys(self):
- with self.assertRaises(ValidationError):
- 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_embed({
- 'title': 'a' * 257
- })
-
- def test_rejects_too_many_fields(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'fields': [{} for _ in range(26)]
- })
-
- def test_rejects_too_long_description(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'description': 'd' * 4097
- })
-
- def test_allows_valid_embed(self):
- validate_embed({
- 'title': "My embed",
- 'description': "look at my embed, my embed is amazing"
- })
-
- def test_allows_unvalidated_fields(self):
- validate_embed({
- 'title': "My embed",
- 'provider': "what am I??"
- })
-
- def test_rejects_fields_as_list_of_non_mappings(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'fields': ['abc']
- })
-
- def test_rejects_fields_with_unknown_fields(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'fields': [
- {
- 'what': "is this field"
- }
- ]
- })
-
- def test_rejects_fields_with_too_long_name(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'fields': [
- {
- 'name': "a" * 257
- }
- ]
- })
-
- def test_rejects_one_correct_one_incorrect_field(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'fields': [
- {
- 'name': "Totally valid",
- 'value': "LOOK AT ME"
- },
- {
- 'name': "Totally valid",
- 'value': "LOOK AT ME",
- 'oh': "what is this key?"
- }
- ]
- })
-
- def test_rejects_missing_required_field_field(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'fields': [
- {
- 'name': "Totally valid",
- 'inline': True,
- }
- ]
- })
-
- def test_rejects_invalid_inline_field_field(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'fields': [
- {
- 'name': "Totally valid",
- 'value': "LOOK AT ME",
- 'inline': "Totally not a boolean",
- }
- ]
- })
-
- def test_allows_valid_fields(self):
- validate_embed({
- 'fields': [
- {
- 'name': "valid",
- 'value': "field",
- },
- {
- 'name': "valid",
- 'value': "field",
- 'inline': False,
- },
- {
- 'name': "valid",
- 'value': "field",
- 'inline': True,
- },
- ]
- })
-
- def test_rejects_footer_as_non_mapping(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'title': "whatever",
- 'footer': []
- })
-
- def test_rejects_footer_with_unknown_fields(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'title': "whatever",
- 'footer': {
- 'duck': "quack"
- }
- })
-
- def test_rejects_footer_with_empty_text(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'title': "whatever",
- 'footer': {
- 'text': ""
- }
- })
-
- def test_allows_footer_with_proper_values(self):
- validate_embed({
- 'title': "whatever",
- 'footer': {
- 'text': "django good"
- }
- })
-
- def test_rejects_author_as_non_mapping(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'title': "whatever",
- 'author': []
- })
-
- def test_rejects_author_with_unknown_field(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'title': "whatever",
- 'author': {
- 'field': "that is unknown"
- }
- })
-
- def test_rejects_author_with_empty_name(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'title': "whatever",
- 'author': {
- 'name': ""
- }
- })
-
- def test_rejects_author_with_one_correct_one_incorrect(self):
- with self.assertRaises(ValidationError):
- validate_embed({
- 'title': "whatever",
- 'author': {
- # Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour
- 'url': "bobswebsite.com",
- 'name': ""
- }
- })
-
- def test_allows_author_with_proper_values(self):
- validate_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))
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index b0ab545b..f872ba92 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -1,12 +1,16 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
-from .views import HealthcheckView, RulesView
+from .views import GitHubArtifactsView, HealthcheckView, RulesView
from .viewsets import (
+ AocAccountLinkViewSet,
+ AocCompletionistBlockViewSet,
BotSettingViewSet,
+ BumpedThreadViewSet,
DeletedMessageViewSet,
DocumentationLinkViewSet,
FilterListViewSet,
+ FilterViewSet,
InfractionViewSet,
NominationViewSet,
OffTopicChannelNameViewSet,
@@ -19,14 +23,30 @@ from .viewsets import (
# https://www.django-rest-framework.org/api-guide/routers/#defaultrouter
bot_router = DefaultRouter(trailing_slash=False)
bot_router.register(
- 'filter-lists',
+ 'filter/filter_lists',
FilterListViewSet
)
bot_router.register(
+ "aoc-account-links",
+ AocAccountLinkViewSet
+)
+bot_router.register(
+ "aoc-completionist-blocks",
+ AocCompletionistBlockViewSet
+)
+bot_router.register(
+ 'filter/filters',
+ FilterViewSet
+)
+bot_router.register(
'bot-settings',
BotSettingViewSet
)
bot_router.register(
+ 'bumped-threads',
+ BumpedThreadViewSet
+)
+bot_router.register(
'deleted-messages',
DeletedMessageViewSet
)
@@ -35,6 +55,10 @@ bot_router.register(
DocumentationLinkViewSet
)
bot_router.register(
+ 'filter-lists',
+ FilterListViewSet
+)
+bot_router.register(
'infractions',
InfractionViewSet
)
@@ -71,5 +95,10 @@ urlpatterns = (
# from django_hosts.resolvers import reverse
path('bot/', include((bot_router.urls, 'api'), namespace='bot')),
path('healthcheck', HealthcheckView.as_view(), name='healthcheck'),
- path('rules', RulesView.as_view(), name='rules')
+ path('rules', RulesView.as_view(), name='rules'),
+ path(
+ 'github/artifact/<str:owner>/<str:repo>/<str:sha>/<str:action_name>/<str:artifact_name>',
+ GitHubArtifactsView.as_view(),
+ name="github-artifacts"
+ ),
)
diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py
index 816463f6..32f41667 100644
--- a/pydis_site/apps/api/views.py
+++ b/pydis_site/apps/api/views.py
@@ -1,7 +1,10 @@
from rest_framework.exceptions import ParseError
+from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
+from . import github_utils
+
class HealthcheckView(APIView):
"""
@@ -34,12 +37,14 @@ class RulesView(APIView):
## Routes
### GET /rules
- Returns a JSON array containing the server's rules:
+ Returns a JSON array containing the server's rules
+ and keywords relating to each rule.
+ Example response:
>>> [
- ... "Eat candy.",
- ... "Wake up at 4 AM.",
- ... "Take your medicine."
+ ... ["Eat candy.", ["candy", "sweets"]],
+ ... ["Wake up at 4 AM.", ["wake_up", "early", "early_bird"]],
+ ... ["Take your medicine.", ["medicine", "health"]]
... ]
Since some of the the rules require links, this view
@@ -88,7 +93,7 @@ class RulesView(APIView):
"""
if target == 'html':
return f'<a href="{link}">{description}</a>'
- elif target == 'md':
+ elif target == 'md': # noqa: RET505
return f'[{description}]({link})'
else:
raise ValueError(
@@ -96,7 +101,13 @@ class RulesView(APIView):
)
# `format` here is the result format, we have a link format here instead.
- def get(self, request, format=None): # noqa: D102,ANN001,ANN201
+ def get(self, request, format=None): # noqa: ANN001, ANN201
+ """
+ Returns a list of our community rules coupled with their keywords.
+
+ Each item in the returned list is a tuple with the rule as first item
+ and a list of keywords that match that rules as second item.
+ """
link_format = request.query_params.get('link_format', 'md')
if link_format not in ('html', 'md'):
raise ParseError(
@@ -109,7 +120,7 @@ class RulesView(APIView):
link_format
)
discord_tos = self._format_link(
- 'Terms Of Service',
+ 'Terms of Service',
'https://discordapp.com/terms',
link_format
)
@@ -121,34 +132,97 @@ class RulesView(APIView):
return Response([
(
- f"Follow the {pydis_coc}."
+ f"Follow the {pydis_coc}.",
+ ["coc", "conduct", "code"]
),
(
- f"Follow the {discord_community_guidelines} and {discord_tos}."
+ f"Follow the {discord_community_guidelines} and {discord_tos}.",
+ ["discord", "guidelines", "discord_tos"]
),
(
- "Respect staff members and listen to their instructions."
+ "Respect staff members and listen to their instructions.",
+ ["respect", "staff", "instructions"]
),
(
"Use English to the best of your ability. "
- "Be polite if someone speaks English imperfectly."
+ "Be polite if someone speaks English imperfectly.",
+ ["english", "language"]
),
(
- "Do not provide or request help on projects that may break laws, "
- "breach terms of services, or are malicious or inappropriate."
+ "Do not provide or request help on projects that may violate terms of service, "
+ "or that may be deemed inappropriate, malicious, or illegal.",
+ ["infraction", "tos", "breach", "malicious", "inappropriate", "illegal"]
),
(
- "Do not post unapproved advertising."
+ "Do not post unapproved advertising.",
+ ["ad", "ads", "advert", "advertising"]
),
(
"Keep discussions relevant to the channel topic. "
- "Each channel's description tells you the topic."
+ "Each channel's description tells you the topic.",
+ ["off-topic", "topic", "relevance"]
),
(
"Do not help with ongoing exams. When helping with homework, "
- "help people learn how to do the assignment without doing it for them."
+ "help people learn how to do the assignment without doing it for them.",
+ ["exam", "exams", "assignment", "assignments", "homework"]
+ ),
+ (
+ "Do not offer or ask for paid work of any kind.",
+ ["paid", "work", "money"]
),
(
- "Do not offer or ask for paid work of any kind."
+ "Do not copy and paste answers from ChatGPT or similar AI tools.",
+ ["gpt", "chatgpt", "gpt3", "ai"]
),
])
+
+
+class GitHubArtifactsView(APIView):
+ """
+ Provides utilities for interacting with the GitHub API and obtaining action artifacts.
+
+ ## Routes
+ ### GET /github/artifacts
+ Returns a download URL for the artifact requested.
+
+ {
+ 'url': 'https://pipelines.actions.githubusercontent.com/...'
+ }
+
+ ### Exceptions
+ In case of an error, the following body will be returned:
+
+ {
+ "error_type": "<error class name>",
+ "error": "<error description>",
+ "requested_resource": "<owner>/<repo>/<sha>/<artifact_name>"
+ }
+
+ ## Authentication
+ Does not require any authentication nor permissions.
+ """
+
+ authentication_classes = ()
+ permission_classes = ()
+
+ def get(
+ self,
+ request: Request,
+ *,
+ owner: str,
+ repo: str,
+ sha: str,
+ action_name: str,
+ artifact_name: str
+ ) -> Response:
+ """Return a download URL for the requested artifact."""
+ try:
+ url = github_utils.get_artifact(owner, repo, sha, action_name, artifact_name)
+ return Response({"url": url})
+ except github_utils.ArtifactProcessingError as e:
+ return Response({
+ "error_type": e.__class__.__name__,
+ "error": str(e),
+ "requested_resource": f"{owner}/{repo}/{sha}/{action_name}/{artifact_name}"
+ }, status=e.status)
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
index f133e77f..1dae9be1 100644
--- a/pydis_site/apps/api/viewsets/__init__.py
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -1,12 +1,17 @@
# flake8: noqa
from .bot import (
- FilterListViewSet,
BotSettingViewSet,
+ BumpedThreadViewSet,
DeletedMessageViewSet,
DocumentationLinkViewSet,
+ FilterListViewSet,
InfractionViewSet,
+ FilterListViewSet,
+ FilterViewSet,
NominationViewSet,
OffensiveMessageViewSet,
+ AocAccountLinkViewSet,
+ AocCompletionistBlockViewSet,
OffTopicChannelNameViewSet,
ReminderViewSet,
RoleViewSet,
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
index 84b87eab..33b65009 100644
--- a/pydis_site/apps/api/viewsets/bot/__init__.py
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -1,12 +1,18 @@
# flake8: noqa
-from .filter_list import FilterListViewSet
+from .filters import (
+ FilterListViewSet,
+ FilterViewSet
+)
from .bot_setting import BotSettingViewSet
+from .bumped_thread import BumpedThreadViewSet
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 .aoc_link import AocAccountLinkViewSet
+from .aoc_completionist_block import AocCompletionistBlockViewSet
from .reminder import ReminderViewSet
from .role import RoleViewSet
from .user import UserViewSet
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
new file mode 100644
index 00000000..97efb63c
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/aoc_completionist_block.py
@@ -0,0 +1,73 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.mixins import (
+ CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import AocCompletionistBlock
+from pydis_site.apps.api.serializers import AocCompletionistBlockSerializer
+
+
+class AocCompletionistBlockViewSet(
+ GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+):
+ """
+ View providing management for Users blocked from gettign the AoC completionist Role.
+
+ ## Routes
+
+ ### GET /bot/aoc-completionist-blocks/
+ Returns all the AoC completionist blocks
+
+ #### Response format
+ >>> [
+ ... {
+ ... "user": 2,
+ ... "is_blocked": False,
+ ... "reason": "Too good to be true"
+ ... }
+ ... ]
+
+
+ ### GET /bot/aoc-completionist-blocks/<user__id:int>
+ Retrieve a single Block by User ID
+
+ #### Response format
+ >>>
+ ... {
+ ... "user": 2,
+ ... "is_blocked": False,
+ ... "reason": "Too good to be true"
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if an AoC completionist block with the given `user__id` was not found.
+
+ ### POST /bot/aoc-completionist-blocks
+ Adds a single AoC completionist block
+
+ #### Request body
+ >>> {
+ ... "user": int,
+ ... "is_blocked": bool,
+ ... "reason": string
+ ... }
+
+ #### Status codes
+ - 204: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/aoc-completionist-blocks/<user__id:int>
+ Deletes the AoC Completionist block item with the given `user__id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: returned if the AoC Completionist block with the given `user__id` was not found
+
+ """
+
+ serializer_class = AocCompletionistBlockSerializer
+ queryset = AocCompletionistBlock.objects.all()
+ filter_backends = (DjangoFilterBackend,)
+ filterset_fields = ("user__id", "is_blocked")
diff --git a/pydis_site/apps/api/viewsets/bot/aoc_link.py b/pydis_site/apps/api/viewsets/bot/aoc_link.py
new file mode 100644
index 00000000..3cdc342d
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/aoc_link.py
@@ -0,0 +1,71 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.mixins import (
+ CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import AocAccountLink
+from pydis_site.apps.api.serializers import AocAccountLinkSerializer
+
+
+class AocAccountLinkViewSet(
+ GenericViewSet, CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListModelMixin
+):
+ """
+ View providing management for Users who linked their AoC accounts to their Discord Account.
+
+ ## Routes
+
+ ### GET /bot/aoc-account-links
+ Returns all the AoC account links
+
+ #### Response format
+ >>> [
+ ... {
+ ... "user": 2,
+ ... "aoc_username": "AoCUser1"
+ ... },
+ ... ...
+ ... ]
+
+
+ ### GET /bot/aoc-account-links/<user__id:int>
+ Retrieve a AoC account link by User ID
+
+ #### Response format
+ >>>
+ ... {
+ ... "user": 2,
+ ... "aoc_username": "AoCUser1"
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if an AoC account link with the given `user__id` was not found.
+
+ ### POST /bot/aoc-account-links
+ Adds a single AoC account link block
+
+ #### Request body
+ >>> {
+ ... 'user': int,
+ ... 'aoc_username': str
+ ... }
+
+ #### Status codes
+ - 204: returned on success
+ - 400: if one of the given fields was invalid
+
+ ### DELETE /bot/aoc-account-links/<user__id:int>
+ Deletes the AoC account link item with the given `user__id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: returned if the AoC account link with the given `user__id` was not found
+
+ """
+
+ serializer_class = AocAccountLinkSerializer
+ queryset = AocAccountLink.objects.all()
+ filter_backends = (DjangoFilterBackend,)
+ filterset_fields = ("user__id", "aoc_username")
diff --git a/pydis_site/apps/api/viewsets/bot/bumped_thread.py b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
new file mode 100644
index 00000000..9d77bb6b
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/bumped_thread.py
@@ -0,0 +1,66 @@
+from rest_framework.mixins import (
+ CreateModelMixin, DestroyModelMixin, ListModelMixin
+)
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import BumpedThread
+from pydis_site.apps.api.serializers import BumpedThreadSerializer
+
+
+class BumpedThreadViewSet(
+ GenericViewSet, CreateModelMixin, DestroyModelMixin, ListModelMixin
+):
+ """
+ View providing CRUD (Minus the U) operations on threads to be bumped.
+
+ ## Routes
+ ### GET /bot/bumped-threads
+ Returns all BumpedThread items in the database.
+
+ #### Response format
+ >>> list[int]
+
+ #### Status codes
+ - 200: returned on success
+ - 401: returned if unauthenticated
+
+ ### GET /bot/bumped-threads/<thread_id:int>
+ Returns whether a specific BumpedThread exists in the database.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: returned if a BumpedThread with the given thread_id was not found.
+
+ ### POST /bot/bumped-threads
+ Adds a single BumpedThread item to the database.
+
+ #### Request body
+ >>> {
+ ... 'thread_id': int,
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/bumped-threads/<thread_id:int>
+ Deletes the BumpedThread item with the given `thread_id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a BumpedThread with the given `thread_id` does not exist
+ """
+
+ serializer_class = BumpedThreadSerializer
+ queryset = BumpedThread.objects.all()
+
+ def retrieve(self, request: Request, *args, **kwargs) -> Response:
+ """
+ DRF method for checking if the given BumpedThread exists.
+
+ Called by the Django Rest Framework in response to the corresponding HTTP request.
+ """
+ self.get_object()
+ return Response(status=204)
diff --git a/pydis_site/apps/api/viewsets/bot/filter_list.py b/pydis_site/apps/api/viewsets/bot/filter_list.py
deleted file mode 100644
index 4b05acee..00000000
--- a/pydis_site/apps/api/viewsets/bot/filter_list.py
+++ /dev/null
@@ -1,98 +0,0 @@
-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"],
- ... ["REDIRECT", "Redirect"]
- ... ]
-
- #### 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/filters.py b/pydis_site/apps/api/viewsets/bot/filters.py
new file mode 100644
index 00000000..9c9e8338
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/filters.py
@@ -0,0 +1,499 @@
+from rest_framework.viewsets import ModelViewSet
+
+from pydis_site.apps.api.models.bot.filters import ( # - Preserving the filter order
+ FilterList,
+ Filter
+)
+from pydis_site.apps.api.serializers import ( # - Preserving the filter order
+ FilterListSerializer,
+ FilterSerializer,
+)
+
+
+class FilterListViewSet(ModelViewSet):
+ """
+ View providing GET/DELETE on lists of items allowed or denied by our bot.
+
+ ## Routes
+ ### GET /bot/filter/filter_lists
+ Returns all FilterList items in the database.
+
+ #### Response format
+ >>> [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.027293Z",
+ ... "updated_at": "2023-01-27T21:26:34.027308Z",
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_and_notification": {
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": ""
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... },
+ ... "mentions": {
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+ - 401: returned if unauthenticated
+
+ ### GET /bot/filter/filter_lists/<id:int>
+ Returns a specific FilterList item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.027293Z",
+ ... "updated_at": "2023-01-27T21:26:34.027308Z",
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_and_notification": {
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": ""
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... },
+ ... "mentions": {
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if the id was not found.
+
+ ### POST /bot/filter/filter_lists
+ Adds a single FilterList item to the database.
+
+ #### Request body
+ >>> {
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": "",
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### PATCH /bot/filter/filter_lists/<id:int>
+ Updates a specific FilterList item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.027293Z",
+ ... "updated_at": "2023-01-27T21:26:34.027308Z",
+ ... "name": "invite",
+ ... "list_type": 1,
+ ... "filters": [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ],
+ ... "settings": {
+ ... "bypass_roles": [
+ ... "Helpers"
+ ... ],
+ ... "filter_dm": True,
+ ... "enabled": True,
+ ... "remove_context": True,
+ ... "send_alert": True,
+ ... "infraction_and_notification": {
+ ... "infraction_type": "NONE",
+ ... "infraction_reason": "",
+ ... "infraction_duration": "0.0",
+ ... "infraction_channel": 0,
+ ... "dm_content": "Per Rule 6, your invite link has been removed...",
+ ... "dm_embed": ""
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": [],
+ ... "disabled_categories": [
+ ... "CODE JAM"
+ ... ],
+ ... "enabled_channels": [],
+ ... "enabled_categories": []
+ ... },
+ ... "mentions": {
+ ... "guild_pings": [
+ ... "Moderators"
+ ... ],
+ ... "dm_pings": []
+ ... }
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/filter/filter_lists/<id:int>
+ Deletes the FilterList item with the given `id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a FilterList with the given `id` does not exist
+ """
+
+ serializer_class = FilterListSerializer
+ queryset = FilterList.objects.all()
+
+
+class FilterViewSet(ModelViewSet):
+ """
+ View providing CRUD operations on items allowed or denied by our bot.
+
+ ## Routes
+ ### GET /bot/filter/filters
+ Returns all Filter items in the database.
+
+ #### Response format
+ >>> [
+ ... {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... },
+ ... ...
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+ - 401: returned if unauthenticated
+
+ ### GET /bot/filter/filters/<id:int>
+ Returns a specific Filter item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: returned if the id was not found.
+
+ ### POST /bot/filter/filters
+ Adds a single Filter item to the database.
+
+ #### Request body
+ >>> {
+ ... "filter_list": 1,
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": False,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### PATCH /bot/filter/filters/<id:int>
+ Updates a specific Filter item from the database.
+
+ #### Response format
+ >>> {
+ ... "id": 1,
+ ... "created_at": "2023-01-27T21:26:34.029539Z",
+ ... "updated_at": "2023-01-27T21:26:34.030532Z",
+ ... "content": "267624335836053506",
+ ... "description": "Python Discord",
+ ... "additional_settings": None,
+ ... "filter_list": 1,
+ ... "settings": {
+ ... "bypass_roles": None,
+ ... "filter_dm": None,
+ ... "enabled": None,
+ ... "remove_context": None,
+ ... "send_alert": None,
+ ... "infraction_and_notification": {
+ ... "infraction_type": None,
+ ... "infraction_reason": None,
+ ... "infraction_duration": None,
+ ... "infraction_channel": None,
+ ... "dm_content": None,
+ ... "dm_embed": None
+ ... },
+ ... "channel_scope": {
+ ... "disabled_channels": None,
+ ... "disabled_categories": None,
+ ... "enabled_channels": None,
+ ... "enabled_categories": None
+ ... },
+ ... "mentions": {
+ ... "guild_pings": None,
+ ... "dm_pings": None
+ ... }
+ ... }
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if one of the given fields is invalid
+
+ ### DELETE /bot/filter/filters/<id:int>
+ Deletes the Filter item with the given `id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a Filter with the given `id` does not exist
+ """
+
+ serializer_class = FilterSerializer
+ queryset = Filter.objects.all()
diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py
index 7f31292f..ec8b83a1 100644
--- a/pydis_site/apps/api/viewsets/bot/infraction.py
+++ b/pydis_site/apps/api/viewsets/bot/infraction.py
@@ -1,9 +1,8 @@
-from datetime import datetime
+import datetime
from django.db import IntegrityError
from django.db.models import QuerySet
from django.http.request import HttpRequest
-from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
@@ -73,7 +72,8 @@ class InfractionViewSet(
... 'type': 'ban',
... 'reason': 'He terk my jerb!',
... 'hidden': True,
- ... 'dm_sent': True
+ ... 'dm_sent': True,
+ ... 'jump_url': '<discord message link>'
... }
... ]
@@ -104,7 +104,8 @@ class InfractionViewSet(
... 'type': 'ban',
... 'reason': 'He terk my jerb!',
... 'user': 172395097705414656,
- ... 'dm_sent': False
+ ... 'dm_sent': False,
+ ... 'jump_url': '<discord message link>'
... }
#### Response format
@@ -139,7 +140,7 @@ class InfractionViewSet(
#### Status codes
- 204: returned on success
- - 404: if a infraction with the given `id` does not exist
+ - 404: if an infraction with the given `id` does not exist
### Expanded routes
All routes support expansion of `user` and `actor` in responses. To use an expanded route,
@@ -154,7 +155,7 @@ class InfractionViewSet(
queryset = Infraction.objects.all()
pagination_class = LimitOffsetPaginationExtended
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter)
- filter_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type')
+ filterset_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type')
search_fields = ('$reason',)
frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden')
@@ -185,23 +186,21 @@ class InfractionViewSet(
filter_expires_after = self.request.query_params.get('expires_after')
if filter_expires_after:
try:
- expires_after_parsed = datetime.fromisoformat(filter_expires_after)
+ expires_after_parsed = datetime.datetime.fromisoformat(filter_expires_after)
except ValueError:
raise ValidationError({'expires_after': ['failed to convert to datetime']})
- additional_filters['expires_at__gte'] = timezone.make_aware(
- expires_after_parsed,
- timezone=timezone.utc,
+ additional_filters['expires_at__gte'] = expires_after_parsed.replace(
+ tzinfo=datetime.timezone.utc
)
filter_expires_before = self.request.query_params.get('expires_before')
if filter_expires_before:
try:
- expires_before_parsed = datetime.fromisoformat(filter_expires_before)
+ expires_before_parsed = datetime.datetime.fromisoformat(filter_expires_before)
except ValueError:
raise ValidationError({'expires_before': ['failed to convert to datetime']})
- additional_filters['expires_at__lte'] = timezone.make_aware(
- expires_before_parsed,
- timezone=timezone.utc,
+ additional_filters['expires_at__lte'] = expires_before_parsed.replace(
+ tzinfo=datetime.timezone.utc
)
if 'expires_at__lte' in additional_filters and 'expires_at__gte' in additional_filters:
diff --git a/pydis_site/apps/api/viewsets/bot/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py
index 144daab0..78687e0e 100644
--- a/pydis_site/apps/api/viewsets/bot/nomination.py
+++ b/pydis_site/apps/api/viewsets/bot/nomination.py
@@ -172,7 +172,7 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
serializer_class = NominationSerializer
queryset = Nomination.objects.all()
filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter)
- filter_fields = ('user__id', 'active')
+ filterset_fields = ('user__id', 'active')
frozen_fields = ('id', 'inserted_at', 'user', 'ended_at')
frozen_on_create = ('ended_at', 'end_reason', 'active', 'inserted_at', 'reviewed')
@@ -273,6 +273,11 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'reviewed': ['This field cannot be set while you are ending a nomination.']}
)
+ if 'thread_id' in request.data:
+ raise ValidationError(
+ {'thread_id': ['This field cannot be set when ending a nomination.']}
+ )
+
instance.ended_at = timezone.now()
elif 'active' in data:
@@ -281,13 +286,15 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge
{'active': ['This field can only be used to end a nomination']}
)
- # This is actually covered, but for some reason coverage don't think so.
- elif 'reviewed' in request.data: # pragma: no cover
- # 4. We are altering the reviewed state of the nomination.
- if not instance.active:
- raise ValidationError(
- {'reviewed': ['This field cannot be set if the nomination is inactive.']}
- )
+ elif not instance.active and 'reviewed' in request.data:
+ raise ValidationError(
+ {'reviewed': ['This field cannot be set if the nomination is inactive.']}
+ )
+
+ elif not instance.active and 'thread_id' in request.data:
+ raise ValidationError(
+ {'thread_id': ['This field cannot be set if the nomination is inactive.']}
+ )
if 'reason' in request.data:
if 'actor' not in request.data:
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 d0519e86..1774004c 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
@@ -85,10 +85,9 @@ class OffTopicChannelNameViewSet(ModelViewSet):
serializer.save()
return Response(create_data, status=HTTP_201_CREATED)
- else:
- raise ParseError(detail={
- 'name': ["This query parameter is required."]
- })
+ raise ParseError(detail={
+ 'name': ["This query parameter is required."]
+ })
def list(self, request: Request, *args, **kwargs) -> Response:
"""
diff --git a/pydis_site/apps/api/viewsets/bot/reminder.py b/pydis_site/apps/api/viewsets/bot/reminder.py
index 78d7cb3b..5f997052 100644
--- a/pydis_site/apps/api/viewsets/bot/reminder.py
+++ b/pydis_site/apps/api/viewsets/bot/reminder.py
@@ -125,4 +125,4 @@ class ReminderViewSet(
serializer_class = ReminderSerializer
queryset = Reminder.objects.prefetch_related('author')
filter_backends = (DjangoFilterBackend, SearchFilter)
- filter_fields = ('active', 'author__id')
+ filterset_fields = ('active', 'author__id')
diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py
index 3318b2b9..88fa3415 100644
--- a/pydis_site/apps/api/viewsets/bot/user.py
+++ b/pydis_site/apps/api/viewsets/bot/user.py
@@ -1,10 +1,10 @@
-import typing
from collections import OrderedDict
from django.db.models import Q
from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework import status
+from rest_framework import fields, status
from rest_framework.decorators import action
+from rest_framework.exceptions import ParseError
from rest_framework.pagination import PageNumberPagination
from rest_framework.request import Request
from rest_framework.response import Response
@@ -23,14 +23,14 @@ class UserListPagination(PageNumberPagination):
page_size = 2500
page_size_query_param = "page_size"
- def get_next_page_number(self) -> typing.Optional[int]:
+ def get_next_page_number(self) -> int | None:
"""Get the next page number."""
if not self.page.has_next():
return None
page_number = self.page.next_page_number()
return page_number
- def get_previous_page_number(self) -> typing.Optional[int]:
+ def get_previous_page_number(self) -> int | None:
"""Get the previous page number."""
if not self.page.has_previous():
return None
@@ -138,6 +138,29 @@ class UserViewSet(ModelViewSet):
- 200: returned on success
- 404: if a user with the given `snowflake` could not be found
+ ### POST /bot/users/metricity_activity_data
+ Returns a mapping of user ID to message count in a given period for
+ the given user IDs.
+
+ #### Required Query Parameters
+ - days: how many days into the past to count message from.
+
+ #### Request Format
+ >>> [
+ ... 409107086526644234,
+ ... 493839819168808962
+ ... ]
+
+ #### Response format
+ >>> {
+ ... "409107086526644234": 54,
+ ... "493839819168808962": 0
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if request body or query parameters were missing or invalid
+
### POST /bot/users
Adds a single or multiple new users.
The roles attached to the user(s) must be roles known by the site.
@@ -237,7 +260,7 @@ class UserViewSet(ModelViewSet):
queryset = User.objects.all().order_by("id")
pagination_class = UserListPagination
filter_backends = (DjangoFilterBackend,)
- filter_fields = ('name', 'discriminator')
+ filterset_fields = ('name', 'discriminator')
def get_serializer(self, *args, **kwargs) -> ModelSerializer:
"""Set Serializer many attribute to True if request body contains a list."""
@@ -298,3 +321,34 @@ class UserViewSet(ModelViewSet):
except NotFoundError:
return Response(dict(detail="User not found in metricity"),
status=status.HTTP_404_NOT_FOUND)
+
+ @action(detail=False, methods=["POST"])
+ def metricity_activity_data(self, request: Request) -> Response:
+ """Request handler for metricity_activity_data endpoint."""
+ if "days" in request.query_params:
+ try:
+ days = int(request.query_params["days"])
+ except ValueError:
+ raise ParseError(detail={
+ "days": ["This query parameter must be an integer."]
+ })
+ else:
+ raise ParseError(detail={
+ "days": ["This query parameter is required."]
+ })
+
+ user_id_list_validator = fields.ListField(
+ child=fields.IntegerField(min_value=0),
+ allow_empty=False
+ )
+ user_ids = [
+ str(user_id) for user_id in
+ user_id_list_validator.run_validation(request.data)
+ ]
+
+ with Metricity() as metricity:
+ data = metricity.total_messages_in_past_n_days(user_ids, days)
+
+ default_data = {user_id: 0 for user_id in user_ids}
+ response_data = default_data | dict(data)
+ return Response(response_data, status=status.HTTP_200_OK)