From e6b2dab5c095fb85c19500f2c39802a402bb6987 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Tue, 10 Mar 2026 09:52:34 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(backend)=20custom=20pydantic?= =?UTF-8?q?=20errors=20to=20be=20compatible=20with=20our=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We are using the drf_standardized_errors handler to have "nice" errors for the frontend application. We decided to override the SchemaField from the django_pydantic_field library in order to make it compatible with this error handler. --- src/backend/core/api/fields.py | 30 +++++++++++++++- src/backend/core/api/serializers.py | 2 +- src/backend/core/models.py | 4 +-- src/backend/core/tests/test_api_users.py | 46 ++++++++++++++++++++++-- 4 files changed, 75 insertions(+), 7 deletions(-) diff --git a/src/backend/core/api/fields.py b/src/backend/core/api/fields.py index 11256224..944b5de9 100644 --- a/src/backend/core/api/fields.py +++ b/src/backend/core/api/fields.py @@ -1,8 +1,13 @@ """A JSONField for DRF to handle serialization/deserialization.""" import json +import typing as ty -from rest_framework import serializers +import pydantic +from django_pydantic_field.v2.rest_framework.fields import ( + SchemaField as PydanticSchemaField, +) +from rest_framework import exceptions, serializers class JSONField(serializers.Field): @@ -23,3 +28,26 @@ class JSONField(serializers.Field): if data is None: return None return json.dumps(data) + + +class SchemaField(PydanticSchemaField): + """ + Custom SchemaField in order to create error messages compatible with + drf_standardized_errors handler. + """ + + def to_internal_value(self, data: ty.Any): + try: + if isinstance(data, (str, bytes)): + return self.adapter.validate_json(data) + return self.adapter.validate_python(data) + except pydantic.ValidationError as exc: + pydantic_errors = exc.errors( + include_url=False, include_context=False, include_input=False + ) + errors = [] + for pydantic_error in pydantic_errors: + for location in pydantic_error.get("loc"): + errors.append({location: pydantic_error.get("msg")}) + + raise exceptions.ValidationError(errors, code="invalid") from exc diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index f79dc704..371ecf46 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -12,12 +12,12 @@ from django.conf import settings from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from django_pydantic_field.rest_framework import SchemaField from lasuite.drf.models.choices import LinkReachChoices, get_equivalent_link_definition from rest_framework import serializers from core import models from core.api import utils +from core.api.fields import SchemaField from core.storage import get_storage_compute_backend from wopi import utils as wopi_utils diff --git a/src/backend/core/models.py b/src/backend/core/models.py index b6f0c896..d5a84ffa 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -213,9 +213,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): short_name = models.CharField(_("short name"), max_length=100, null=True, blank=True) email = models.EmailField(_("identity email address"), blank=True, null=True) - column_preferences = SchemaField( - ColumnPreferences, blank=True, null=True, default=None - ) + column_preferences = SchemaField(ColumnPreferences, blank=True, null=True, default=None) # Unlike the "email" field which stores the email coming from the OIDC token, this field # stores the email used by staff users to login to the admin site diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index ff125b9f..bfb7e8b4 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -684,8 +684,10 @@ def test_api_users_patch_column_preferences_valid(column2, column1): assert user.column_preferences == models.ColumnPreferences(**column_preferences) -@pytest.mark.parametrize("column_name", ["column1", "column2"]) -def test_api_users_patch_column_preferences_missing_column_should_fail(column_name): +@pytest.mark.parametrize( + "column_name,missing_column", [("column1", "column2"), ("column2", "column1")] +) +def test_api_users_patch_column_preferences_missing_column_should_fail(column_name, missing_column): """Patching column_preferences with a missing required column parameter should fails.""" user = factories.UserFactory() @@ -705,6 +707,17 @@ def test_api_users_patch_column_preferences_missing_column_should_fail(column_na format="json", ) + assert response.json() == { + "type": "validation_error", + "errors": [ + { + "code": "invalid", + "detail": "Field required", + "attr": f"column_preferences.0.{missing_column}", + } + ], + } + assert response.status_code == 400 @@ -732,6 +745,17 @@ def test_api_users_patch_column_preferences_extra_value_should_fail(): format="json", ) + assert response.json() == { + "type": "validation_error", + "errors": [ + { + "code": "invalid", + "detail": "Extra inputs are not permitted", + "attr": "column_preferences.0.not_allowed", + }, + ], + } + assert response.status_code == 400 @@ -758,6 +782,24 @@ def test_api_users_patch_column_preferences_invalid_value(): format="json", ) + assert response.json() == { + "type": "validation_error", + "errors": [ + { + "code": "invalid", + "detail": "Input should be 'last_modified', 'created', 'created_by', " + "'file_type' or 'file_size'", + "attr": "column_preferences.0.column1", + }, + { + "code": "invalid", + "detail": "Input should be 'last_modified', 'created', 'created_by', " + "'file_type' or 'file_size'", + "attr": "column_preferences.1.column2", + }, + ], + } + assert response.status_code == 400