aboutsummaryrefslogtreecommitdiffstats
path: root/pydis_site
diff options
context:
space:
mode:
authorGravatar Leon Sandøy <[email protected]>2019-09-06 09:32:30 +0200
committerGravatar GitHub <[email protected]>2019-09-06 09:32:30 +0200
commitae6538a46a74cf8d7bb2ef796a5a82f7623c4e24 (patch)
tree970c8384bf6a635fbc10624a027d123870c38871 /pydis_site
parentMerge pull request #240 from python-discord/update-contrib (diff)
parentApplying 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')
-rw-r--r--pydis_site/apps/api/models/bot/deleted_message.py5
-rw-r--r--pydis_site/apps/api/models/bot/message.py10
-rw-r--r--pydis_site/apps/api/models/bot/tag.py17
-rw-r--r--pydis_site/apps/api/tests/test_validators.py39
-rw-r--r--pydis_site/apps/staff/__init__.py0
-rw-r--r--pydis_site/apps/staff/apps.py7
-rw-r--r--pydis_site/apps/staff/migrations/__init__.py0
-rw-r--r--pydis_site/apps/staff/models/__init__.py0
-rw-r--r--pydis_site/apps/staff/templatetags/__init__.py3
-rw-r--r--pydis_site/apps/staff/templatetags/deletedmessage_filters.py17
-rw-r--r--pydis_site/apps/staff/tests/__init__.py0
-rw-r--r--pydis_site/apps/staff/tests/test_deletedmessage_filters.py33
-rw-r--r--pydis_site/apps/staff/tests/test_logs_view.py160
-rw-r--r--pydis_site/apps/staff/urls.py10
-rw-r--r--pydis_site/apps/staff/viewsets/__init__.py3
-rw-r--r--pydis_site/apps/staff/viewsets/logs.py11
-rw-r--r--pydis_site/hosts.py1
-rw-r--r--pydis_site/settings.py1
-rw-r--r--pydis_site/static/css/staff/logs.css256
-rw-r--r--pydis_site/templates/staff/logs.html97
-rw-r--r--pydis_site/urls.py1
21 files changed, 668 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 = "&lt;em&gt;I think my tape has run out...&lt;/em&gt;"
+ 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"
diff --git a/pydis_site/hosts.py b/pydis_site/hosts.py
index 0fa4793d..898e8cdc 100644
--- a/pydis_site/hosts.py
+++ b/pydis_site/hosts.py
@@ -5,5 +5,6 @@ host_patterns = patterns(
'',
host(r'admin', 'pydis_site.apps.admin.urls', name="admin"),
host(r'api', 'pydis_site.apps.api.urls', name='api'),
+ host(r'staff', 'pydis_site.apps.staff.urls', name='staff'),
host(r'.*', 'pydis_site.apps.home.urls', name=settings.DEFAULT_HOST)
)
diff --git a/pydis_site/settings.py b/pydis_site/settings.py
index 4a4eb94b..d3b371b6 100644
--- a/pydis_site/settings.py
+++ b/pydis_site/settings.py
@@ -63,6 +63,7 @@ else:
INSTALLED_APPS = [
'pydis_site.apps.api',
'pydis_site.apps.home',
+ 'pydis_site.apps.staff',
'django.contrib.admin',
'django.contrib.auth',
diff --git a/pydis_site/static/css/staff/logs.css b/pydis_site/static/css/staff/logs.css
new file mode 100644
index 00000000..d7bb04cf
--- /dev/null
+++ b/pydis_site/static/css/staff/logs.css
@@ -0,0 +1,256 @@
+main.site-content {
+ background-color: hsl(220, 8%, 23%);
+ color: #dcddde;
+ font-size: 0.9375rem;
+ font-weight: 400;
+ line-height: 1.3;
+ letter-spacing: 0;
+ text-rendering: optimizeLegibility;
+ padding: 1rem;
+ font-family: Whitney, Helvetica Neue, Helvetica, Arial, Lucida Grande, sans-serif;
+}
+
+.has-small-margin {
+ margin: 1rem 0;
+}
+
+.deleted-header {
+ font-weight: 700;
+ margin-top: 1rem;
+}
+
+.discord-message {
+ margin-bottom: 15px;
+}
+
+.discord-message:first-child {
+ border-top: 1px;
+
+}
+
+.discord-message-header {
+ white-space: nowrap;
+ letter-spacing: 0;
+}
+
+.discord-username {
+ font-size: 1rem;
+ font-weight: 600;
+}
+
+.discord-message-metadata {
+ color: hsla(0, 0%, 100%, .2);
+ font-size: 0.75rem;
+ font-weight: 400;
+ margin: 0 .3rem;
+}
+
+.discord-channel {
+ font-size: 0.9rem;
+ font-weight: 500;
+ color: #dcddde;
+}
+
+.discord-id {
+ color: #dcddde;
+ font-weight: 300;
+ margin-left: 0.3rem;
+}
+
+.discord-embed {
+
+ position: relative;
+ margin-top: 5px;
+ max-width: 520px;
+ display: flex;
+}
+
+.discord-embed a {
+ text-decoration: none;
+ color: hsl(197, 100%, 41%);
+}
+
+.discord-embed a:hover {
+ text-decoration: underline;
+ color: hsl(197, 100%, 41%);
+}
+
+.discord-embed-color {
+ width: 4px;
+ border-radius: 3px 0 0 3px;
+ flex-shrink: 0;
+}
+
+.discord-embed-inner {
+ background-color: #34363b;
+ padding: 8px 10px;
+ border-radius: 0 3px 3px 0;
+ box-sizing: border-box;
+ border: 1px solid hsla(225, 8%, 20%, 0.6);
+ display: flex;
+ flex-direction: column;
+}
+
+.discord-embed-content {
+ width: 100%;
+ display: flex;
+}
+
+.discord-embed-main {
+ flex: 1;
+}
+
+.discord-embed-thumbnail > img {
+ max-width: 80px;
+ max-height: 80px;
+ border-radius: 3px;
+ width: auto;
+ object-fit: contain;
+ margin-left: 20px;
+ flex-shrink: 0;
+ border-style: none;
+}
+
+.discord-embed-author {
+ display: flex;
+ align-items: center;
+ margin-bottom: 5px;
+ font-weight: 600;
+ font-size: 14px;
+ line-height: 1.15;
+}
+
+.discord-embed-author-icon {
+ margin-right: 9px;
+ width: 20px;
+ height: 20px;
+ object-fit: contain;
+ border-radius: 50%;
+}
+
+.discord-embed-author a {
+ color: white;
+}
+
+.discord-embed-author a:hover {
+ color: white;
+}
+
+.discord-embed-title {
+ margin-bottom: 5px;
+ font-size: 14px;
+ display: inline-block;
+ font-weight: 600;
+}
+
+.discord-embed-description {
+ margin-bottom: 10px;
+}
+
+.discord-embed-fields {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ margin-top: -10px;
+ margin-bottom: 10px;
+}
+
+.discord-embed-field {
+ flex: 0;
+ padding-top: 5px;
+ min-width: 100%;
+ max-width: 506px;
+}
+
+.discord-embed-field-name {
+ margin-bottom: 4px;
+ font-weight: 600;
+}
+
+.discord-embed-field-value {
+ font-weight: 500;
+}
+
+.discord-embed-field-inline {
+ flex: 1;
+ min-width: 150px;
+ flex-basis: auto;
+}
+
+.discord-embed-main > :last-child {
+ margin-bottom: 0 !important;
+}
+
+.discord-embed-image {
+ position: relative;
+ display: inline-block;
+ margin-bottom: 10px;
+}
+
+.discord-embed-image > img {
+ margin: 0;
+ vertical-align: bottom;
+ max-width: 300px;
+ display: flex;
+ overflow: hidden;
+ border-radius: 2px;
+}
+
+.discord-embed-footer-text {
+ font-size: .70rem !important;
+ letter-spacing: 0;
+ display: inline-block;
+}
+
+.discord-embed-footer-icon {
+ margin-right: 10px;
+ height: 18px;
+ width: 18px;
+ object-fit: contain;
+ float: left;
+ border-radius: 50%;
+}
+
+.discord-embed-content {
+ margin-bottom: 10px;
+}
+
+.discord-embed-inner > :last-child {
+ margin-bottom: 0 !important;
+}
+
+/* Discord Font definitions */
+@font-face {
+ font-family: Whitney;
+ font-style: light;
+ font-weight: 300;
+ src: url(https://discordapp.com/assets/6c6374bad0b0b6d204d8d6dc4a18d820.woff) format("woff")
+}
+
+@font-face {
+ font-family: Whitney;
+ font-style: normal;
+ font-weight: 500;
+ src: url(https://discordapp.com/assets/e8acd7d9bf6207f99350ca9f9e23b168.woff) format("woff")
+}
+
+@font-face {
+ font-family: Whitney;
+ font-style: medium;
+ font-weight: 600;
+ src: url(https://discordapp.com/assets/3bdef1251a424500c1b3a78dea9b7e57.woff) format("woff")
+}
+
+@font-face {
+ font-family: WhitneyMedium;
+ font-style: medium;
+ font-weight: 600;
+ src: url(https://discordapp.com/assets/be0060dafb7a0e31d2a1ca17c0708636.woff) format("woff")
+}
+
+@font-face {
+ font-family: Whitney;
+ font-style: bold;
+ font-weight: 700;
+ src: url(https://discordapp.com/assets/8e12fb4f14d9c4592eb8ec9f22337b04.woff) format("woff")
+}
diff --git a/pydis_site/templates/staff/logs.html b/pydis_site/templates/staff/logs.html
new file mode 100644
index 00000000..9c8ed7d3
--- /dev/null
+++ b/pydis_site/templates/staff/logs.html
@@ -0,0 +1,97 @@
+{% extends 'base/base.html' %}
+{% load static %}
+{% load deletedmessage_filters %}
+
+{% block title %}Logs for Deleted Message Context {{ message_context.id }}{% endblock %}
+
+{% block head %}
+ <link rel="stylesheet" href="{% static "css/staff/logs.css" %}">
+{% endblock %}
+
+{% block content %}
+ <ul class="is-size-7">
+ <li>Deleted by: <span style="color: {{ deletion_context.actor.top_role.colour | hex_colour }}">{{ deletion_context.actor }}</span></li>
+ <li>Date: {{ deletion_context.creation }}</li>
+ </ul>
+ <div class="is-divider has-small-margin"></div>
+ {% for message in deletion_context.deletedmessage_set.all %}
+ <div class="discord-message">
+ <div class="discord-message-header">
+ <span class="discord-username"
+ style="color: {{ message.author.top_role.colour | hex_colour }}">{{ message.author }}</span><span
+ class="discord-message-metadata">{{ message.timestamp }} | User ID: {{ message.author.id }}</span>
+ </div>
+ <div class="discord-message-content">
+ {{ message.content|linebreaks }}
+ </div>
+ {% for embed in message.embeds %}
+ <div class="discord-embed is-size-7">
+ <div class="discord-embed-color" style="background-color: {% if embed.color %}{{ embed.color | hex_colour }}{% else %}#cacbce{% endif %}"></div>
+ <div class="discord-embed-inner">
+ <div class="discord-embed-content">
+ <div class="discord-embed-main">
+ {% if embed.author %}
+ <div class="discord-embed-author">
+ {% if embed.author.icon_url %}
+ <img alt="Author Icon" class="discord-embed-author-icon"
+ src="{{ embed.author.icon_url }}">{% endif %}
+ {% if embed.author.url %}<a class="discord-embed-author-url"
+ href="{{ embed.author.url }}">{% endif %}
+ <span class="discord-embed-author-name">{{ embed.author.name }}</span>
+ {% if embed.author.url %}</a>{% endif %}
+ </div>
+ {% endif %}
+ {% if embed.title %}
+ <div class="discord-embed-title">
+ {% if embed.url %}<a href="{{ embed.url }}">{% endif %}
+ {{ embed.title }}
+ {% if embed.url %}</a>{% endif %}
+ </div>
+ {% endif %}
+ {% if embed.description %}
+ <div class="discord-embed-description">
+ {{ embed.description | linebreaksbr }}
+ </div>
+ {% endif %}
+ {% if embed.fields %}
+ <div class="discord-embed-fields">
+ {% for field in embed.fields %}
+ <div class="discord-embed-field{% if field.inline %} discord-embed-field-inline{% endif %}">
+ <div class="discord-embed-field-name">{{ field.name }}</div>
+ <div class="discord-embed-field-value">{{ field.value }}</div>
+ </div>
+ {% endfor %}
+ </div>
+ {% endif %}
+ {% if embed.image %}
+ <div class="discord-embed-image">
+ <img alt="Discord Embed Image" src="{{ embed.image.url }}">
+ </div>
+ {% endif %}
+ </div>
+ {% if embed.thumbnail %}
+ <div class="discord-embed-thumbnail">
+ <img alt="Embed thumbnail" src="{{ embed.thumbnail.url }}">
+ </div>
+ {% endif %}
+ </div>
+ {% if embed.footer or embed.timestamp %}
+ <div class="discord-embed-footer">
+ {% if embed.footer.icon_url %}
+ <img class="discord-embed-footer-icon" alt="Footer Icon"
+ src="{{ embed.footer.icon_url }}">
+ {% endif %}
+ {% if embed.footer.text or embed.timestamp %}
+ <span class="discord-embed-footer-text">{% endif %}
+ {% if embed.footer.text %}{{ embed.footer.text }}{% endif %}
+ {% if embed.footer.text and embed.timestamp %} • {% endif %}
+ {% if embed.timestamp %}{{ embed.timestamp | footer_datetime }}{% endif %}
+ {% if embed.footer.text or embed.timestamp %}</span>{% endif %}
+ </div>
+ {% endif %}
+ </div>
+ </div>
+ {% endfor %}
+ </div>
+ {% endfor %}
+{% endblock %}
diff --git a/pydis_site/urls.py b/pydis_site/urls.py
index c68375da..47cf0ba1 100644
--- a/pydis_site/urls.py
+++ b/pydis_site/urls.py
@@ -3,4 +3,5 @@ from django.urls import include, path
urlpatterns = (
path('', include('pydis_site.apps.home.urls', namespace='home')),
+ path('staff/', include('pydis_site.apps.staff.urls', namespace='staff')),
)