diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 2186b02cf..87fd6190d 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -2,6 +2,7 @@ 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 @@ -35,6 +36,30 @@ 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.""" @@ -426,7 +451,7 @@ def to_representation(self, instance: FilterList) -> dict: # endregion -class InfractionSerializer(ModelSerializer): +class InfractionSerializer(FrozenFieldsMixin, ModelSerializer): """A class providing (de-)serialization of `Infraction` instances.""" class Meta: @@ -447,6 +472,7 @@ class Meta: 'dm_sent', 'jump_url' ) + 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.""" @@ -683,7 +709,7 @@ class Meta: 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) @@ -703,9 +729,10 @@ class Meta: '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: @@ -713,3 +740,4 @@ class Meta: model = OffensiveMessage fields = ('id', 'channel_id', 'delete_date') + frozen_fields = ('id', 'channel_id') diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py index 7fe2f0a81..e4dfe36a7 100644 --- a/pydis_site/apps/api/tests/test_nominations.py +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -254,7 +254,7 @@ def test_returns_200_update_reason_on_active_with_actor(self): def test_returns_400_on_frozen_field_update(self): url = reverse('api:bot:nomination-detail', args=(self.active_nomination.id,)) data = { - 'user': "Theo Katzman" + 'user': 1234 } response = self.client.patch(url, data=data) diff --git a/pydis_site/apps/api/tests/test_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py index d01231f16..2dc60bc30 100644 --- a/pydis_site/apps/api/tests/test_offensive_message.py +++ b/pydis_site/apps/api/tests/test_offensive_message.py @@ -156,6 +156,14 @@ def test_updating_message(self): delta=datetime.timedelta(seconds=1), ) + def test_updating_write_once_fields(self): + """Fields such as the channel ID may not be updated.""" + url = reverse('api:bot:offensivemessage-detail', args=(self.message.id,)) + data = {'channel_id': self.message.channel_id + 1} + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {'channel_id': ["This field cannot be updated."]}) + def test_updating_nonexistent_message(self): url = reverse('api:bot:offensivemessage-detail', args=(self.message.id + 1,)) data = {'delete_date': self.in_one_week} diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py index 26cae3adb..09c05a743 100644 --- a/pydis_site/apps/api/viewsets/bot/infraction.py +++ b/pydis_site/apps/api/viewsets/bot/infraction.py @@ -157,14 +157,9 @@ class InfractionViewSet( filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter) filterset_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type') search_fields = ('$reason',) - frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden') def partial_update(self, request: HttpRequest, *_args, **_kwargs) -> Response: """Method that handles the nuts and bolts of updating an Infraction.""" - for field in request.data: - if field in self.frozen_fields: - raise ValidationError({field: ['This field cannot be updated.']}) - instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) diff --git a/pydis_site/apps/api/viewsets/bot/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py index 78687e0e7..953513e02 100644 --- a/pydis_site/apps/api/viewsets/bot/nomination.py +++ b/pydis_site/apps/api/viewsets/bot/nomination.py @@ -173,7 +173,6 @@ class NominationViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, Ge queryset = Nomination.objects.all() filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter) filterset_fields = ('user__id', 'active') - frozen_fields = ('id', 'inserted_at', 'user', 'ended_at') frozen_on_create = ('ended_at', 'end_reason', 'active', 'inserted_at', 'reviewed') def create(self, request: HttpRequest, *args, **kwargs) -> Response: @@ -238,10 +237,6 @@ def partial_update(self, request: HttpRequest, *args, **kwargs) -> Response: Called by the Django Rest Framework in response to the corresponding HTTP request. """ - for field in request.data: - if field in self.frozen_fields: - raise ValidationError({field: ['This field cannot be updated.']}) - instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True)