diff options
| author | 2019-09-06 09:32:30 +0200 | |
|---|---|---|
| committer | 2019-09-06 09:32:30 +0200 | |
| commit | ae6538a46a74cf8d7bb2ef796a5a82f7623c4e24 (patch) | |
| tree | 970c8384bf6a635fbc10624a027d123870c38871 /pydis_site/apps | |
| parent | Merge pull request #240 from python-discord/update-contrib (diff) | |
| parent | Applying PR Feedback: Colour constants for readability & absolute import (diff) | |
Merge pull request #238 from python-discord/django-deleted-messages-frontend
Deleted Messages Front-End
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"  |