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()) ) |