diff options
Diffstat (limited to 'pydis_site')
52 files changed, 1229 insertions, 449 deletions
diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py index 0333fefc..b6fee9d1 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -1,68 +1,413 @@ -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,      DeletedMessage,      DocumentationLink,      Infraction, -    LogEntry,      MessageDeletionContext,      Nomination,      OffTopicChannelName,      OffensiveMessage,      Role, -    Tag,      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.""" -class LogEntryAdmin(admin.ModelAdmin): -    """Allows viewing logs in the Django Admin without allowing edits.""" +    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.""" -    actions = None -    list_display = ('timestamp', 'application', 'level', 'message')      fieldsets = ( -        ('Overview', {'fields': ('timestamp', 'application', 'logger_name')}), -        ('Metadata', {'fields': ('level', 'module', 'line')}), -        ('Contents', {'fields': ('message',)}) +        ("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](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",      ) -    list_filter = ('application', 'level', 'timestamp') -    search_fields = ('message',) + +    fields = ( +        "user", +        "active", +        "actor", +        "reason", +        "inserted_at", +        "ended_at", +        "end_reason" +    ) + +    # only allow reason fields to be edited.      readonly_fields = ( -        'application', -        'logger_name', -        'timestamp', -        'level', -        'module', -        'line', -        'message' +        "user", +        "active", +        "actor", +        "inserted_at", +        "ended_at"      ) -    def has_add_permission(self, request: HttpRequest) -> bool: -        """Deny manual LogEntry creation.""" +    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 -    def has_delete_permission( -            self, -            request: HttpRequest, -            obj: Optional[LogEntry] = None -    ) -> bool: -        """Deny LogEntry deletion.""" + [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 -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(Tag) -admin.site.register(User) +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/dblogger.py b/pydis_site/apps/api/dblogger.py deleted file mode 100644 index 4b4e3a9d..00000000 --- a/pydis_site/apps/api/dblogger.py +++ /dev/null @@ -1,22 +0,0 @@ -from logging import LogRecord, StreamHandler - - -class DatabaseLogHandler(StreamHandler): -    """Logs entries into the database.""" - -    def emit(self, record: LogRecord) -> None: -        """Write the given `record` into the database.""" -        # This import needs to be deferred due to Django's application -        # registry instantiation logic loading this handler before the -        # application is ready. -        from pydis_site.apps.api.models.log_entry import LogEntry - -        entry = LogEntry( -            application='site', -            logger_name=record.name, -            level=record.levelname.lower(), -            module=record.module, -            line=record.lineno, -            message=self.format(record) -        ) -        entry.save() diff --git a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py index d53ddb90..d92042d2 100644 --- a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py +++ b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py @@ -1,7 +1,5 @@  # Generated by Django 2.1.1 on 2018-09-23 10:07 -import pydis_site.apps.api.models.bot.tag -import django.contrib.postgres.fields.jsonb  from django.db import migrations @@ -12,9 +10,4 @@ class Migration(migrations.Migration):      ]      operations = [ -        migrations.AlterField( -            model_name='tag', -            name='embed', -            field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.', validators=[pydis_site.apps.api.models.bot.tag.validate_tag_embed]), -        ),      ] diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py index 33746253..6b848d64 100644 --- a/pydis_site/apps/api/migrations/0019_deletedmessage.py +++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py @@ -18,7 +18,7 @@ class Migration(migrations.Migration):                  ('id', 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.')])),                  ('channel_id', 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.')])),                  ('content', models.CharField(help_text='The content of this message, taken from Discord.', max_length=2000)), -                ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.bot.tag.validate_tag_embed]), help_text='Embeds attached to this message.', size=None)), +                ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), help_text='Embeds attached to this message.', size=None)),                  ('author', models.ForeignKey(help_text='The author of this message.', on_delete=django.db.models.deletion.CASCADE, to='api.User')),                  ('deletion_context', models.ForeignKey(help_text='The deletion context this message is part of.', on_delete=django.db.models.deletion.CASCADE, to='api.MessageDeletionContext')),              ], diff --git a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py index e617e1c9..124c6a57 100644 --- a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py +++ b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py @@ -3,7 +3,7 @@  import django.contrib.postgres.fields  import django.contrib.postgres.fields.jsonb  from django.db import migrations -import pydis_site.apps.api.models.bot.tag +import pydis_site.apps.api.models.utils  class Migration(migrations.Migration): @@ -16,6 +16,6 @@ class Migration(migrations.Migration):          migrations.AlterField(              model_name='deletedmessage',              name='embeds', -            field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.bot.tag.validate_tag_embed]), blank=True, help_text='Embeds attached to this message.', size=None), +            field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None),          ),      ] diff --git a/pydis_site/apps/api/migrations/0051_delete_tag.py b/pydis_site/apps/api/migrations/0051_delete_tag.py new file mode 100644 index 00000000..bada5788 --- /dev/null +++ b/pydis_site/apps/api/migrations/0051_delete_tag.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2.11 on 2020-04-01 06:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0050_remove_infractions_active_default_value'), +    ] + +    operations = [ +        migrations.DeleteModel( +            name='Tag', +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py b/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py new file mode 100644 index 00000000..dfdf3835 --- /dev/null +++ b/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2020-03-30 10:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0051_create_news_setting'), +    ] + +    operations = [ +        migrations.AddField( +            model_name='offtopicchannelname', +            name='used', +            field=models.BooleanField(default=False, help_text='Whether or not this name has already been used during this rotation'), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py b/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py new file mode 100644 index 00000000..f0668696 --- /dev/null +++ b/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.8 on 2020-08-30 05:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0060_populate_filterlists_fix'), +        ('api', '0052_offtopicchannelname_used'), +    ] + +    operations = [ +    ] diff --git a/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py b/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py new file mode 100644 index 00000000..d162acf1 --- /dev/null +++ b/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.8 on 2020-09-01 14:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0051_delete_tag'), +        ('api', '0061_merge_20200830_0526'), +    ] + +    operations = [ +    ] 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/0064_delete_logentry.py b/pydis_site/apps/api/migrations/0064_delete_logentry.py new file mode 100644 index 00000000..a5f344d1 --- /dev/null +++ b/pydis_site/apps/api/migrations/0064_delete_logentry.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.9 on 2020-10-03 06:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0063_Allow_blank_or_null_for_nomination_reason'), +    ] + +    operations = [ +        migrations.DeleteModel( +            name='LogEntry', +        ), +    ] 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/migrations/0066_merge_20201003_0730.py b/pydis_site/apps/api/migrations/0066_merge_20201003_0730.py new file mode 100644 index 00000000..298416db --- /dev/null +++ b/pydis_site/apps/api/migrations/0066_merge_20201003_0730.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.9 on 2020-10-03 07:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0064_delete_logentry'), +        ('api', '0065_auto_20200919_2033'), +    ] + +    operations = [ +    ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index 1d0ab7ea..0a8c90f6 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -12,7 +12,5 @@ from .bot import (      OffTopicChannelName,      Reminder,      Role, -    Tag,      User  ) -from .log_entry import LogEntry diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index efd98184..1673b434 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -11,5 +11,4 @@ from .off_topic_channel_name import OffTopicChannelName  from .offensive_message import OffensiveMessage  from .reminder import Reminder  from .role import Role -from .tag import Tag  from .user import User 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 78dcbf1d..ff06de21 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -5,9 +5,9 @@ from django.core.validators import MinValueValidator  from django.db import models  from django.utils import timezone -from pydis_site.apps.api.models.bot.tag import validate_tag_embed  from pydis_site.apps.api.models.bot.user import User  from pydis_site.apps.api.models.mixins import ModelReprMixin +from pydis_site.apps.api.models.utils import validate_embed  class Message(ModelReprMixin, models.Model): @@ -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, @@ -47,7 +49,7 @@ class Message(ModelReprMixin, models.Model):      )      embeds = pgfields.ArrayField(          pgfields.JSONField( -            validators=(validate_tag_embed,) +            validators=(validate_embed,)          ),          blank=True,          help_text="Embeds attached to this message." 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/off_topic_channel_name.py b/pydis_site/apps/api/models/bot/off_topic_channel_name.py index 20e77b9f..403c7465 100644 --- a/pydis_site/apps/api/models/bot/off_topic_channel_name.py +++ b/pydis_site/apps/api/models/bot/off_topic_channel_name.py @@ -16,6 +16,11 @@ class OffTopicChannelName(ModelReprMixin, models.Model):          help_text="The actual channel name that will be used on our Discord server."      ) +    used = models.BooleanField( +        default=False, +        help_text="Whether or not this name has already been used during this rotation", +    ) +      def __str__(self):          """Returns the current off-topic name, for display purposes."""          return self.name 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/models/log_entry.py b/pydis_site/apps/api/models/log_entry.py deleted file mode 100644 index 752cd2ca..00000000 --- a/pydis_site/apps/api/models/log_entry.py +++ /dev/null @@ -1,55 +0,0 @@ -from django.db import models -from django.utils import timezone - -from pydis_site.apps.api.models.mixins import ModelReprMixin - - -class LogEntry(ModelReprMixin, models.Model): -    """A log entry generated by one of the PyDis applications.""" - -    application = models.CharField( -        max_length=20, -        help_text="The application that generated this log entry.", -        choices=( -            ('bot', 'Bot'), -            ('seasonalbot', 'Seasonalbot'), -            ('site', 'Website') -        ) -    ) -    logger_name = models.CharField( -        max_length=100, -        help_text="The name of the logger that generated this log entry." -    ) -    timestamp = models.DateTimeField( -        default=timezone.now, -        help_text="The date and time when this entry was created." -    ) -    level = models.CharField( -        max_length=8,  # 'critical' -        choices=( -            ('debug', 'Debug'), -            ('info', 'Info'), -            ('warning', 'Warning'), -            ('error', 'Error'), -            ('critical', 'Critical') -        ), -        help_text=( -            "The logger level at which this entry was emitted. The levels " -            "correspond to the Python `logging` levels." -        ) -    ) -    module = models.CharField( -        max_length=100, -        help_text="The fully qualified path of the module generating this log line." -    ) -    line = models.PositiveSmallIntegerField( -        help_text="The line at which the log line was emitted." -    ) -    message = models.TextField( -        help_text="The textual content of the log line." -    ) - -    class Meta: -        """Customizes the default generated plural name to valid English.""" - -        verbose_name_plural = 'Log entries' diff --git a/pydis_site/apps/api/models/bot/tag.py b/pydis_site/apps/api/models/utils.py index 5e53582f..107231ba 100644 --- a/pydis_site/apps/api/models/bot/tag.py +++ b/pydis_site/apps/api/models/utils.py @@ -1,12 +1,8 @@  from collections.abc import Mapping  from typing import Any, Dict -from django.contrib.postgres import fields as pgfields  from django.core.exceptions import ValidationError  from django.core.validators import MaxLengthValidator, MinLengthValidator -from django.db import models - -from pydis_site.apps.api.models.mixins import ModelReprMixin  def is_bool_validator(value: Any) -> None: @@ -15,7 +11,7 @@ def is_bool_validator(value: Any) -> None:          raise ValidationError(f"This field must be of type bool, not {type(value)}.") -def validate_tag_embed_fields(fields: dict) -> None: +def validate_embed_fields(fields: dict) -> None:      """Raises a ValidationError if any of the given embed fields is invalid."""      field_validators = {          'name': (MaxLengthValidator(limit_value=256),), @@ -42,7 +38,7 @@ def validate_tag_embed_fields(fields: dict) -> None:                  validator(value) -def validate_tag_embed_footer(footer: Dict[str, str]) -> None: +def validate_embed_footer(footer: Dict[str, str]) -> None:      """Raises a ValidationError if the given footer is invalid."""      field_validators = {          'text': ( @@ -67,7 +63,7 @@ def validate_tag_embed_footer(footer: Dict[str, str]) -> None:              validator(value) -def validate_tag_embed_author(author: Any) -> None: +def validate_embed_author(author: Any) -> None:      """Raises a ValidationError if the given author is invalid."""      field_validators = {          'name': ( @@ -93,7 +89,7 @@ def validate_tag_embed_author(author: Any) -> None:              validator(value) -def validate_tag_embed(embed: Any) -> None: +def validate_embed(embed: Any) -> None:      """      Validate a JSON document containing an embed as possible to send on Discord. @@ -109,11 +105,11 @@ def validate_tag_embed(embed: Any) -> None:          >>> from django.contrib.postgres import fields as pgfields          >>> from django.db import models -        >>> from pydis_site.apps.api.models.bot.tag import validate_tag_embed +        >>> from pydis_site.apps.api.models.utils import validate_embed          >>> class MyMessage(models.Model):          ...     embed = pgfields.JSONField(          ...         validators=( -        ...             validate_tag_embed, +        ...             validate_embed,          ...         )          ...     )          ...     # ... @@ -149,10 +145,10 @@ def validate_tag_embed(embed: Any) -> None:          'description': (MaxLengthValidator(limit_value=2048),),          'fields': (              MaxLengthValidator(limit_value=25), -            validate_tag_embed_fields +            validate_embed_fields          ), -        'footer': (validate_tag_embed_footer,), -        'author': (validate_tag_embed_author,) +        'footer': (validate_embed_footer,), +        'author': (validate_embed_author,)      }      if not embed: @@ -175,24 +171,3 @@ def validate_tag_embed(embed: Any) -> None:          if field_name in field_validators:              for validator in field_validators[field_name]:                  validator(value) - - -class Tag(ModelReprMixin, models.Model): -    """A tag providing (hopefully) useful information.""" - -    title = models.CharField( -        max_length=100, -        help_text=( -            "The title of this tag, shown in searches and providing " -            "a quick overview over what this embed contains." -        ), -        primary_key=True -    ) -    embed = pgfields.JSONField( -        help_text="The actual embed shown by this tag.", -        validators=(validate_tag_embed,) -    ) - -    def __str__(self): -        """Returns the title of this tag, for display purposes.""" -        return self.title diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 52e0d972..25c5c82e 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -1,7 +1,16 @@  """Converters from Django models to data interchange formats and back.""" -from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError +from django.db.models.query import QuerySet +from django.db.utils import IntegrityError +from rest_framework.exceptions import NotFound +from rest_framework.serializers import ( +    IntegerField, +    ListSerializer, +    ModelSerializer, +    PrimaryKeyRelatedField, +    ValidationError +) +from rest_framework.settings import api_settings  from rest_framework.validators import UniqueTogetherValidator -from rest_framework_bulk import BulkSerializerMixin  from .models import (      BotSetting, @@ -9,14 +18,12 @@ from .models import (      DocumentationLink,      FilterList,      Infraction, -    LogEntry,      MessageDeletionContext,      Nomination,      OffTopicChannelName,      OffensiveMessage,      Reminder,      Role, -    Tag,      User  ) @@ -192,19 +199,6 @@ class ExpandedInfractionSerializer(InfractionSerializer):          return ret -class LogEntrySerializer(ModelSerializer): -    """A class providing (de-)serialization of `LogEntry` instances.""" - -    class Meta: -        """Metadata defined for the Django REST Framework.""" - -        model = LogEntry -        fields = ( -            'application', 'logger_name', 'timestamp', -            'level', 'module', 'line', 'message' -        ) - -  class OffTopicChannelNameSerializer(ModelSerializer):      """A class providing (de-)serialization of `OffTopicChannelName` instances.""" @@ -250,25 +244,98 @@ class RoleSerializer(ModelSerializer):          fields = ('id', 'name', 'colour', 'permissions', 'position') -class TagSerializer(ModelSerializer): -    """A class providing (de-)serialization of `Tag` instances.""" +class UserListSerializer(ListSerializer): +    """List serializer for User model to handle bulk updates.""" -    class Meta: -        """Metadata defined for the Django REST Framework.""" +    def create(self, validated_data: list) -> list: +        """Override create method to optimize django queries.""" +        new_users = [] +        seen = set() + +        for user_dict in validated_data: +            if user_dict["id"] in seen: +                raise ValidationError( +                    {"id": [f"User with ID {user_dict['id']} given multiple times."]} +                ) +            seen.add(user_dict["id"]) +            new_users.append(User(**user_dict)) + +        User.objects.bulk_create(new_users, ignore_conflicts=True) +        return [] -        model = Tag -        fields = ('title', 'embed') +    def update(self, queryset: QuerySet, validated_data: list) -> list: +        """ +        Override update method to support bulk updates. + +        ref:https://www.django-rest-framework.org/api-guide/serializers/#customizing-multiple-update +        """ +        object_ids = set() + +        for data in validated_data: +            try: +                if data["id"] in object_ids: +                    # If request data contains users with same ID. +                    raise ValidationError( +                        {"id": [f"User with ID {data['id']} given multiple times."]} +                    ) +            except KeyError: +                # If user ID not provided in request body. +                raise ValidationError( +                    {"id": ["This field is required."]} +                ) +            object_ids.add(data["id"]) + +        # filter queryset +        filtered_instances = queryset.filter(id__in=object_ids) + +        instance_mapping = {user.id: user for user in filtered_instances} + +        updated = [] +        fields_to_update = set() +        for user_data in validated_data: +            for key in user_data: +                fields_to_update.add(key) +                try: +                    user = instance_mapping[user_data["id"]] +                except KeyError: +                    raise NotFound({"detail": f"User with id {user_data['id']} not found."}) -class UserSerializer(BulkSerializerMixin, ModelSerializer): +                user.__dict__.update(user_data) +            updated.append(user) + +        fields_to_update.remove("id") + +        if not fields_to_update: +            # Raise ValidationError when only id field is given. +            raise ValidationError( +                {api_settings.NON_FIELD_ERRORS_KEY: ["Insufficient data provided."]} +            ) + +        User.objects.bulk_update(updated, fields_to_update) +        return updated + + +class UserSerializer(ModelSerializer):      """A class providing (de-)serialization of `User` instances.""" +    # ID field must be explicitly set as the default id field is read-only. +    id = IntegerField(min_value=0) +      class Meta:          """Metadata defined for the Django REST Framework."""          model = User          fields = ('id', 'name', 'discriminator', 'roles', 'in_guild')          depth = 1 +        list_serializer_class = UserListSerializer + +    def create(self, validated_data: dict) -> User: +        """Override create method to catch IntegrityError.""" +        try: +            return super().create(validated_data) +        except IntegrityError: +            raise ValidationError({"id": ["User with ID already present."]})  class NominationSerializer(ModelSerializer): diff --git a/pydis_site/apps/api/tests/test_dblogger.py b/pydis_site/apps/api/tests/test_dblogger.py deleted file mode 100644 index bb19f297..00000000 --- a/pydis_site/apps/api/tests/test_dblogger.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging -from datetime import datetime - -from django.test import TestCase - -from ..dblogger import DatabaseLogHandler -from ..models import LogEntry - - -class DatabaseLogHandlerTests(TestCase): -    def test_logs_to_database(self): -        module_basename = __name__.split('.')[-1] -        logger = logging.getLogger(__name__) -        logger.handlers = [DatabaseLogHandler()] -        logger.warning("I am a test case!") - -        # Ensure we only have a single record in the database -        # after the logging call above. -        [entry] = LogEntry.objects.all() - -        self.assertEqual(entry.application, 'site') -        self.assertEqual(entry.logger_name, __name__) -        self.assertIsInstance(entry.timestamp, datetime) -        self.assertEqual(entry.level, 'warning') -        self.assertEqual(entry.module, module_basename) -        self.assertIsInstance(entry.line, int) -        self.assertEqual(entry.message, "I am a test case!") 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_models.py b/pydis_site/apps/api/tests/test_models.py index e0e347bb..853e6621 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -14,7 +14,6 @@ from pydis_site.apps.api.models import (      OffensiveMessage,      Reminder,      Role, -    Tag,      User  )  from pydis_site.apps.api.models.mixins import ModelReprMixin @@ -104,10 +103,6 @@ class StringDunderMethodTests(SimpleTestCase):                  ),                  creation=dt.utcnow()              ), -            Tag( -                title='bob', -                embed={'content': "the builder"} -            ),              User(                  id=5,                  name='bob', 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_off_topic_channel_names.py b/pydis_site/apps/api/tests/test_off_topic_channel_names.py index bd42cd81..3ab8b22d 100644 --- a/pydis_site/apps/api/tests/test_off_topic_channel_names.py +++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py @@ -10,12 +10,14 @@ class UnauthenticatedTests(APISubdomainTestCase):          self.client.force_authenticate(user=None)      def test_cannot_read_off_topic_channel_name_list(self): +        """Return a 401 response when not authenticated."""          url = reverse('bot:offtopicchannelname-list', host='api')          response = self.client.get(url)          self.assertEqual(response.status_code, 401)      def test_cannot_read_off_topic_channel_name_list_with_random_item_param(self): +        """Return a 401 response when `random_items` provided and not authenticated."""          url = reverse('bot:offtopicchannelname-list', host='api')          response = self.client.get(f'{url}?random_items=no') @@ -24,6 +26,7 @@ class UnauthenticatedTests(APISubdomainTestCase):  class EmptyDatabaseTests(APISubdomainTestCase):      def test_returns_empty_object(self): +        """Return empty list when no names in database."""          url = reverse('bot:offtopicchannelname-list', host='api')          response = self.client.get(url) @@ -31,6 +34,7 @@ class EmptyDatabaseTests(APISubdomainTestCase):          self.assertEqual(response.json(), [])      def test_returns_empty_list_with_get_all_param(self): +        """Return empty list when no names and `random_items` param provided."""          url = reverse('bot:offtopicchannelname-list', host='api')          response = self.client.get(f'{url}?random_items=5') @@ -38,6 +42,7 @@ class EmptyDatabaseTests(APISubdomainTestCase):          self.assertEqual(response.json(), [])      def test_returns_400_for_bad_random_items_param(self): +        """Return error message when passing not integer as `random_items`."""          url = reverse('bot:offtopicchannelname-list', host='api')          response = self.client.get(f'{url}?random_items=totally-a-valid-integer') @@ -47,6 +52,7 @@ class EmptyDatabaseTests(APISubdomainTestCase):          })      def test_returns_400_for_negative_random_items_param(self): +        """Return error message when passing negative int as `random_items`."""          url = reverse('bot:offtopicchannelname-list', host='api')          response = self.client.get(f'{url}?random_items=-5') @@ -59,10 +65,11 @@ class EmptyDatabaseTests(APISubdomainTestCase):  class ListTests(APISubdomainTestCase):      @classmethod      def setUpTestData(cls): -        cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand') -        cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk') +        cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand', used=False) +        cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk', used=True)      def test_returns_name_in_list(self): +        """Return all off-topic channel names."""          url = reverse('bot:offtopicchannelname-list', host='api')          response = self.client.get(url) @@ -76,11 +83,21 @@ class ListTests(APISubdomainTestCase):          )      def test_returns_single_item_with_random_items_param_set_to_1(self): +        """Return not-used name instead used."""          url = reverse('bot:offtopicchannelname-list', host='api')          response = self.client.get(f'{url}?random_items=1')          self.assertEqual(response.status_code, 200)          self.assertEqual(len(response.json()), 1) +        self.assertEqual(response.json(), [self.test_name.name]) + +    def test_running_out_of_names_with_random_parameter(self): +        """Reset names `used` parameter to `False` when running out of names.""" +        url = reverse('bot:offtopicchannelname-list', host='api') +        response = self.client.get(f'{url}?random_items=2') + +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json(), [self.test_name.name, self.test_name_2.name])  class CreationTests(APISubdomainTestCase): @@ -93,6 +110,7 @@ class CreationTests(APISubdomainTestCase):          self.assertEqual(response.status_code, 201)      def test_returns_201_for_unicode_chars(self): +        """Accept all valid characters."""          url = reverse('bot:offtopicchannelname-list', host='api')          names = (              '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹', @@ -104,6 +122,7 @@ class CreationTests(APISubdomainTestCase):              self.assertEqual(response.status_code, 201)      def test_returns_400_for_missing_name_param(self): +        """Return error message when name not provided."""          url = reverse('bot:offtopicchannelname-list', host='api')          response = self.client.post(url)          self.assertEqual(response.status_code, 400) @@ -112,6 +131,7 @@ class CreationTests(APISubdomainTestCase):          })      def test_returns_400_for_bad_name_param(self): +        """Return error message when invalid characters provided."""          url = reverse('bot:offtopicchannelname-list', host='api')          invalid_names = (              'space between words', @@ -134,18 +154,21 @@ class DeletionTests(APISubdomainTestCase):          cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk')      def test_deleting_unknown_name_returns_404(self): +        """Return 404 reponse when trying to delete unknown name."""          url = reverse('bot:offtopicchannelname-detail', args=('unknown-name',), host='api')          response = self.client.delete(url)          self.assertEqual(response.status_code, 404)      def test_deleting_known_name_returns_204(self): +        """Return 204 response when deleting was successful."""          url = reverse('bot:offtopicchannelname-detail', args=(self.test_name.name,), host='api')          response = self.client.delete(url)          self.assertEqual(response.status_code, 204)      def test_name_gets_deleted(self): +        """Name gets actually deleted."""          url = reverse('bot:offtopicchannelname-detail', args=(self.test_name_2.name,), host='api')          response = self.client.delete(url) diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py index 4c0f6e27..825e4edb 100644 --- a/pydis_site/apps/api/tests/test_users.py +++ b/pydis_site/apps/api/tests/test_users.py @@ -45,6 +45,13 @@ class CreationTests(APISubdomainTestCase):              position=1          ) +        cls.user = User.objects.create( +            id=11, +            name="Name doesn't matter.", +            discriminator=1122, +            in_guild=True +        ) +      def test_accepts_valid_data(self):          url = reverse('bot:user-list', host='api')          data = { @@ -89,7 +96,7 @@ class CreationTests(APISubdomainTestCase):          response = self.client.post(url, data=data)          self.assertEqual(response.status_code, 201) -        self.assertEqual(response.json(), data) +        self.assertEqual(response.json(), [])      def test_returns_400_for_unknown_role_id(self):          url = reverse('bot:user-list', host='api') @@ -115,6 +122,176 @@ class CreationTests(APISubdomainTestCase):          response = self.client.post(url, data=data)          self.assertEqual(response.status_code, 400) +    def test_returns_400_for_user_recreation(self): +        """Return 201 if User is already present in database as it skips User creation.""" +        url = reverse('bot:user-list', host='api') +        data = [{ +            'id': 11, +            'name': 'You saw nothing.', +            'discriminator': 112, +            'in_guild': True +        }] +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 201) + +    def test_returns_400_for_duplicate_request_users(self): +        """Return 400 if 2 Users with same ID is passed in the request data.""" +        url = reverse('bot:user-list', host='api') +        data = [ +            { +                'id': 11, +                'name': 'You saw nothing.', +                'discriminator': 112, +                'in_guild': True +            }, +            { +                'id': 11, +                'name': 'You saw nothing part 2.', +                'discriminator': 1122, +                'in_guild': False +            } +        ] +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) + +    def test_returns_400_for_existing_user(self): +        """Returns 400 if user is already present in DB.""" +        url = reverse('bot:user-list', host='api') +        data = { +            'id': 11, +            'name': 'You saw nothing part 3.', +            'discriminator': 1122, +            'in_guild': True +        } +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) + + +class MultiPatchTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls): +        cls.role_developer = Role.objects.create( +            id=159, +            name="Developer", +            colour=2, +            permissions=0b01010010101, +            position=10, +        ) +        cls.user_1 = User.objects.create( +            id=1, +            name="Patch test user 1.", +            discriminator=1111, +            in_guild=True +        ) +        cls.user_2 = User.objects.create( +            id=2, +            name="Patch test user 2.", +            discriminator=2222, +            in_guild=True +        ) + +    def test_multiple_users_patch(self): +        url = reverse("bot:user-bulk-patch", host="api") +        data = [ +            { +                "id": 1, +                "name": "User 1 patched!", +                "discriminator": 1010, +                "roles": [self.role_developer.id], +                "in_guild": False +            }, +            { +                "id": 2, +                "name": "User 2 patched!" +            } +        ] + +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 200) +        self.assertEqual(response.json()[0], data[0]) + +        user_2 = User.objects.get(id=2) +        self.assertEqual(user_2.name, data[1]["name"]) + +    def test_returns_400_for_missing_user_id(self): +        url = reverse("bot:user-bulk-patch", host="api") +        data = [ +            { +                "name": "I am ghost user!", +                "discriminator": 1010, +                "roles": [self.role_developer.id], +                "in_guild": False +            }, +            { +                "name": "patch me? whats my id?" +            } +        ] +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 400) + +    def test_returns_404_for_not_found_user(self): +        url = reverse("bot:user-bulk-patch", host="api") +        data = [ +            { +                "id": 1, +                "name": "User 1 patched again!!!", +                "discriminator": 1010, +                "roles": [self.role_developer.id], +                "in_guild": False +            }, +            { +                "id": 22503405, +                "name": "User unknown not patched!" +            } +        ] +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 404) + +    def test_returns_400_for_bad_data(self): +        url = reverse("bot:user-bulk-patch", host="api") +        data = [ +            { +                "id": 1, +                "in_guild": "Catch me!" +            }, +            { +                "id": 2, +                "discriminator": "find me!" +            } +        ] + +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 400) + +    def test_returns_400_for_insufficient_data(self): +        url = reverse("bot:user-bulk-patch", host="api") +        data = [ +            { +                "id": 1, +            }, +            { +                "id": 2, +            } +        ] +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 400) + +    def test_returns_400_for_duplicate_request_users(self): +        """Return 400 if 2 Users with same ID is passed in the request data.""" +        url = reverse("bot:user-bulk-patch", host="api") +        data = [ +            { +                'id': 1, +                'name': 'You saw nothing.', +            }, +            { +                'id': 1, +                'name': 'You saw nothing part 2.', +            } +        ] +        response = self.client.patch(url, data=data) +        self.assertEqual(response.status_code, 400) +  class UserModelTests(APISubdomainTestCase):      @classmethod @@ -143,7 +320,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 +343,49 @@ 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") + + +class UserPaginatorTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls): +        users = [] +        for i in range(1, 10_001): +            users.append(User( +                id=i, +                name=f"user{i}", +                discriminator=1111, +                in_guild=True +            )) +        cls.users = User.objects.bulk_create(users) + +    def test_returns_single_page_response(self): +        url = reverse("bot:user-list", host="api") +        response = self.client.get(url).json() +        self.assertIsNone(response["next_page_no"]) +        self.assertIsNone(response["previous_page_no"]) + +    def test_returns_next_page_number(self): +        User.objects.create( +            id=10_001, +            name="user10001", +            discriminator=1111, +            in_guild=True +        ) +        url = reverse("bot:user-list", host="api") +        response = self.client.get(url).json() +        self.assertEqual(2, response["next_page_no"]) + +    def test_returns_previous_page_number(self): +        User.objects.create( +            id=10_001, +            name="user10001", +            discriminator=1111, +            in_guild=True +        ) +        url = reverse("bot:user-list", host="api") +        response = self.client.get(url, {"page": 2}).json() +        self.assertEqual(1, response["previous_page_no"]) diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py index 241af08c..8bb7b917 100644 --- a/pydis_site/apps/api/tests/test_validators.py +++ b/pydis_site/apps/api/tests/test_validators.py @@ -5,7 +5,7 @@ from django.test import TestCase  from ..models.bot.bot_setting import validate_bot_setting_name  from ..models.bot.offensive_message import future_date_validator -from ..models.bot.tag import validate_tag_embed +from ..models.utils import validate_embed  REQUIRED_KEYS = ( @@ -25,77 +25,77 @@ class BotSettingValidatorTests(TestCase):  class TagEmbedValidatorTests(TestCase):      def test_rejects_non_mapping(self):          with self.assertRaises(ValidationError): -            validate_tag_embed('non-empty non-mapping') +            validate_embed('non-empty non-mapping')      def test_rejects_missing_required_keys(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'unknown': "key"              })      def test_rejects_one_correct_one_incorrect(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'provider': "??",                  'title': ""              })      def test_rejects_empty_required_key(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': ''              })      def test_rejects_list_as_embed(self):          with self.assertRaises(ValidationError): -            validate_tag_embed([]) +            validate_embed([])      def test_rejects_required_keys_and_unknown_keys(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': "the duck walked up to the lemonade stand",                  'and': "he said to the man running the stand"              })      def test_rejects_too_long_title(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': 'a' * 257              })      def test_rejects_too_many_fields(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'fields': [{} for _ in range(26)]              })      def test_rejects_too_long_description(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'description': 'd' * 2049              })      def test_allows_valid_embed(self): -        validate_tag_embed({ +        validate_embed({              'title': "My embed",              'description': "look at my embed, my embed is amazing"          })      def test_allows_unvalidated_fields(self): -        validate_tag_embed({ +        validate_embed({              'title': "My embed",              'provider': "what am I??"          })      def test_rejects_fields_as_list_of_non_mappings(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'fields': ['abc']              })      def test_rejects_fields_with_unknown_fields(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'fields': [                      {                          'what': "is this field" @@ -105,7 +105,7 @@ class TagEmbedValidatorTests(TestCase):      def test_rejects_fields_with_too_long_name(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'fields': [                      {                          'name': "a" * 257 @@ -115,7 +115,7 @@ class TagEmbedValidatorTests(TestCase):      def test_rejects_one_correct_one_incorrect_field(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'fields': [                      {                          'name': "Totally valid", @@ -131,7 +131,7 @@ class TagEmbedValidatorTests(TestCase):      def test_rejects_missing_required_field_field(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'fields': [                      {                          'name': "Totally valid", @@ -142,7 +142,7 @@ class TagEmbedValidatorTests(TestCase):      def test_rejects_invalid_inline_field_field(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'fields': [                      {                          'name': "Totally valid", @@ -153,7 +153,7 @@ class TagEmbedValidatorTests(TestCase):              })      def test_allows_valid_fields(self): -        validate_tag_embed({ +        validate_embed({              'fields': [                  {                      'name': "valid", @@ -174,14 +174,14 @@ class TagEmbedValidatorTests(TestCase):      def test_rejects_footer_as_non_mapping(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': "whatever",                  'footer': []              })      def test_rejects_footer_with_unknown_fields(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': "whatever",                  'footer': {                      'duck': "quack" @@ -190,7 +190,7 @@ class TagEmbedValidatorTests(TestCase):      def test_rejects_footer_with_empty_text(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': "whatever",                  'footer': {                      'text': "" @@ -198,7 +198,7 @@ class TagEmbedValidatorTests(TestCase):              })      def test_allows_footer_with_proper_values(self): -        validate_tag_embed({ +        validate_embed({              'title': "whatever",              'footer': {                  'text': "django good" @@ -207,14 +207,14 @@ class TagEmbedValidatorTests(TestCase):      def test_rejects_author_as_non_mapping(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': "whatever",                  'author': []              })      def test_rejects_author_with_unknown_field(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': "whatever",                  'author': {                      'field': "that is unknown" @@ -223,7 +223,7 @@ class TagEmbedValidatorTests(TestCase):      def test_rejects_author_with_empty_name(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': "whatever",                  'author': {                      'name': "" @@ -232,7 +232,7 @@ class TagEmbedValidatorTests(TestCase):      def test_rejects_author_with_one_correct_one_incorrect(self):          with self.assertRaises(ValidationError): -            validate_tag_embed({ +            validate_embed({                  'title': "whatever",                  'author': {                      # Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour @@ -242,7 +242,7 @@ class TagEmbedValidatorTests(TestCase):              })      def test_allows_author_with_proper_values(self): -        validate_tag_embed({ +        validate_embed({              'title': "whatever",              'author': {                  'name': "Bob" diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index a4fd5b2e..2e1ef0b4 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -8,13 +8,11 @@ from .viewsets import (      DocumentationLinkViewSet,      FilterListViewSet,      InfractionViewSet, -    LogEntryViewSet,      NominationViewSet,      OffTopicChannelNameViewSet,      OffensiveMessageViewSet,      ReminderViewSet,      RoleViewSet, -    TagViewSet,      UserViewSet  ) @@ -62,10 +60,6 @@ bot_router.register(      RoleViewSet  )  bot_router.register( -    'tags', -    TagViewSet -) -bot_router.register(      'users',      UserViewSet  ) @@ -76,7 +70,6 @@ urlpatterns = (      #      # from django_hosts.resolvers import reverse      path('bot/', include((bot_router.urls, 'api'), namespace='bot')), -    path('logs', LogEntryViewSet.as_view({'post': 'create'}), name='logs'),      path('healthcheck', HealthcheckView.as_view(), name='healthcheck'),      path('rules', RulesView.as_view(), name='rules')  ) diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 7ac56641..0d126051 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -135,8 +135,9 @@ class RulesView(APIView):              ),              (                  "Do not provide or request help on projects that may break laws, " -                "breach terms of services, be considered malicious/inappropriate " -                "or be for graded coursework/exams." +                "breach terms of services, be considered malicious or inappropriate. " +                "Do not help with ongoing exams. Do not provide or request solutions " +                "for graded assignments, although general guidance is okay."              ),              (                  "No spamming or unapproved advertising, including requests for paid work. " diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index 8699517e..f133e77f 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -10,7 +10,5 @@ from .bot import (      OffTopicChannelNameViewSet,      ReminderViewSet,      RoleViewSet, -    TagViewSet,      UserViewSet  ) -from .log_entry import LogEntryViewSet diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index e64e3988..84b87eab 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -9,5 +9,4 @@ from .off_topic_channel_name import OffTopicChannelNameViewSet  from .offensive_message import OffensiveMessageViewSet  from .reminder import ReminderViewSet  from .role import RoleViewSet -from .tag import TagViewSet  from .user import UserViewSet diff --git a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py index d6da2399..826ad25e 100644 --- a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py +++ b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py @@ -1,3 +1,4 @@ +from django.db.models import Case, Value, When  from django.db.models.query import QuerySet  from django.http.request import HttpRequest  from django.shortcuts import get_object_or_404 @@ -20,7 +21,9 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet):      Return all known off-topic channel names from the database.      If the `random_items` query parameter is given, for example using...          $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?random_items=5 -    ... then the API will return `5` random items from the database. +    ... then the API will return `5` random items from the database +    that is not used in current rotation. +    When running out of names, API will mark all names to not used and start new rotation.      #### Response format      Return a list of off-topic-channel names: @@ -106,7 +109,27 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet):                      'random_items': ["Must be a positive integer."]                  }) -            queryset = self.get_queryset().order_by('?')[:random_count] +            queryset = self.get_queryset().order_by('used', '?')[:random_count] + +            # When any name is used in our listing then this means we reached end of round +            # and we need to reset all other names `used` to False +            if any(offtopic_name.used for offtopic_name in queryset): +                # These names that we just got have to be excluded from updating used to False +                self.get_queryset().update( +                    used=Case( +                        When( +                            name__in=(offtopic_name.name for offtopic_name in queryset), +                            then=Value(True) +                        ), +                        default=Value(False) +                    ) +                ) +            else: +                # Otherwise mark selected names `used` to True +                self.get_queryset().filter( +                    name__in=(offtopic_name.name for offtopic_name in queryset) +                ).update(used=True) +              serialized = self.serializer_class(queryset, many=True)              return Response(serialized.data) diff --git a/pydis_site/apps/api/viewsets/bot/tag.py b/pydis_site/apps/api/viewsets/bot/tag.py deleted file mode 100644 index 7e9ba117..00000000 --- a/pydis_site/apps/api/viewsets/bot/tag.py +++ /dev/null @@ -1,105 +0,0 @@ -from rest_framework.viewsets import ModelViewSet - -from pydis_site.apps.api.models.bot.tag import Tag -from pydis_site.apps.api.serializers import TagSerializer - - -class TagViewSet(ModelViewSet): -    """ -    View providing CRUD operations on tags shown by our bot. - -    ## Routes -    ### GET /bot/tags -    Returns all tags in the database. - -    #### Response format -    >>> [ -    ...     { -    ...         'title': "resources", -    ...         'embed': { -    ...             'content': "Did you really think I'd put something useful here?" -    ...         } -    ...     } -    ... ] - -    #### Status codes -    - 200: returned on success - -    ### GET /bot/tags/<title:str> -    Gets a single tag by its title. - -    #### Response format -    >>> { -    ...     'title': "My awesome tag", -    ...     'embed': { -    ...         'content': "totally not filler words" -    ...     } -    ... } - -    #### Status codes -    - 200: returned on success -    - 404: if a tag with the given `title` could not be found - -    ### POST /bot/tags -    Adds a single tag to the database. - -    #### Request body -    >>> { -    ...     'title': str, -    ...     'embed': dict -    ... } - -    The embed structure is the same as the embed structure that the Discord API -    expects. You can view the documentation for it here: -        https://discordapp.com/developers/docs/resources/channel#embed-object - -    #### Status codes -    - 201: returned on success -    - 400: if one of the given fields is invalid - -    ### PUT /bot/tags/<title:str> -    Update the tag with the given `title`. - -    #### Request body -    >>> { -    ...     'title': str, -    ...     'embed': dict -    ... } - -    The embed structure is the same as the embed structure that the Discord API -    expects. You can view the documentation for it here: -        https://discordapp.com/developers/docs/resources/channel#embed-object - -    #### Status codes -    - 200: returned on success -    - 400: if the request body was invalid, see response body for details -    - 404: if the tag with the given `title` could not be found - -    ### PATCH /bot/tags/<title:str> -    Update the tag with the given `title`. - -    #### Request body -    >>> { -    ...     'title': str, -    ...     'embed': dict -    ... } - -    The embed structure is the same as the embed structure that the Discord API -    expects. You can view the documentation for it here: -        https://discordapp.com/developers/docs/resources/channel#embed-object - -    #### Status codes -    - 200: returned on success -    - 400: if the request body was invalid, see response body for details -    - 404: if the tag with the given `title` could not be found - -    ### DELETE /bot/tags/<title:str> -    Deletes the tag with the given `title`. - -    #### Status codes -    - 204: returned on success -    - 404: if a tag with the given `title` does not exist -    """ - -    serializer_class = TagSerializer -    queryset = Tag.objects.all() diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py index 9571b3d7..3e4b627e 100644 --- a/pydis_site/apps/api/viewsets/bot/user.py +++ b/pydis_site/apps/api/viewsets/bot/user.py @@ -1,21 +1,64 @@ +import typing +from collections import OrderedDict + +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.pagination import PageNumberPagination +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer  from rest_framework.viewsets import ModelViewSet -from rest_framework_bulk import BulkCreateModelMixin  from pydis_site.apps.api.models.bot.user import User  from pydis_site.apps.api.serializers import UserSerializer -class UserViewSet(BulkCreateModelMixin, ModelViewSet): +class UserListPagination(PageNumberPagination): +    """Custom pagination class for the User Model.""" + +    page_size = 10000 +    page_size_query_param = "page_size" + +    def get_next_page_number(self) -> typing.Optional[int]: +        """Get the next page number.""" +        if not self.page.has_next(): +            return None +        page_number = self.page.next_page_number() +        return page_number + +    def get_previous_page_number(self) -> typing.Optional[int]: +        """Get the previous page number.""" +        if not self.page.has_previous(): +            return None + +        page_number = self.page.previous_page_number() +        return page_number + +    def get_paginated_response(self, data: list) -> Response: +        """Override method to send modified response.""" +        return Response(OrderedDict([ +            ('count', self.page.paginator.count), +            ('next_page_no', self.get_next_page_number()), +            ('previous_page_no', self.get_previous_page_number()), +            ('results', data) +        ])) + + +class UserViewSet(ModelViewSet):      """      View providing CRUD operations on Discord users through the bot.      ## Routes      ### GET /bot/users -    Returns all users currently known. +    Returns all users currently known with pagination.      #### Response format -    >>> [ -    ...     { +    >>> { +    ...     'count': 95000, +    ...     'next_page_no': "2", +    ...     'previous_page_no': None, +    ...     'results': [ +    ...      {      ...         'id': 409107086526644234,      ...         'name': "Python",      ...         'discriminator': 4329, @@ -26,8 +69,13 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):      ...             458226699344019457      ...         ],      ...         'in_guild': True -    ...     } -    ... ] +    ...     }, +    ...     ] +    ... } + +    #### Optional Query Parameters +    - page_size: number of Users in one page, defaults to 10,000 +    - page: page number      #### Status codes      - 200: returned on success @@ -56,6 +104,7 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):      ### POST /bot/users      Adds a single or multiple new users.      The roles attached to the user(s) must be roles known by the site. +    Users that already exist in the database will be skipped.      #### Request body      >>> { @@ -67,11 +116,13 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):      ... }      Alternatively, request users can be POSTed as a list of above objects, -    in which case multiple users will be created at once. +    in which case multiple users will be created at once. In this case, +    the response is an empty list.      #### Status codes      - 201: returned on success      - 400: if one of the given roles does not exist, or one of the given fields is invalid +    - 400: if multiple user objects with the same id are given      ### PUT /bot/users/<snowflake:int>      Update the user with the given `snowflake`. @@ -109,6 +160,34 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):      - 400: if the request body was invalid, see response body for details      - 404: if the user with the given `snowflake` could not be found +    ### BULK PATCH /bot/users/bulk_patch +    Update users with the given `ids` and `details`. +    `id` field and at least one other field is mandatory. + +    #### Request body +    >>> [ +    ...     { +    ...         'id': int, +    ...         'name': str, +    ...         'discriminator': int, +    ...         'roles': List[int], +    ...         'in_guild': bool +    ...     }, +    ...     { +    ...         'id': int, +    ...         'name': str, +    ...         'discriminator': int, +    ...         'roles': List[int], +    ...         'in_guild': bool +    ...     }, +    ... ] + +    #### Status codes +    - 200: returned on success +    - 400: if the request body was invalid, see response body for details +    - 400: if multiple user objects with the same id are given +    - 404: if the user with the given id does not exist +      ### DELETE /bot/users/<snowflake:int>      Deletes the user with the given `snowflake`. @@ -118,4 +197,27 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet):      """      serializer_class = UserSerializer -    queryset = User.objects +    queryset = User.objects.all().order_by("id") +    pagination_class = UserListPagination + +    def get_serializer(self, *args, **kwargs) -> ModelSerializer: +        """Set Serializer many attribute to True if request body contains a list.""" +        if isinstance(kwargs.get('data', {}), list): +            kwargs['many'] = True + +        return super().get_serializer(*args, **kwargs) + +    @action(detail=False, methods=["PATCH"], name='user-bulk-patch') +    def bulk_patch(self, request: Request) -> Response: +        """Update multiple User objects in a single request.""" +        serializer = self.get_serializer( +            instance=self.get_queryset(), +            data=request.data, +            many=True, +            partial=True +        ) + +        serializer.is_valid(raise_exception=True) +        serializer.save() + +        return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/pydis_site/apps/api/viewsets/log_entry.py b/pydis_site/apps/api/viewsets/log_entry.py deleted file mode 100644 index 9108a4fa..00000000 --- a/pydis_site/apps/api/viewsets/log_entry.py +++ /dev/null @@ -1,36 +0,0 @@ -from rest_framework.mixins import CreateModelMixin -from rest_framework.viewsets import GenericViewSet - -from pydis_site.apps.api.models.log_entry import LogEntry -from pydis_site.apps.api.serializers import LogEntrySerializer - - -class LogEntryViewSet(CreateModelMixin, GenericViewSet): -    """ -    View supporting the creation of log entries in the database for viewing via the log browser. - -    ## Routes -    ### POST /logs -    Create a new log entry. - -    #### Request body -    >>> { -    ...     'application': str,  # 'bot' | 'seasonalbot' | 'site' -    ...     'logger_name': str,  # such as 'bot.cogs.moderation' -    ...     'timestamp': Optional[str],  # from `datetime.utcnow().isoformat()` -    ...     'level': str,  # 'debug' | 'info' | 'warning' | 'error' | 'critical' -    ...     'module': str,  # such as 'pydis_site.apps.api.serializers' -    ...     'line': int,  # > 0 -    ...     'message': str,  # textual formatted content of the logline -    ... } - -    #### Status codes -    - 201: returned on success -    - 400: if the request body has invalid fields, see the response for details - -    ## Authentication -    Requires a API token. -    """ - -    queryset = LogEntry.objects.all() -    serializer_class = LogEntrySerializer diff --git a/pydis_site/apps/home/resources/books/effective_python.yaml b/pydis_site/apps/home/resources/books/effective_python.yaml index ab782704..7f9d0dea 100644 --- a/pydis_site/apps/home/resources/books/effective_python.yaml +++ b/pydis_site/apps/home/resources/books/effective_python.yaml @@ -1,4 +1,4 @@ -description: A book that gives 59 best practices for writing excellent Python. Great +description: A book that gives 90 best practices for writing excellent Python. Great    for intermediates.  name: Effective Python  payment: paid @@ -8,7 +8,7 @@ urls:    url: https://effectivepython.com/  - icon: branding/amazon    title: Amazon -  url: https://www.amazon.com/Effective-Python-Specific-Software-Development/dp/0134034287 +  url: https://www.amazon.com/Effective-Python-Specific-Software-Development/dp/0134853989  - icon: branding/github    title: GitHub    url: https://github.com/bslatkin/effectivepython diff --git a/pydis_site/apps/home/tests/mock_github_api_response.json b/pydis_site/apps/home/tests/mock_github_api_response.json index 35604a85..10be4f99 100644 --- a/pydis_site/apps/home/tests/mock_github_api_response.json +++ b/pydis_site/apps/home/tests/mock_github_api_response.json @@ -28,7 +28,7 @@      "forks_count": 31    },    { -    "full_name": "python-discord/flake8-annotations", +    "full_name": "python-discord/metricity",      "description": "test",      "stargazers_count": 97,      "language": "Python", diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py index 7ad995cc..09969f1d 100644 --- a/pydis_site/apps/home/views/home.py +++ b/pydis_site/apps/home/views/home.py @@ -23,7 +23,7 @@ class HomeView(View):          "python-discord/bot",          "python-discord/snekbox",          "python-discord/seasonalbot", -        "python-discord/flake8-annotations", +        "python-discord/metricity",          "python-discord/django-simple-bulma",      ] 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/constants.py b/pydis_site/constants.py new file mode 100644 index 00000000..0b76694a --- /dev/null +++ b/pydis_site/constants.py @@ -0,0 +1,5 @@ +import git + +# Git SHA +repo = git.Repo(search_parent_directories=True) +GIT_SHA = repo.head.object.hexsha diff --git a/pydis_site/context_processors.py b/pydis_site/context_processors.py new file mode 100644 index 00000000..6937a3db --- /dev/null +++ b/pydis_site/context_processors.py @@ -0,0 +1,8 @@ +from django.template import RequestContext + +from pydis_site.constants import GIT_SHA + + +def git_sha_processor(_: RequestContext) -> dict: +    """Expose the git SHA for this repo to all views.""" +    return {'git_sha': GIT_SHA} diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 2c87007c..5eb812ac 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -20,6 +20,7 @@ import sentry_sdk  from django.contrib.messages import constants as messages  from sentry_sdk.integrations.django import DjangoIntegration +from pydis_site.constants import GIT_SHA  if typing.TYPE_CHECKING:      from django.contrib.auth.models import User @@ -33,7 +34,8 @@ env = environ.Env(  sentry_sdk.init(      dsn=env('SITE_SENTRY_DSN'),      integrations=[DjangoIntegration()], -    send_default_pii=True +    send_default_pii=True, +    release=f"pydis-site@{GIT_SHA}"  )  # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -157,8 +159,8 @@ TEMPLATES = [                  'django.template.context_processors.static',                  'django.contrib.auth.context_processors.auth',                  'django.contrib.messages.context_processors.messages', -                  "sekizai.context_processors.sekizai", +                "pydis_site.context_processors.git_sha_processor"              ],          },      }, @@ -258,14 +260,11 @@ LOGGING = {      'handlers': {          'console': {              'class': 'logging.StreamHandler' -        }, -        'database': { -            'class': 'pydis_site.apps.api.dblogger.DatabaseLogHandler'          }      },      'loggers': {          'django': { -            'handlers': ['console', 'database'], +            'handlers': ['console'],              'propagate': True,              'level': env(                  'LOG_LEVEL', @@ -399,3 +398,11 @@ ACCOUNT_USERNAME_VALIDATORS = "pydis_site.VALIDATORS"  LOGIN_REDIRECT_URL = "home"  SOCIALACCOUNT_ADAPTER = "pydis_site.utils.account.SocialAccountAdapter" +SOCIALACCOUNT_PROVIDERS = { +    "discord": { +        "SCOPE": [ +            "identify", +        ], +        "AUTH_PARAMS": {"prompt": "none"} +    } +} diff --git a/pydis_site/templates/base/base.html b/pydis_site/templates/base/base.html index 4c70d778..70426dc1 100644 --- a/pydis_site/templates/base/base.html +++ b/pydis_site/templates/base/base.html @@ -37,6 +37,7 @@    {% render_block "css" %}  </head>  <body class="site"> +  <!-- Git hash for this release: {{ git_sha }} -->  <main class="site-content">    {% if messages %} diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html index 3e96cc91..f31363a4 100644 --- a/pydis_site/templates/home/index.html +++ b/pydis_site/templates/home/index.html @@ -39,9 +39,7 @@          {# Right column container #}          <div class="column is-half-desktop"> -          <a href="https://pythondiscord.com/pages/code-jams/code-jam-7/"> -            <img src="{% static "images/events/summer_code_jam_2020.png" %}"> -          </a> +          <iframe width="560" height="315" src="https://www.youtube.com/embed/ZH26PuX3re0" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>          </div>        </div>  |