diff options
Diffstat (limited to 'pydis_site/apps/api/serializers.py')
-rw-r--r-- | pydis_site/apps/api/serializers.py | 382 |
1 files changed, 361 insertions, 21 deletions
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 4a702d61..87fd6190 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -1,4 +1,8 @@ """Converters from Django models to data interchange formats and back.""" +from datetime import timedelta +from typing import Any + +from django.db import models from django.db.models.query import QuerySet from django.db.utils import IntegrityError from rest_framework.exceptions import NotFound @@ -13,9 +17,13 @@ from rest_framework.settings import api_settings from rest_framework.validators import UniqueTogetherValidator from .models import ( + AocAccountLink, + AocCompletionistBlock, BotSetting, + BumpedThread, DeletedMessage, DocumentationLink, + Filter, FilterList, Infraction, MessageDeletionContext, @@ -28,6 +36,30 @@ from .models import ( User ) +class FrozenFieldsMixin: + """ + Serializer mixin that allows adding non-updateable fields to a serializer. + + To use, inherit from the mixin and specify the fields that should only be + written to on creation in the `frozen_fields` attribute of the `Meta` class + in a serializer. + + See also the DRF discussion for this feature at + https://github.com/encode/django-rest-framework/discussions/8606, which may + eventually provide an official way to implement this. + """ + + def update(self, instance: models.Model, validated_data: dict) -> models.Model: + """Validate that no frozen fields were changed and update the instance.""" + for field_name in getattr(self.Meta, 'frozen_fields', ()): + if field_name in validated_data: + raise ValidationError( + { + field_name: ["This field cannot be updated."] + } + ) + return super().update(instance, validated_data) + class BotSettingSerializer(ModelSerializer): """A class providing (de-)serialization of `BotSetting` instances.""" @@ -39,6 +71,32 @@ class BotSettingSerializer(ModelSerializer): fields = ('name', 'data') +class ListBumpedThreadSerializer(ListSerializer): + """Custom ListSerializer to override to_representation() when list views are triggered.""" + + def to_representation(self, objects: list[BumpedThread]) -> int: + """ + Used by the `ListModelMixin` to return just the list of bumped thread ids. + + Only the thread_id field is useful, hence it is unnecessary to create a nested dictionary. + + Additionally, this allows bumped thread routes to simply return an + array of thread_id ints instead of objects, saving on bandwidth. + """ + return [obj.thread_id for obj in objects] + + +class BumpedThreadSerializer(ModelSerializer): + """A class providing (de-)serialization of `BumpedThread` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + list_serializer_class = ListBumpedThreadSerializer + model = BumpedThread + fields = ('thread_id',) + + class DeletedMessageSerializer(ModelSerializer): """ A class providing (de-)serialization of `DeletedMessage` instances. @@ -112,32 +170,288 @@ class DocumentationLinkSerializer(ModelSerializer): fields = ('package', 'base_url', 'inventory_url') +# region: filters serializers + +SETTINGS_FIELDS = ( + 'dm_content', + 'dm_embed', + 'infraction_type', + 'infraction_reason', + 'infraction_duration', + 'infraction_channel', + 'guild_pings', + 'filter_dm', + 'dm_pings', + 'remove_context', + 'send_alert', + 'bypass_roles', + 'enabled', + 'enabled_channels', + 'disabled_channels', + 'enabled_categories', + 'disabled_categories', +) + +ALLOW_BLANK_SETTINGS = ( + 'dm_content', + 'dm_embed', + 'infraction_reason', +) + +ALLOW_EMPTY_SETTINGS = ( + 'enabled_channels', + 'disabled_channels', + 'enabled_categories', + 'disabled_categories', + 'guild_pings', + 'dm_pings', + 'bypass_roles', +) + +# Required fields for custom JSON representation purposes +BASE_FILTER_FIELDS = ( + 'id', 'created_at', 'updated_at', 'content', 'description', 'additional_settings' +) +BASE_FILTERLIST_FIELDS = ('id', 'created_at', 'updated_at', 'name', 'list_type') +BASE_SETTINGS_FIELDS = ( + 'bypass_roles', + 'filter_dm', + 'enabled', + 'remove_context', + 'send_alert' +) +INFRACTION_AND_NOTIFICATION_FIELDS = ( + 'infraction_type', + 'infraction_reason', + 'infraction_duration', + 'infraction_channel', + 'dm_content', + 'dm_embed' +) +CHANNEL_SCOPE_FIELDS = ( + 'disabled_channels', + 'disabled_categories', + 'enabled_channels', + 'enabled_categories' +) +MENTIONS_FIELDS = ('guild_pings', 'dm_pings') + +MAX_TIMEOUT_DURATION = timedelta(days=28) + + +def _create_meta_extra_kwargs(*, for_filter: bool) -> dict[str, dict[str, bool]]: + """Create the extra kwargs for the Meta classes of the Filter and FilterList serializers.""" + extra_kwargs = {} + for field in SETTINGS_FIELDS: + field_args = {'required': False, 'allow_null': True} if for_filter else {} + if field in ALLOW_BLANK_SETTINGS: + field_args['allow_blank'] = True + if field in ALLOW_EMPTY_SETTINGS: + field_args['allow_empty'] = True + extra_kwargs[field] = field_args + return extra_kwargs + + +def get_field_value(data: dict, field_name: str) -> Any: + """Get the value directly from the key, or from the filter list if it's missing or is None.""" + if data.get(field_name) is not None: + return data[field_name] + return getattr(data['filter_list'], field_name) + + +class FilterSerializer(ModelSerializer): + """A class providing (de-)serialization of `Filter` instances.""" + + def validate(self, data: dict) -> dict: + """Perform infraction data + allowed and disallowed lists validation.""" + infraction_type = get_field_value(data, 'infraction_type') + infraction_duration = get_field_value(data, 'infraction_duration') + if ( + (get_field_value(data, 'infraction_reason') or infraction_duration) + and infraction_type == 'NONE' + ): + raise ValidationError( + "Infraction type is required with infraction duration or reason." + ) + + if ( + infraction_type == 'TIMEOUT' + and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION) + ): + raise ValidationError( + f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days." + ) + + common_channels = ( + set(get_field_value(data, 'disabled_channels')) + & set(get_field_value(data, 'enabled_channels')) + ) + if common_channels: + raise ValidationError( + "You can't have the same value in both enabled and disabled channels lists:" + f" {', '.join(repr(channel) for channel in common_channels)}." + ) + + common_categories = ( + set(get_field_value(data, 'disabled_categories')) + & set(get_field_value(data, 'enabled_categories')) + ) + if common_categories: + raise ValidationError( + "You can't have the same value in both enabled and disabled categories lists:" + f" {', '.join(repr(category) for category in common_categories)}." + ) + + return data + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = Filter + fields = ( + 'id', + 'created_at', + 'updated_at', + 'content', + 'description', + 'additional_settings', + 'filter_list' + ) + SETTINGS_FIELDS + extra_kwargs = _create_meta_extra_kwargs(for_filter=True) + + def create(self, validated_data: dict) -> User: + """Override the create method to catch violations of the custom uniqueness constraint.""" + try: + return super().create(validated_data) + except IntegrityError: + raise ValidationError( + "Check if a filter with this combination of content " + "and settings already exists in this filter list." + ) + + def to_representation(self, instance: Filter) -> dict: + """ + Provides a custom JSON representation to the Filter Serializers. + + This representation restructures how the Filter is represented. + It groups the Infraction, Channel and Mention related fields into their own separated group. + + Furthermore, it puts the fields that meant to represent Filter settings, + into a sub-field called `settings`. + """ + settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} + settings['infraction_and_notification'] = { + name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS + } + settings['channel_scope'] = { + name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS + } + settings['mentions'] = { + name: getattr(instance, name) for name in MENTIONS_FIELDS + } + + schema = {name: getattr(instance, name) for name in BASE_FILTER_FIELDS} + schema['filter_list'] = instance.filter_list.id + schema['settings'] = settings + return schema + + class FilterListSerializer(ModelSerializer): """A class providing (de-)serialization of `FilterList` instances.""" + filters = FilterSerializer(many=True, read_only=True) + + def validate(self, data: dict) -> dict: + """Perform infraction data + allow and disallowed lists validation.""" + infraction_duration = data.get('infraction_duration') + if ( + data.get('infraction_reason') or infraction_duration + ) and not data.get('infraction_type'): + raise ValidationError("Infraction type is required with infraction duration or reason") + + if ( + data.get('disabled_channels') is not None + and data.get('enabled_channels') is not None + ): + common_channels = set(data['disabled_channels']) & set(data['enabled_channels']) + if common_channels: + raise ValidationError( + "You can't have the same value in both enabled and disabled channels lists:" + f" {', '.join(repr(channel) for channel in common_channels)}." + ) + + if ( + data.get('infraction_type') == 'TIMEOUT' + and (not infraction_duration or infraction_duration > MAX_TIMEOUT_DURATION) + ): + raise ValidationError( + f"A timeout cannot be longer than {MAX_TIMEOUT_DURATION.days} days." + ) + + if ( + data.get('disabled_categories') is not None + and data.get('enabled_categories') is not None + ): + common_categories = set(data['disabled_categories']) & set(data['enabled_categories']) + if common_categories: + raise ValidationError( + "You can't have the same value in both enabled and disabled categories lists:" + f" {', '.join(repr(category) for category in common_categories)}." + ) + + return data + class Meta: """Metadata defined for the Django REST Framework.""" model = FilterList - fields = ('id', 'created_at', 'updated_at', 'type', 'allowed', 'content', 'comment') + fields = ( + 'id', 'created_at', 'updated_at', 'name', 'list_type', 'filters' + ) + SETTINGS_FIELDS + extra_kwargs = _create_meta_extra_kwargs(for_filter=False) - # This validator ensures only one filterlist with the - # same content can exist. This means that we cannot have both an allow - # and a deny for the same item, and we cannot have duplicates of the - # same item. + # Ensure there can only be one filter list with the same name and type. validators = [ UniqueTogetherValidator( queryset=FilterList.objects.all(), - fields=['content', 'type'], + fields=('name', 'list_type'), message=( - "A filterlist for this item already exists. " - "Please note that you cannot add the same item to both allow and deny." + "A filterlist with the same name and type already exists." ) ), ] + def to_representation(self, instance: FilterList) -> dict: + """ + Provides a custom JSON representation to the FilterList Serializers. + + This representation restructures how the Filter is represented. + It groups the Infraction, Channel, and Mention related fields + into their own separated groups. + + Furthermore, it puts the fields that are meant to represent FilterList settings, + into a sub-field called `settings`. + """ + schema = {name: getattr(instance, name) for name in BASE_FILTERLIST_FIELDS} + schema['filters'] = [ + FilterSerializer(many=False).to_representation(instance=item) + for item in Filter.objects.filter(filter_list=instance.id) + ] + + settings = {name: getattr(instance, name) for name in BASE_SETTINGS_FIELDS} + settings['infraction_and_notification'] = { + name: getattr(instance, name) for name in INFRACTION_AND_NOTIFICATION_FIELDS + } + settings['channel_scope'] = {name: getattr(instance, name) for name in CHANNEL_SCOPE_FIELDS} + settings['mentions'] = {name: getattr(instance, name) for name in MENTIONS_FIELDS} + + schema['settings'] = settings + return schema + +# endregion + -class InfractionSerializer(ModelSerializer): +class InfractionSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `Infraction` instances.""" class Meta: @@ -147,6 +461,7 @@ class InfractionSerializer(ModelSerializer): fields = ( 'id', 'inserted_at', + 'last_applied', 'expires_at', 'active', 'user', @@ -154,15 +469,10 @@ class InfractionSerializer(ModelSerializer): 'type', 'reason', 'hidden', - 'dm_sent' + 'dm_sent', + 'jump_url' ) - validators = [ - UniqueTogetherValidator( - queryset=Infraction.objects.filter(active=True), - fields=['user', 'type', 'active'], - message='This user already has an active infraction of this type.', - ) - ] + frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') def validate(self, attrs: dict) -> dict: """Validate data constraints for the given data and abort if it is invalid.""" @@ -180,7 +490,7 @@ class InfractionSerializer(ModelSerializer): if hidden and infr_type in ('superstar', 'warning', 'voice_ban', 'voice_mute'): raise ValidationError({'hidden': [f'{infr_type} infractions cannot be hidden.']}) - if not hidden and infr_type in ('note', ): + if not hidden and infr_type in ('note',): raise ValidationError({'hidden': [f'{infr_type} infractions must be hidden.']}) return attrs @@ -257,6 +567,26 @@ class ReminderSerializer(ModelSerializer): ) +class AocCompletionistBlockSerializer(ModelSerializer): + """A class providing (de-)serialization of `AocCompletionistBlock` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = AocCompletionistBlock + fields = ("user", "is_blocked", "reason") + + +class AocAccountLinkSerializer(ModelSerializer): + """A class providing (de-)serialization of `AocAccountLink` instances.""" + + class Meta: + """Metadata defined for the Django REST Framework.""" + + model = AocAccountLink + fields = ("user", "aoc_username") + + class RoleSerializer(ModelSerializer): """A class providing (de-)serialization of `Role` instances.""" @@ -379,7 +709,7 @@ class NominationEntrySerializer(ModelSerializer): fields = ('nomination', 'actor', 'reason', 'inserted_at') -class NominationSerializer(ModelSerializer): +class NominationSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `Nomination` instances.""" entries = NominationEntrySerializer(many=True, read_only=True) @@ -389,11 +719,20 @@ class NominationSerializer(ModelSerializer): model = Nomination fields = ( - 'id', 'active', 'user', 'inserted_at', 'end_reason', 'ended_at', 'reviewed', 'entries' + 'id', + 'active', + 'user', + 'inserted_at', + 'end_reason', + 'ended_at', + 'reviewed', + 'entries', + 'thread_id' ) + frozen_fields = ('id', 'inserted_at', 'user', 'ended_at') -class OffensiveMessageSerializer(ModelSerializer): +class OffensiveMessageSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `OffensiveMessage` instances.""" class Meta: @@ -401,3 +740,4 @@ class OffensiveMessageSerializer(ModelSerializer): model = OffensiveMessage fields = ('id', 'channel_id', 'delete_date') + frozen_fields = ('id', 'channel_id') |