diff options
Diffstat (limited to 'pydis_site/apps')
21 files changed, 605 insertions, 429 deletions
| 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/0063_Allow_blank_or_null_for_nomination_reason.py b/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py new file mode 100644 index 00000000..9eb05eaa --- /dev/null +++ b/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.9 on 2020-09-11 21:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0062_merge_20200901_1459'), +    ] + +    operations = [ +        migrations.AlterField( +            model_name='nomination', +            name='reason', +            field=models.TextField(blank=True, help_text='Why this user was nominated.', null=True), +        ), +    ] 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 21e34e87..11b9e36e 100644 --- a/pydis_site/apps/api/models/bot/nomination.py +++ b/pydis_site/apps/api/models/bot/nomination.py @@ -18,7 +18,9 @@ class Nomination(ModelReprMixin, models.Model):          related_name='nomination_set'      )      reason = models.TextField( -        help_text="Why this user was nominated." +        help_text="Why this user was nominated.", +        null=True, +        blank=True      )      user = models.ForeignKey(          User, @@ -32,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, @@ -44,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_nominations.py b/pydis_site/apps/api/tests/test_nominations.py index 92c62c87..b37135f8 100644 --- a/pydis_site/apps/api/tests/test_nominations.py +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -80,7 +80,7 @@ class CreationTests(APISubdomainTestCase):              'actor': ['This field is required.']          }) -    def test_returns_400_for_missing_reason(self): +    def test_returns_201_for_missing_reason(self):          url = reverse('bot:nomination-list', host='api')          data = {              'user': self.user.id, @@ -88,10 +88,7 @@ class CreationTests(APISubdomainTestCase):          }          response = self.client.post(url, data=data) -        self.assertEqual(response.status_code, 400) -        self.assertEqual(response.json(), { -            'reason': ['This field is required.'] -        }) +        self.assertEqual(response.status_code, 201)      def test_returns_400_for_bad_user(self):          url = reverse('bot:nomination-list', host='api') 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/home/templatetags/extra_filters.py b/pydis_site/apps/home/templatetags/extra_filters.py index d63b3245..89b45831 100644 --- a/pydis_site/apps/home/templatetags/extra_filters.py +++ b/pydis_site/apps/home/templatetags/extra_filters.py @@ -11,7 +11,7 @@ def starts_with(value: str, arg: str) -> bool:      Usage:      ```django -        {% if request.url | starts_with:"/wiki" %} +        {% if request.url | starts_with:"/events" %}            ...          {% endif %}      ``` diff --git a/pydis_site/apps/home/templatetags/wiki_extra.py b/pydis_site/apps/home/templatetags/wiki_extra.py deleted file mode 100644 index b4b720bf..00000000 --- a/pydis_site/apps/home/templatetags/wiki_extra.py +++ /dev/null @@ -1,132 +0,0 @@ -from typing import Any, Dict, List, Type, Union - -from django import template -from django.forms import BooleanField, BoundField, CharField, Field, ImageField, ModelChoiceField -from django.template import Context -from django.template.loader import get_template -from django.utils.safestring import SafeText, mark_safe -from wiki.editors.markitup import MarkItUpWidget -from wiki.forms import WikiSlugField -from wiki.models import URLPath -from wiki.plugins.notifications.forms import SettingsModelChoiceField - -TEMPLATE_PATH = "wiki/forms/fields/{0}.html" - -TEMPLATES: Dict[Type, str] = { -    BooleanField: TEMPLATE_PATH.format("boolean"), -    CharField: TEMPLATE_PATH.format("char"), -    ImageField: TEMPLATE_PATH.format("image"), - -    ModelChoiceField: TEMPLATE_PATH.format("model_choice"), -    SettingsModelChoiceField: TEMPLATE_PATH.format("model_choice"), -    WikiSlugField: TEMPLATE_PATH.format("wiki_slug_render"), -} - - -register = template.Library() - - -def get_unbound_field(field: Union[BoundField, Field]) -> Field: -    """ -    Unwraps a bound Django Forms field, returning the unbound field. - -    Bound fields often don't give you the same level of access to the field's underlying attributes, -    so sometimes it helps to have access to the underlying field object. -    """ -    while isinstance(field, BoundField): -        field = field.field - -    return field - - -def render(template_path: str, context: Dict[str, Any]) -> SafeText: -    """ -    Renders a template at a specified path, with the provided context dictionary. - -    This was extracted mostly for the sake of mocking it out in the tests - but do note that -    the resulting rendered template is wrapped with `mark_safe`, so it will not be escaped. -    """ -    return mark_safe(get_template(template_path).render(context))  # noqa: S703, S308 - - -def render_field(field: Field, render_labels: bool = True) -> SafeText: -    """ -    Renders a form field using a custom template designed specifically for the wiki forms. - -    As the wiki uses custom form rendering logic, we were unable to make use of Crispy Forms for -    it. This means that, in order to customize the form fields, we needed to be able to render -    the fields manually. This function handles that logic. - -    Sometimes we don't want to render the label that goes with a field - the `render_labels` -    argument defaults to True, but can be set to False if the label shouldn't be rendered. - -    The label rendering logic is left up to the template. - -    Usage: `{% render_field field_obj [render_labels=True/False] %}` -    """ -    unbound_field = get_unbound_field(field) - -    if not isinstance(render_labels, bool): -        render_labels = True - -    template_path = TEMPLATES.get(unbound_field.__class__, TEMPLATE_PATH.format("in_place_render")) -    is_markitup = isinstance(unbound_field.widget, MarkItUpWidget) -    context = {"field": field, "is_markitup": is_markitup, "render_labels": render_labels} - -    return render(template_path, context) - - [email protected]_tag(takes_context=True) -def get_field_options(context: Context, field: BoundField) -> str: -    """ -    Retrieves the field options for a multiple choice field, and stores it in the context. - -    This tag exists because we can't call functions within Django templates directly, and is -    only made use of in the template for ModelChoice (and derived) fields - but would work fine -    with anything that makes use of your standard `<select>` element widgets. - -    This stores the parsed options under `options` in the context, which will subsequently -    be available in the template. - -    Usage: - -    ```django -    {% get_field_options field_object %} - -    {% if options %} -      {% for group_name, group_choices, group_index in options %} -        ... -      {% endfor %} -    {% endif %} -    ``` -    """ -    widget = field.field.widget - -    if field.value() is None: -        value: List[str] = [] -    else: -        value = [str(field.value())] - -    context["options"] = widget.optgroups(field.name, value) -    return "" - - -def render_urlpath(value: Union[URLPath, str]) -> str: -    """ -    Simple filter to render a URLPath (or string) into a template. - -    This is used where the wiki intends to render a path - mostly because if you just -    `str(url_path)`, you'll actually get a path that starts with `(root)` instead of `/`. - -    We support strings here as well because the wiki is very inconsistent about when it -    provides a string versus when it provides a URLPath, and it was too much work to figure out -    and account for it in the templates. - -    Usage: `{{ url_path | render_urlpath }}` -    """ -    if isinstance(value, str): -        return value or "/" - -    return value.path or "/" diff --git a/pydis_site/apps/home/tests/test_wiki_templatetags.py b/pydis_site/apps/home/tests/test_wiki_templatetags.py deleted file mode 100644 index e1e2a02c..00000000 --- a/pydis_site/apps/home/tests/test_wiki_templatetags.py +++ /dev/null @@ -1,238 +0,0 @@ -from unittest.mock import Mock, create_autospec - -from django.forms import ( -    BooleanField, BoundField, CharField, ChoiceField, Field, Form, ImageField, -    ModelChoiceField -) -from django.template import Context, Template -from django.test import TestCase -from wiki.editors.markitup import MarkItUpWidget -from wiki.forms import WikiSlugField -from wiki.models import Article, URLPath as _URLPath -from wiki.plugins.notifications.forms import SettingsModelChoiceField - -from pydis_site.apps.home.templatetags import wiki_extra - -URLPath = Mock(_URLPath) - - -class TestURLPathFilter(TestCase): -    TEMPLATE = Template( -        """ -        {% load wiki_extra %} -        {{ obj|render_urlpath }} -        """ -    ) - -    def test_str(self): -        context = {"obj": "/path/"} -        rendered = self.TEMPLATE.render(Context(context)) - -        self.assertEqual(rendered.strip(), "/path/") - -    def test_str_empty(self): -        context = {"obj": ""} -        rendered = self.TEMPLATE.render(Context(context)) - -        self.assertEqual(rendered.strip(), "/") - -    def test_urlpath(self): -        url_path = URLPath() -        url_path.path = "/path/" - -        context = {"obj": url_path} -        rendered = self.TEMPLATE.render(Context(context)) - -        self.assertEqual(rendered.strip(), "/path/") - -    def test_urlpath_root(self): -        url_path = URLPath() -        url_path.path = None - -        context = {"obj": url_path} -        rendered = self.TEMPLATE.render(Context(context)) - -        self.assertEqual(rendered.strip(), "/") - - -class TestRenderField(TestCase): -    TEMPLATE = Template( -        """ -        {% load wiki_extra %} -        {% render_field field %} -        """ -    ) - -    TEMPLATE_NO_LABELS = Template( -        """ -        {% load wiki_extra %} -        {% render_field field render_labels=False %} -        """ -    ) - -    TEMPLATE_LABELS_NOT_BOOLEAN = Template( -        """ -        {% load wiki_extra %} -        {% render_field field render_labels="" %} -        """ -    ) - -    def test_bound_field(self): -        unbound_field = Field() -        field = BoundField(Form(), unbound_field, "field") - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -    def test_bound_field_no_labels(self): -        unbound_field = Field() -        field = BoundField(Form(), unbound_field, "field") - -        context = Context({"field": field}) -        self.TEMPLATE_NO_LABELS.render(context) - -    def test_bound_field_labels_not_boolean(self): -        unbound_field = Field() -        field = BoundField(Form(), unbound_field, "field") - -        context = Context({"field": field}) -        self.TEMPLATE_LABELS_NOT_BOOLEAN.render(context) - -    def test_unbound_field(self): -        field = Field() - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -    def test_unbound_field_no_labels(self): -        field = Field() - -        context = Context({"field": field}) -        self.TEMPLATE_NO_LABELS.render(context) - -    def test_unbound_field_labels_not_boolean(self): -        field = Field() - -        context = Context({"field": field}) -        self.TEMPLATE_LABELS_NOT_BOOLEAN.render(context) - - -class TestRenderFieldTypes(TestCase): -    TEMPLATE = Template( -        """ -        {% load wiki_extra %} -        {% render_field field %} -        """ -    ) - -    @classmethod -    def setUpClass(cls): -        cls._wiki_extra_render = wiki_extra.render -        wiki_extra.render = create_autospec(wiki_extra.render, return_value="") - -    @classmethod -    def tearDownClass(cls): -        wiki_extra.render = cls._wiki_extra_render -        del cls._wiki_extra_render - -    def test_field_boolean(self): -        field = BooleanField() - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -        template_path = "wiki/forms/fields/boolean.html" -        context = {"field": field, "is_markitup": False, "render_labels": True} - -        wiki_extra.render.assert_called_with(template_path, context) - -    def test_field_char(self): -        field = CharField() -        field.widget = None - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -        template_path = "wiki/forms/fields/char.html" -        context = {"field": field, "is_markitup": False, "render_labels": True} - -        wiki_extra.render.assert_called_with(template_path, context) - -    def test_field_char_markitup(self): -        field = CharField() -        field.widget = MarkItUpWidget() - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -        template_path = "wiki/forms/fields/char.html" -        context = {"field": field, "is_markitup": True, "render_labels": True} - -        wiki_extra.render.assert_called_with(template_path, context) - -    def test_field_image(self): -        field = ImageField() - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -        template_path = "wiki/forms/fields/image.html" -        context = {"field": field, "is_markitup": False, "render_labels": True} - -        wiki_extra.render.assert_called_with(template_path, context) - -    def test_field_model_choice(self): -        field = ModelChoiceField(Article.objects.all()) - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -        template_path = "wiki/forms/fields/model_choice.html" -        context = {"field": field, "is_markitup": False, "render_labels": True} - -        wiki_extra.render.assert_called_with(template_path, context) - -    def test_field_settings_model_choice(self): -        field = SettingsModelChoiceField(Article.objects.all()) - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -        template_path = "wiki/forms/fields/model_choice.html" -        context = {"field": field, "is_markitup": False, "render_labels": True} - -        wiki_extra.render.assert_called_with(template_path, context) - -    def test_field_wiki_slug(self): -        field = WikiSlugField() - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -        template_path = "wiki/forms/fields/wiki_slug_render.html" -        context = {"field": field, "is_markitup": False, "render_labels": True} - -        wiki_extra.render.assert_called_with(template_path, context) - - -class TestGetFieldOptions(TestCase): -    TEMPLATE = Template( -        """ -        {% load wiki_extra %} -        {% get_field_options field %} -        """ -    ) - -    def test_get_field_options(self): -        unbound_field = ChoiceField() -        field = BoundField(Form(), unbound_field, "field") - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) - -    def test_get_field_options_value(self): -        unbound_field = ChoiceField() -        field = BoundField(Form(initial={"field": "Value"}), unbound_field, "field") - -        context = Context({"field": field}) -        self.TEMPLATE.render(context) diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py index e49fd4e0..c7e36156 100644 --- a/pydis_site/apps/home/urls.py +++ b/pydis_site/apps/home/urls.py @@ -1,6 +1,4 @@  from allauth.account.views import LogoutView -from django.conf import settings -from django.conf.urls.static import static  from django.contrib import admin  from django.contrib.messages import ERROR  from django.urls import include, path @@ -14,8 +12,6 @@ urlpatterns = [      path('', HomeView.as_view(), name='home'),      path('', HomeView.as_view(), name='socialaccount_connections'), -    path('pages/', include('wiki.urls')), -      path('accounts/', include('allauth.socialaccount.providers.discord.urls')),      path('accounts/', include('allauth.socialaccount.providers.github.urls')), @@ -37,7 +33,5 @@ urlpatterns = [      path('logout', LogoutView.as_view(), name="logout"),      path('admin/', admin.site.urls), -    path('notifications/', include('django_nyt.urls')), -      path('content/', include('pydis_site.apps.content.urls', namespace='content')), -] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +] 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())          ) diff --git a/pydis_site/apps/staff/urls.py b/pydis_site/apps/staff/urls.py index a564d516..ca8d1a0f 100644 --- a/pydis_site/apps/staff/urls.py +++ b/pydis_site/apps/staff/urls.py @@ -1,5 +1,3 @@ -from django.conf import settings -from django.conf.urls.static import static  from django.urls import path  from .viewsets import LogView @@ -7,4 +5,4 @@ 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) +] | 
