diff options
Diffstat (limited to 'pydis_site/apps')
-rw-r--r-- | pydis_site/apps/api/models/bot/deleted_message.py | 5 | ||||
-rw-r--r-- | pydis_site/apps/api/models/bot/message.py | 10 | ||||
-rw-r--r-- | pydis_site/apps/api/models/bot/tag.py | 17 | ||||
-rw-r--r-- | pydis_site/apps/api/tests/test_validators.py | 39 | ||||
-rw-r--r-- | pydis_site/apps/staff/__init__.py | 0 | ||||
-rw-r--r-- | pydis_site/apps/staff/apps.py | 7 | ||||
-rw-r--r-- | pydis_site/apps/staff/migrations/__init__.py | 0 | ||||
-rw-r--r-- | pydis_site/apps/staff/models/__init__.py | 0 | ||||
-rw-r--r-- | pydis_site/apps/staff/templatetags/__init__.py | 3 | ||||
-rw-r--r-- | pydis_site/apps/staff/templatetags/deletedmessage_filters.py | 17 | ||||
-rw-r--r-- | pydis_site/apps/staff/tests/__init__.py | 0 | ||||
-rw-r--r-- | pydis_site/apps/staff/tests/test_deletedmessage_filters.py | 33 | ||||
-rw-r--r-- | pydis_site/apps/staff/tests/test_logs_view.py | 160 | ||||
-rw-r--r-- | pydis_site/apps/staff/urls.py | 10 | ||||
-rw-r--r-- | pydis_site/apps/staff/viewsets/__init__.py | 3 | ||||
-rw-r--r-- | pydis_site/apps/staff/viewsets/logs.py | 11 |
16 files changed, 312 insertions, 3 deletions
diff --git a/pydis_site/apps/api/models/bot/deleted_message.py b/pydis_site/apps/api/models/bot/deleted_message.py index eb7f4c89..1eb4516e 100644 --- a/pydis_site/apps/api/models/bot/deleted_message.py +++ b/pydis_site/apps/api/models/bot/deleted_message.py @@ -12,3 +12,8 @@ class DeletedMessage(Message): help_text="The deletion context this message is part of.", on_delete=models.CASCADE ) + + class Meta: + """Sets the default ordering for list views to oldest first.""" + + ordering = ["id"] diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index 6b566620..31316a01 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -1,6 +1,9 @@ +from 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.tag import validate_tag_embed from pydis_site.apps.api.models.bot.user import User @@ -49,6 +52,13 @@ class Message(ModelReprMixin, models.Model): help_text="Embeds 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.""" diff --git a/pydis_site/apps/api/models/bot/tag.py b/pydis_site/apps/api/models/bot/tag.py index 01b49525..de4eab30 100644 --- a/pydis_site/apps/api/models/bot/tag.py +++ b/pydis_site/apps/api/models/bot/tag.py @@ -1,4 +1,5 @@ from collections.abc import Mapping +from typing import Any from django.contrib.postgres import fields as pgfields from django.core.exceptions import ValidationError @@ -8,17 +9,31 @@ from django.db import models from pydis_site.apps.api.models.utils import ModelReprMixin +def is_bool_validator(value: Any) -> None: + """Validates if a given value is of type bool.""" + if not isinstance(value, bool): + raise ValidationError(f"This field must be of type bool, not {type(value)}.") + + def validate_tag_embed_fields(fields): """Raises a ValidationError if any of the given embed fields is invalid.""" field_validators = { 'name': (MaxLengthValidator(limit_value=256),), - 'value': (MaxLengthValidator(limit_value=1024),) + '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}.") diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py index ffa2f61e..4222f0c0 100644 --- a/pydis_site/apps/api/tests/test_validators.py +++ b/pydis_site/apps/api/tests/test_validators.py @@ -119,18 +119,53 @@ class TagEmbedValidatorTests(TestCase): '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_tag_embed({ + 'fields': [ + { + 'name': "Totally valid", + 'inline': True, + } + ] + }) + + def test_rejects_invalid_inline_field_field(self): + with self.assertRaises(ValidationError): + validate_tag_embed({ + 'fields': [ + { + 'name': "Totally valid", + 'value': "LOOK AT ME", + 'inline': "Totally not a boolean", + } + ] + }) + def test_allows_valid_fields(self): validate_tag_embed({ 'fields': [ { 'name': "valid", - 'value': "field" - } + 'value': "field", + }, + { + 'name': "valid", + 'value': "field", + 'inline': False, + }, + { + 'name': "valid", + 'value': "field", + 'inline': True, + }, ] }) diff --git a/pydis_site/apps/staff/__init__.py b/pydis_site/apps/staff/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/staff/__init__.py diff --git a/pydis_site/apps/staff/apps.py b/pydis_site/apps/staff/apps.py new file mode 100644 index 00000000..70a15f40 --- /dev/null +++ b/pydis_site/apps/staff/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class StaffConfig(AppConfig): + """Django AppConfig for the staff app.""" + + name = 'staff' diff --git a/pydis_site/apps/staff/migrations/__init__.py b/pydis_site/apps/staff/migrations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/staff/migrations/__init__.py diff --git a/pydis_site/apps/staff/models/__init__.py b/pydis_site/apps/staff/models/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/staff/models/__init__.py diff --git a/pydis_site/apps/staff/templatetags/__init__.py b/pydis_site/apps/staff/templatetags/__init__.py new file mode 100644 index 00000000..e8b6983a --- /dev/null +++ b/pydis_site/apps/staff/templatetags/__init__.py @@ -0,0 +1,3 @@ +from .deletedmessage_filters import footer_datetime, hex_colour + +__all__ = ["hex_colour", "footer_datetime"] diff --git a/pydis_site/apps/staff/templatetags/deletedmessage_filters.py b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py new file mode 100644 index 00000000..f950870f --- /dev/null +++ b/pydis_site/apps/staff/templatetags/deletedmessage_filters.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from django import template + +register = template.Library() + + +def hex_colour(color: int) -> str: + """Converts an integer representation of a colour to the RGB hex value.""" + return f"#{color:0>6X}" + + +def footer_datetime(timestamp: str) -> datetime: + """Takes an embed timestamp and returns a timezone-aware datetime object.""" + return datetime.fromisoformat(timestamp) diff --git a/pydis_site/apps/staff/tests/__init__.py b/pydis_site/apps/staff/tests/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/staff/tests/__init__.py diff --git a/pydis_site/apps/staff/tests/test_deletedmessage_filters.py b/pydis_site/apps/staff/tests/test_deletedmessage_filters.py new file mode 100644 index 00000000..d9179044 --- /dev/null +++ b/pydis_site/apps/staff/tests/test_deletedmessage_filters.py @@ -0,0 +1,33 @@ +import enum + +from django.test import TestCase +from django.utils import timezone + +from ..templatetags import deletedmessage_filters + + +class Colour(enum.IntEnum): + """Enumeration of integer colour values for readability.""" + + BLACK = 0 + BLUE = 255 + GREEN = 65280 + RED = 16711680 + WHITE = 16777215 + + +class DeletedMessageFilterTests(TestCase): + def test_hex_colour_filter(self): + self.assertEqual(deletedmessage_filters.hex_colour(Colour.BLACK), "#000000") + self.assertEqual(deletedmessage_filters.hex_colour(Colour.BLUE), "#0000FF") + self.assertEqual(deletedmessage_filters.hex_colour(Colour.GREEN), "#00FF00") + self.assertEqual(deletedmessage_filters.hex_colour(Colour.RED), "#FF0000") + self.assertEqual(deletedmessage_filters.hex_colour(Colour.WHITE), "#FFFFFF") + + def test_footer_datetime_filter(self): + datetime_aware = timezone.now() + iso_string = datetime_aware.isoformat() + + datetime_returned = deletedmessage_filters.footer_datetime(iso_string) + self.assertTrue(timezone.is_aware(datetime_returned)) + self.assertEqual(datetime_aware, datetime_returned) diff --git a/pydis_site/apps/staff/tests/test_logs_view.py b/pydis_site/apps/staff/tests/test_logs_view.py new file mode 100644 index 00000000..32cb6bbf --- /dev/null +++ b/pydis_site/apps/staff/tests/test_logs_view.py @@ -0,0 +1,160 @@ +from django.test import Client, TestCase +from django.utils import timezone +from django_hosts.resolvers import reverse, reverse_host + +from pydis_site.apps.api.models.bot import DeletedMessage, MessageDeletionContext, Role, User +from pydis_site.apps.staff.templatetags.deletedmessage_filters import hex_colour + + +class TestLogsView(TestCase): + @classmethod + def setUpTestData(cls): + cls.developers_role = Role.objects.create( + id=12345678, + name="Developers", + colour=16777215, + permissions=104324673, + position=1, + ) + + cls.author = cls.actor = User.objects.create( + id=12345678901, + name='Alan Turing', + discriminator=1912, + avatar_hash=None + ) + + cls.author.roles.add(cls.developers_role) + + cls.deletion_context = MessageDeletionContext.objects.create( + actor=cls.actor, + creation=timezone.now() + ) + + cls.deleted_message_one = DeletedMessage.objects.create( + author=cls.author, + id=614125807161573397, + channel_id=1984, + content='<em>I think my tape has run out...</em>', + embeds=[], + deletion_context=cls.deletion_context, + ) + + cls.embed_one = { + "footer": { + "text": "This will be displayed in the footer!", + "icon_url": "https://avatars0.githubusercontent.com/u/33516116?s=460&v=4" + }, + "image": { + "url": "https://avatars0.githubusercontent.com/u/33516116?s=460&v=4" + }, + "thumbnail": { + "url": "https://avatars0.githubusercontent.com/u/33516116?s=460&v=4" + }, + "author": { + "name": "Ves Zappa", + "url": "https://pydis.com", + "icon_url": "https://avatars0.githubusercontent.com/u/33516116?s=460&v=4" + }, + "fields": [ + { + "inline": False, + "name": "Field Name 1", + "value": "Field Value 1" + }, + { + "inline": False, + "name": "Field Name 2", + "value": "Field Value 2" + }, + { + "inline": True, + "name": "Field Name 3", + "value": "Field Value 3" + }, + { + "inline": True, + "name": "Field Name 4", + "value": "Field Value 4" + }, + { + "inline": True, + "name": "Field Name 5", + "value": "Field Value 5" + } + ], + "color": 16711680, + "timestamp": "2019-08-21T13:58:34.480053+00:00", + "type": "rich", + "description": "This embed is way too cool to be seen in public channels.", + "url": "https://pythondiscord.com/", + "title": "Hello, PyDis" + } + + cls.embed_two = { + "description": "This embed is way too cool to be seen in public channels.", + } + + cls.deleted_message_two = DeletedMessage.objects.create( + author=cls.author, + id=614444836291870750, + channel_id=1984, + content='Does that mean this thing will halt?', + embeds=[cls.embed_one, cls.embed_two], + deletion_context=cls.deletion_context, + ) + + def setUp(self): + """Sets up a test client that automatically sets the correct HOST header.""" + self.client = Client(HTTP_HOST=reverse_host(host="staff")) + + def test_logs_returns_200_for_existing_logs_pk(self): + url = reverse('logs', host="staff", args=(self.deletion_context.id,)) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_logs_returns_404_for_nonexisting_logs_pk(self): + url = reverse('logs', host="staff", args=(self.deletion_context.id + 100,)) + response = self.client.get(url) + self.assertEqual(response.status_code, 404) + + def test_author_color_is_set_in_response(self): + url = reverse('logs', host="staff", args=(self.deletion_context.id,)) + response = self.client.get(url) + role_colour = hex_colour(self.developers_role.colour) + html_needle = ( + f'<span class="discord-username" style="color: {role_colour}">{self.author}</span>' + ) + self.assertInHTML(html_needle, response.content.decode()) + + def test_correct_messages_have_been_passed_to_template(self): + url = reverse('logs', host="staff", args=(self.deletion_context.id,)) + response = self.client.get(url) + self.assertIn("messages", response.context) + self.assertListEqual( + [self.deleted_message_one, self.deleted_message_two], + list(response.context["deletion_context"].deletedmessage_set.all()) + ) + + def test_if_both_embeds_are_included_html_response(self): + url = reverse('logs', host="staff", args=(self.deletion_context.id,)) + response = self.client.get(url) + + html_response = response.content.decode() + embed_colour_needle = ( + '<div class="discord-embed-color" style="background-color: {colour}"></div>' + ) + embed_one_colour = hex_colour(self.embed_one["color"]) + embed_two_colour = "#cacbce" + self.assertInHTML(embed_colour_needle.format(colour=embed_one_colour), html_response) + self.assertInHTML(embed_colour_needle.format(colour=embed_two_colour), html_response) + + def test_if_html_in_content_is_properly_escaped(self): + url = reverse('logs', host="staff", args=(self.deletion_context.id,)) + response = self.client.get(url) + + html_response = response.content.decode() + unescaped_content = "<em>I think my tape has run out...</em>" + self.assertInHTML(unescaped_content, html_response, count=0) + escaped_content = "<em>I think my tape has run out...</em>" + self.assertInHTML(escaped_content, html_response, count=1) diff --git a/pydis_site/apps/staff/urls.py b/pydis_site/apps/staff/urls.py new file mode 100644 index 00000000..a564d516 --- /dev/null +++ b/pydis_site/apps/staff/urls.py @@ -0,0 +1,10 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.urls import path + +from .viewsets import LogView + +app_name = 'staff' +urlpatterns = [ + path('bot/logs/<int:pk>/', LogView.as_view(), name="logs"), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/pydis_site/apps/staff/viewsets/__init__.py b/pydis_site/apps/staff/viewsets/__init__.py new file mode 100644 index 00000000..6b10eb83 --- /dev/null +++ b/pydis_site/apps/staff/viewsets/__init__.py @@ -0,0 +1,3 @@ +from .logs import LogView + +__all__ = ["LogView"] diff --git a/pydis_site/apps/staff/viewsets/logs.py b/pydis_site/apps/staff/viewsets/logs.py new file mode 100644 index 00000000..22dede95 --- /dev/null +++ b/pydis_site/apps/staff/viewsets/logs.py @@ -0,0 +1,11 @@ +from django.views.generic.detail import DetailView + +from pydis_site.apps.api.models.bot.message_deletion_context import MessageDeletionContext + + +class LogView(DetailView): + """The default view for the Deleted Messages logs.""" + + model = MessageDeletionContext + context_object_name = "deletion_context" + template_name = "staff/logs.html" |