diff options
| author | 2020-09-20 01:02:06 +0100 | |
|---|---|---|
| committer | 2020-09-20 01:02:06 +0100 | |
| commit | 5e8b55146a13b99453595337f33c06d96767b0d5 (patch) | |
| tree | c82797873c048b91b848e73f3d46a980e9a75372 | |
| parent | Merge pull request #390 from python-discord/allow_blank_or_null_for_nominatio... (diff) | |
| parent | Remove delete permission for bot settings admin. (diff) | |
Merge pull request #289 from python-discord/admin-api-pages-improvements
Improvements to Admin pages for the API Section
| -rwxr-xr-x | manage.py | 14 | ||||
| -rw-r--r-- | pydis_site/apps/api/admin.py | 431 | ||||
| -rw-r--r-- | pydis_site/apps/api/migrations/0064_auto_20200919_1900.py | 76 | ||||
| -rw-r--r-- | pydis_site/apps/api/migrations/0065_auto_20200919_2033.py | 17 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/deleted_message.py | 4 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/documentation_link.py | 5 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/message.py | 6 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/message_deletion_context.py | 11 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/nomination.py | 8 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/offensive_message.py | 9 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/role.py | 8 | ||||
| -rw-r--r-- | pydis_site/apps/api/models/bot/user.py | 17 | ||||
| -rw-r--r-- | pydis_site/apps/api/tests/test_deleted_messages.py | 21 | ||||
| -rw-r--r-- | pydis_site/apps/api/tests/test_users.py | 6 | ||||
| -rw-r--r-- | pydis_site/apps/staff/tests/test_logs_view.py | 2 | 
15 files changed, 593 insertions, 42 deletions
| @@ -112,6 +112,19 @@ class SiteManager:              print("Database could not be found, exiting.")              sys.exit(1) +    @staticmethod +    def set_dev_site_name() -> None: +        """Set the development site domain in admin from default example.""" +        # import Site model now after django setup +        from django.contrib.sites.models import Site +        query = Site.objects.filter(id=1) +        site = query.get() +        if site.domain == "example.com": +            query.update( +                domain="pythondiscord.local:8000", +                name="pythondiscord.local:8000" +            ) +      def prepare_server(self) -> None:          """Perform preparation tasks before running the server."""          django.setup() @@ -125,6 +138,7 @@ class SiteManager:          call_command("collectstatic", interactive=False, clear=True, verbosity=self.verbosity)          if self.debug: +            self.set_dev_site_name()              self.create_superuser()      def run_server(self) -> None: diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py index dd1291b8..5093e605 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -1,7 +1,13 @@ -from typing import Optional +from __future__ import annotations +import json +from typing import Iterable, Optional, Tuple + +from django import urls  from django.contrib import admin +from django.db.models import QuerySet  from django.http import HttpRequest +from django.utils.html import SafeString, format_html  from .models import (      BotSetting, @@ -17,50 +23,419 @@ from .models import (      User  ) +admin.site.site_header = "Python Discord | Administration" +admin.site.site_title = "Python Discord" + + [email protected](BotSetting) +class BotSettingAdmin(admin.ModelAdmin): +    """Admin formatting for the BotSetting model.""" + +    fields = ("name", "data") +    list_display = ("name",) +    readonly_fields = ("name",) + +    def has_add_permission(self, *args) -> bool: +        """Prevent adding from django admin.""" +        return False + +    def has_delete_permission(self, *args) -> bool: +        """Prevent deleting from django admin.""" +        return False + + [email protected](DocumentationLink) +class DocumentationLinkAdmin(admin.ModelAdmin): +    """Admin formatting for the DocumentationLink model.""" + +    fields = ("package", "base_url", "inventory_url") +    list_display = ("package", "base_url", "inventory_url") +    list_editable = ("base_url", "inventory_url") +    search_fields = ("package",) + + +class InfractionActorFilter(admin.SimpleListFilter): +    """Actor Filter for Infraction Admin list page.""" + +    title = "Actor" +    parameter_name = "actor" + +    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]: +        """Query to filter the list of Users against.""" +        if not self.value(): +            return +        return queryset.filter(actor__id=self.value()) + + [email protected](Infraction) +class InfractionAdmin(admin.ModelAdmin): +    """Admin formatting for the Infraction model.""" + +    fieldsets = ( +        ("Members", {"fields": ("user", "actor")}), +        ("Action", {"fields": ("type", "hidden", "active")}), +        ("Dates", {"fields": ("inserted_at", "expires_at")}), +        ("Reason", {"fields": ("reason",)}), +    ) +    readonly_fields = ( +        "user", +        "actor", +        "type", +        "inserted_at", +        "expires_at", +        "active", +        "hidden" +    ) +    list_display = ( +        "type", +        "active", +        "user", +        "inserted_at", +        "reason", +    ) +    search_fields = ( +        "id", +        "user__name", +        "user__id", +        "actor__name", +        "actor__id", +        "reason", +        "type" +    ) +    list_filter = ( +        "type", +        "hidden", +        "active", +        InfractionActorFilter +    ) + +    def has_add_permission(self, *args) -> bool: +        """Prevent adding from django admin.""" +        return False + [email protected](LogEntry)  class LogEntryAdmin(admin.ModelAdmin):      """Allows viewing logs in the Django Admin without allowing edits."""      actions = None -    list_display = ('timestamp', 'application', 'level', 'message') +    list_display = ('timestamp', 'level', 'message')      fieldsets = (          ('Overview', {'fields': ('timestamp', 'application', 'logger_name')}),          ('Metadata', {'fields': ('level', 'module', 'line')}),          ('Contents', {'fields': ('message',)})      ) -    list_filter = ('application', 'level', 'timestamp') +    list_filter = ('level', 'timestamp')      search_fields = ('message',) -    readonly_fields = ( -        'application', -        'logger_name', -        'timestamp', -        'level', -        'module', -        'line', -        'message' -    )      def has_add_permission(self, request: HttpRequest) -> bool:          """Deny manual LogEntry creation."""          return False -    def has_delete_permission( -            self, -            request: HttpRequest, -            obj: Optional[LogEntry] = None -    ) -> bool: +    def has_change_permission(self, *args) -> bool: +        """Prevent editing from django admin.""" +        return False + +    def has_delete_permission(self, request: HttpRequest, obj: Optional[LogEntry] = None) -> bool:          """Deny LogEntry deletion."""          return False -admin.site.register(BotSetting) -admin.site.register(DeletedMessage) -admin.site.register(DocumentationLink) -admin.site.register(Infraction) -admin.site.register(LogEntry, LogEntryAdmin) -admin.site.register(MessageDeletionContext) -admin.site.register(Nomination) -admin.site.register(OffensiveMessage) -admin.site.register(OffTopicChannelName) -admin.site.register(Role) -admin.site.register(User) [email protected](DeletedMessage) +class DeletedMessageAdmin(admin.ModelAdmin): +    """Admin formatting for the DeletedMessage model.""" + +    fields = ( +        "id", +        "author", +        "channel_id", +        "content", +        "embed_data", +        "context", +        "view_full_log" +    ) + +    exclude = ("embeds", "deletion_context") + +    search_fields = ( +        "id", +        "content", +        "author__name", +        "author__id", +        "deletion_context__actor__name", +        "deletion_context__actor__id" +    ) + +    list_display = ("id", "author", "channel_id") + +    def embed_data(self, message: DeletedMessage) -> Optional[str]: +        """Format embed data in a code block for better readability.""" +        if message.embeds: +            return format_html( +                "<pre style='word-wrap: break-word; white-space: pre-wrap; overflow-x: auto;'>" +                "<code>{0}</code></pre>", +                json.dumps(message.embeds, indent=4) +            ) + +    embed_data.short_description = "Embeds" + +    @staticmethod +    def context(message: DeletedMessage) -> str: +        """Provide full context info with a link through to context admin view.""" +        link = urls.reverse( +            "admin:api_messagedeletioncontext_change", +            args=[message.deletion_context.id] +        ) +        details = ( +            f"Deleted by {message.deletion_context.actor} at " +            f"{message.deletion_context.creation}" +        ) +        return format_html("<a href='{0}'>{1}</a>", link, details) + +    @staticmethod +    def view_full_log(message: DeletedMessage) -> str: +        """Provide a link to the message logs for the relevant context.""" +        return format_html( +            "<a href='{0}'>Click to view full context log</a>", +            message.deletion_context.log_url +        ) + +    def has_add_permission(self, *args) -> bool: +        """Prevent adding from django admin.""" +        return False + +    def has_change_permission(self, *args) -> bool: +        """Prevent editing from django admin.""" +        return False + + +class DeletedMessageInline(admin.TabularInline): +    """Tabular Inline Admin model for Deleted Message to be viewed within Context.""" + +    model = DeletedMessage + + [email protected](MessageDeletionContext) +class MessageDeletionContextAdmin(admin.ModelAdmin): +    """Admin formatting for the MessageDeletionContext model.""" + +    fields = ("actor", "creation") +    list_display = ("id", "creation", "actor") +    inlines = (DeletedMessageInline,) + +    def has_add_permission(self, *args) -> bool: +        """Prevent adding from django admin.""" +        return False + +    def has_change_permission(self, *args) -> bool: +        """Prevent editing from django admin.""" +        return False + + +class NominationActorFilter(admin.SimpleListFilter): +    """Actor Filter for Nomination Admin list page.""" + +    title = "Actor" +    parameter_name = "actor" + +    def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]: +        """Selectable values for viewer to filter by.""" +        actor_ids = Nomination.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]: +        """Query to filter the list of Users against.""" +        if not self.value(): +            return +        return queryset.filter(actor__id=self.value()) + + [email protected](Nomination) +class NominationAdmin(admin.ModelAdmin): +    """Admin formatting for the Nomination model.""" + +    search_fields = ( +        "user__name", +        "user__id", +        "actor__name", +        "actor__id", +        "reason", +        "end_reason" +    ) + +    list_filter = ("active", NominationActorFilter) + +    list_display = ( +        "user", +        "active", +        "reason", +        "actor", +    ) + +    fields = ( +        "user", +        "active", +        "actor", +        "reason", +        "inserted_at", +        "ended_at", +        "end_reason" +    ) + +    # only allow reason fields to be edited. +    readonly_fields = ( +        "user", +        "active", +        "actor", +        "inserted_at", +        "ended_at" +    ) + +    def has_add_permission(self, *args) -> bool: +        """Prevent adding from django admin.""" +        return False + + [email protected](OffTopicChannelName) +class OffTopicChannelNameAdmin(admin.ModelAdmin): +    """Admin formatting for the OffTopicChannelName model.""" + +    search_fields = ("name",) +    list_filter = ("used",) + + [email protected](OffensiveMessage) +class OffensiveMessageAdmin(admin.ModelAdmin): +    """Admin formatting for the OffensiveMessage model.""" + +    def message_jumplink(self, message: OffensiveMessage) -> SafeString: +        """Message ID hyperlinked to the direct discord jumplink.""" +        return format_html( +            '<a href="https://canary.discordapp.com/channels/267624335836053506/{0}/{1}">{1}</a>', +            message.channel_id, +            message.id +        ) + +    message_jumplink.short_description = "Message ID" + +    search_fields = ("id", "channel_id") +    list_display = ("id", "channel_id", "delete_date") +    fields = ("message_jumplink", "channel_id", "delete_date") +    readonly_fields = ("message_jumplink", "channel_id") + +    def has_add_permission(self, *args) -> bool: +        """Prevent adding from django admin.""" +        return False + + [email protected](Role) +class RoleAdmin(admin.ModelAdmin): +    """Admin formatting for the Role model.""" + +    def coloured_name(self, role: Role) -> SafeString: +        """Role name with html style colouring.""" +        return format_html( +            '<span style="color: {0}!important; font-weight: bold;">{1}</span>', +            f"#{role.colour:06X}", +            role.name +        ) + +    coloured_name.short_description = "Name" + +    def colour_with_preview(self, role: Role) -> SafeString: +        """Show colour value in both int and hex, in bolded and coloured style.""" +        return format_html( +            "<span style='color: {0}; font-weight: bold;'>{0} ({1})</span>", +            f"#{role.colour:06x}", +            role.colour +        ) + +    colour_with_preview.short_description = "Colour" + +    def permissions_with_calc_link(self, role: Role) -> SafeString: +        """Show permissions with link to API permissions calculator page.""" +        return format_html( +            "<a href='https://discordapi.com/permissions.html#{0}' target='_blank'>{0}</a>", +            role.permissions +        ) + +    permissions_with_calc_link.short_description = "Permissions" + +    search_fields = ("name", "id") +    list_display = ("coloured_name",) +    fields = ("id", "name", "colour_with_preview", "permissions_with_calc_link", "position") + +    def has_add_permission(self, *args) -> bool: +        """Prevent adding from django admin.""" +        return False + +    def has_change_permission(self, *args) -> bool: +        """Prevent editing from django admin.""" +        return False + + +class UserRoleFilter(admin.SimpleListFilter): +    """List Filter for User list Admin page.""" + +    title = "Role" +    parameter_name = "role" + +    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]: +        """Query to filter the list of Users against.""" +        if not self.value(): +            return +        role = Role.objects.get(name=self.value()) +        return queryset.filter(roles__contains=[role.id]) + + [email protected](User) +class UserAdmin(admin.ModelAdmin): +    """Admin formatting for the User model.""" + +    def top_role_coloured(self, user: User) -> SafeString: +        """Returns the top role of the user with html style matching role colour.""" +        return format_html( +            '<span style="color: {0}; font-weight: bold;">{1}</span>', +            f"#{user.top_role.colour:06X}", +            user.top_role.name +        ) + +    top_role_coloured.short_description = "Top Role" + +    def all_roles_coloured(self, user: User) -> SafeString: +        """Returns all user roles with html style matching role colours.""" +        roles = Role.objects.filter(id__in=user.roles) +        return format_html( +            "</br>".join( +                f'<span style="color: #{r.colour:06X}; font-weight: bold;">{r.name}</span>' +                for r in roles +            ) +        ) + +    all_roles_coloured.short_description = "All Roles" + +    search_fields = ("name", "id", "roles") +    list_filter = (UserRoleFilter, "in_guild") +    list_display = ("username", "top_role_coloured", "in_guild") +    fields = ("username", "id", "in_guild", "all_roles_coloured") +    sortable_by = ("username",) + +    def has_add_permission(self, *args) -> bool: +        """Prevent adding from django admin.""" +        return False + +    def has_change_permission(self, *args) -> bool: +        """Prevent editing from django admin.""" +        return False diff --git a/pydis_site/apps/api/migrations/0064_auto_20200919_1900.py b/pydis_site/apps/api/migrations/0064_auto_20200919_1900.py new file mode 100644 index 00000000..0080eb42 --- /dev/null +++ b/pydis_site/apps/api/migrations/0064_auto_20200919_1900.py @@ -0,0 +1,76 @@ +# Generated by Django 3.0.9 on 2020-09-19 19:00 + +import django.core.validators +from django.db import migrations, models +import pydis_site.apps.api.models.bot.offensive_message + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0063_Allow_blank_or_null_for_nomination_reason'), +    ] + +    operations = [ +        migrations.AlterModelOptions( +            name='deletedmessage', +            options={'ordering': ('-id',)}, +        ), +        migrations.AlterModelOptions( +            name='messagedeletioncontext', +            options={'ordering': ('-creation',)}, +        ), +        migrations.AlterModelOptions( +            name='nomination', +            options={'ordering': ('-inserted_at',)}, +        ), +        migrations.AlterModelOptions( +            name='role', +            options={'ordering': ('-position',)}, +        ), +        migrations.AlterField( +            model_name='deletedmessage', +            name='channel_id', +            field=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.')], verbose_name='Channel ID'), +        ), +        migrations.AlterField( +            model_name='deletedmessage', +            name='id', +            field=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.')], verbose_name='ID'), +        ), +        migrations.AlterField( +            model_name='nomination', +            name='end_reason', +            field=models.TextField(blank=True, default='', help_text='Why the nomination was ended.'), +        ), +        migrations.AlterField( +            model_name='offensivemessage', +            name='channel_id', +            field=models.BigIntegerField(help_text='The channel ID that the message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')], verbose_name='Channel ID'), +        ), +        migrations.AlterField( +            model_name='offensivemessage', +            name='delete_date', +            field=models.DateTimeField(help_text='The date on which the message will be auto-deleted.', validators=[pydis_site.apps.api.models.bot.offensive_message.future_date_validator], verbose_name='To Be Deleted'), +        ), +        migrations.AlterField( +            model_name='offensivemessage', +            name='id', +            field=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.')], verbose_name='Message ID'), +        ), +        migrations.AlterField( +            model_name='role', +            name='id', +            field=models.BigIntegerField(help_text='The role ID, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')], verbose_name='ID'), +        ), +        migrations.AlterField( +            model_name='user', +            name='id', +            field=models.BigIntegerField(help_text='The ID of this user, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='User IDs cannot be negative.')], verbose_name='ID'), +        ), +        migrations.AlterField( +            model_name='user', +            name='in_guild', +            field=models.BooleanField(default=True, help_text='Whether this user is in our server.', verbose_name='In Guild'), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0065_auto_20200919_2033.py b/pydis_site/apps/api/migrations/0065_auto_20200919_2033.py new file mode 100644 index 00000000..89bc4e02 --- /dev/null +++ b/pydis_site/apps/api/migrations/0065_auto_20200919_2033.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.9 on 2020-09-19 20:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0064_auto_20200919_1900'), +    ] + +    operations = [ +        migrations.AlterModelOptions( +            name='documentationlink', +            options={'ordering': ['package']}, +        ), +    ] diff --git a/pydis_site/apps/api/models/bot/deleted_message.py b/pydis_site/apps/api/models/bot/deleted_message.py index 1eb4516e..50b70d8c 100644 --- a/pydis_site/apps/api/models/bot/deleted_message.py +++ b/pydis_site/apps/api/models/bot/deleted_message.py @@ -14,6 +14,6 @@ class DeletedMessage(Message):      )      class Meta: -        """Sets the default ordering for list views to oldest first.""" +        """Sets the default ordering for list views to newest first.""" -        ordering = ["id"] +        ordering = ("-id",) diff --git a/pydis_site/apps/api/models/bot/documentation_link.py b/pydis_site/apps/api/models/bot/documentation_link.py index 5a46460b..2a0ce751 100644 --- a/pydis_site/apps/api/models/bot/documentation_link.py +++ b/pydis_site/apps/api/models/bot/documentation_link.py @@ -24,3 +24,8 @@ class DocumentationLink(ModelReprMixin, models.Model):      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'] diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index f6ae55a5..ff06de21 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -21,7 +21,8 @@ class Message(ModelReprMixin, models.Model):                  limit_value=0,                  message="Message IDs cannot be negative."              ), -        ) +        ), +        verbose_name="ID"      )      author = models.ForeignKey(          User, @@ -38,7 +39,8 @@ class Message(ModelReprMixin, models.Model):                  limit_value=0,                  message="Channel IDs cannot be negative."              ), -        ) +        ), +        verbose_name="Channel ID"      )      content = models.CharField(          max_length=2_000, 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 04ae8d34..1410250a 100644 --- a/pydis_site/apps/api/models/bot/message_deletion_context.py +++ b/pydis_site/apps/api/models/bot/message_deletion_context.py @@ -1,4 +1,5 @@  from django.db import models +from django_hosts.resolvers import reverse  from pydis_site.apps.api.models.bot.user import User  from pydis_site.apps.api.models.mixins import ModelReprMixin @@ -28,3 +29,13 @@ class MessageDeletionContext(ModelReprMixin, models.Model):          # the deletion context does not take place in the future.          help_text="When this deletion took place."      ) + +    @property +    def log_url(self) -> str: +        """Create the url for the deleted message logs.""" +        return reverse('logs', host="staff", args=(self.id,)) + +    class Meta: +        """Set the ordering for list views to newest first.""" + +        ordering = ("-creation",) diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py index 183b22d5..11b9e36e 100644 --- a/pydis_site/apps/api/models/bot/nomination.py +++ b/pydis_site/apps/api/models/bot/nomination.py @@ -34,7 +34,8 @@ class Nomination(ModelReprMixin, models.Model):      )      end_reason = models.TextField(          help_text="Why the nomination was ended.", -        default="" +        default="", +        blank=True      )      ended_at = models.DateTimeField(          auto_now_add=False, @@ -46,3 +47,8 @@ class Nomination(ModelReprMixin, models.Model):          """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 Meta: +        """Set the ordering of nominations to most recent first.""" + +        ordering = ("-inserted_at",) diff --git a/pydis_site/apps/api/models/bot/offensive_message.py b/pydis_site/apps/api/models/bot/offensive_message.py index 6c0e5ffb..74dab59b 100644 --- a/pydis_site/apps/api/models/bot/offensive_message.py +++ b/pydis_site/apps/api/models/bot/offensive_message.py @@ -24,7 +24,8 @@ class OffensiveMessage(ModelReprMixin, models.Model):                  limit_value=0,                  message="Message IDs cannot be negative."              ), -        ) +        ), +        verbose_name="Message ID"      )      channel_id = models.BigIntegerField(          help_text=( @@ -36,11 +37,13 @@ class OffensiveMessage(ModelReprMixin, models.Model):                  limit_value=0,                  message="Channel IDs cannot be negative."              ), -        ) +        ), +        verbose_name="Channel ID"      )      delete_date = models.DateTimeField(          help_text="The date on which the message will be auto-deleted.", -        validators=(future_date_validator,) +        validators=(future_date_validator,), +        verbose_name="To Be Deleted"      )      def __str__(self): diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py index 721e4815..cfadfec4 100644 --- a/pydis_site/apps/api/models/bot/role.py +++ b/pydis_site/apps/api/models/bot/role.py @@ -22,7 +22,8 @@ class Role(ModelReprMixin, models.Model):                  message="Role IDs cannot be negative."              ),          ), -        help_text="The role ID, taken from Discord." +        help_text="The role ID, taken from Discord.", +        verbose_name="ID"      )      name = models.CharField(          max_length=100, @@ -65,3 +66,8 @@ 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/bot/user.py b/pydis_site/apps/api/models/bot/user.py index cd2d58b9..afc5ba1e 100644 --- a/pydis_site/apps/api/models/bot/user.py +++ b/pydis_site/apps/api/models/bot/user.py @@ -26,11 +26,12 @@ class User(ModelReprMixin, models.Model):                  message="User IDs cannot be negative."              ),          ), +        verbose_name="ID",          help_text="The ID of this user, taken from Discord."      )      name = models.CharField(          max_length=32, -        help_text="The username, taken from Discord." +        help_text="The username, taken from Discord.",      )      discriminator = models.PositiveSmallIntegerField(          validators=( @@ -57,12 +58,13 @@ class User(ModelReprMixin, models.Model):      )      in_guild = models.BooleanField(          default=True, -        help_text="Whether this user is in our server." +        help_text="Whether this user is in our server.", +        verbose_name="In Guild"      )      def __str__(self):          """Returns the name and discriminator for the current user, for display purposes.""" -        return f"{self.name}#{self.discriminator:0>4}" +        return f"{self.name}#{self.discriminator:04d}"      @property      def top_role(self) -> Role: @@ -75,3 +77,12 @@ class User(ModelReprMixin, models.Model):          if not roles:              return Role.objects.get(name="Developers")          return max(roles) + +    @property +    def username(self) -> str: +        """ +        Returns the display version with name and discriminator as a standard attribute. + +        For usability in read-only fields such as Django Admin. +        """ +        return str(self) diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py index f079a8dd..40450844 100644 --- a/pydis_site/apps/api/tests/test_deleted_messages.py +++ b/pydis_site/apps/api/tests/test_deleted_messages.py @@ -1,5 +1,6 @@  from datetime import datetime +from django.utils import timezone  from django_hosts.resolvers import reverse  from .base import APISubdomainTestCase @@ -76,3 +77,23 @@ class DeletedMessagesWithActorTests(APISubdomainTestCase):          self.assertEqual(response.status_code, 201)          [context] = MessageDeletionContext.objects.all()          self.assertEqual(context.actor.id, self.actor.id) + + +class DeletedMessagesLogURLTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls): +        cls.author = cls.actor = User.objects.create( +            id=324888, +            name='Black Knight', +            discriminator=1975, +        ) + +        cls.deletion_context = MessageDeletionContext.objects.create( +            actor=cls.actor, +            creation=timezone.now() +        ) + +    def test_valid_log_url(self): +        expected_url = reverse('logs', host="staff", args=(1,)) +        [context] = MessageDeletionContext.objects.all() +        self.assertEqual(context.log_url, expected_url) diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py index 4c0f6e27..a02fce8a 100644 --- a/pydis_site/apps/api/tests/test_users.py +++ b/pydis_site/apps/api/tests/test_users.py @@ -143,7 +143,7 @@ class UserModelTests(APISubdomainTestCase):          cls.user_with_roles = User.objects.create(              id=1,              name="Test User with two roles", -            discriminator=1111, +            discriminator=1,              in_guild=True,          )          cls.user_with_roles.roles.extend([cls.role_bottom.id, cls.role_top.id]) @@ -166,3 +166,7 @@ class UserModelTests(APISubdomainTestCase):          top_role = self.user_without_roles.top_role          self.assertIsInstance(top_role, Role)          self.assertEqual(top_role.id, self.developers_role.id) + +    def test_correct_username_formatting(self): +        """Tests the username property with both name and discriminator formatted together.""" +        self.assertEqual(self.user_with_roles.username, "Test User with two roles#0001") diff --git a/pydis_site/apps/staff/tests/test_logs_view.py b/pydis_site/apps/staff/tests/test_logs_view.py index 17910bb6..00e0ab2f 100644 --- a/pydis_site/apps/staff/tests/test_logs_view.py +++ b/pydis_site/apps/staff/tests/test_logs_view.py @@ -133,7 +133,7 @@ class TestLogsView(TestCase):          response = self.client.get(url)          self.assertIn("messages", response.context)          self.assertListEqual( -            [self.deleted_message_one, self.deleted_message_two], +            [self.deleted_message_two, self.deleted_message_one],              list(response.context["deletion_context"].deletedmessage_set.all())          ) | 
