diff --git a/drf_writable_nested/mixins.py b/drf_writable_nested/mixins.py index f40dd48..91a221b 100644 --- a/drf_writable_nested/mixins.py +++ b/drf_writable_nested/mixins.py @@ -10,7 +10,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from rest_framework.validators import UniqueValidator +from rest_framework.validators import UniqueTogetherValidator, UniqueValidator class BaseNestedModelSerializer(serializers.ModelSerializer): @@ -400,12 +400,14 @@ class Meta: (`UniqueFieldsMixin` and `NestedCreateMixin` or `NestedUpdateMixin`) you should put `UniqueFieldsMixin` ahead. """ - _unique_fields = [] # type: List[Tuple[str,UniqueValidator]] + _unique_fields = [] # type: List[Tuple[str, UniqueValidator]] + _unique_together_validators = [] # type: List[UniqueTogetherValidator] def get_fields(self): self._unique_fields = [] fields = super(UniqueFieldsMixin, self).get_fields() + for field_name, field in fields.items(): unique_validators = [validator for validator in field.validators @@ -419,6 +421,10 @@ def get_fields(self): return fields + def get_unique_together_validators(self): + self._unique_together_validators = super().get_unique_together_validators() + return [] + def _validate_unique_fields(self, validated_data): for unique_field in self._unique_fields: field_name, unique_validator = unique_field @@ -434,6 +440,8 @@ def _validate_unique_fields(self, validated_data): self.fields[field_name]) except ValidationError as exc: raise ValidationError({field_name: exc.detail}) + for validator in self._unique_together_validators: + validator(validated_data, self) def create(self, validated_data): self._validate_unique_fields(validated_data) diff --git a/tests/migrations/0003_add_itemcategory_and_more.py b/tests/migrations/0003_add_itemcategory_and_more.py new file mode 100644 index 0000000..56e23ee --- /dev/null +++ b/tests/migrations/0003_add_itemcategory_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.6 on 2025-09-14 14:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tests', '0002_alter_profile_sites_setnullforeignkey_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ItemCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('company', models.CharField(max_length=50)), + ], + options={ + 'unique_together': {('name', 'company')}, + }, + ), + migrations.CreateModel( + name='ItemParent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('child', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.itemcategory')), + ], + ), + ] diff --git a/tests/models.py b/tests/models.py index df28c1e..e998c18 100644 --- a/tests/models.py +++ b/tests/models.py @@ -128,6 +128,20 @@ class UFMParent(models.Model): child = models.ForeignKey(UFMChild, on_delete=models.CASCADE) +class ItemCategory(models.Model): + name = models.CharField(max_length=50) + company = models.CharField(max_length=50) + + class Meta: + unique_together = ( + ("name", "company"), + ) + + +class ItemParent(models.Model): + child = models.ForeignKey(ItemCategory, on_delete=models.CASCADE) + + # Models for different relations class ForeignKeyChild(models.Model): diff --git a/tests/serializers.py b/tests/serializers.py index 3c5e46f..df86020 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -228,6 +228,20 @@ class Meta: model = models.UFMParent fields = ('pk', 'child') +# UniqueFieldsMixin, unique_together validation serializers + +class ItemCategorySerializer(UniqueFieldsMixin, serializers.ModelSerializer): + class Meta: + model = models.ItemCategory + fields = "__all__" + + +class ItemParentSerializer(WritableNestedModelSerializer): + child = ItemCategorySerializer() + + class Meta: + model = models.ItemParent + fields = "__all__" # Different relations diff --git a/tests/test_unique_fields_mixin.py b/tests/test_unique_fields_mixin.py index 4a036aa..59bafaa 100644 --- a/tests/test_unique_fields_mixin.py +++ b/tests/test_unique_fields_mixin.py @@ -101,3 +101,65 @@ def test_unique_field_not_required_for_partial_updates(self): ) self.assertTrue(serializer.is_valid()) serializer.save() + + +class UniqueFieldsMixinUniqueTogetherTestCase(TestCase): + def test_create_update_success(self): + serializer = serializers.ItemParentSerializer( + data={'child': {'name': 'Video Cards', 'company': 'Example'}}) + self.assertTrue(serializer.is_valid()) + parent = serializer.save() # type: models.ItemParent + + serializer = serializers.ItemParentSerializer( + instance=parent, + data={ + 'pk': parent.pk, + 'child': { + 'pk': parent.child.pk, + 'name': 'value', + 'company': 'value', + } + } + ) + self.assertTrue(serializer.is_valid()) + serializer.save() + + def test_create_update_failed(self): + # In this case everything is valid on the validation stage, because + # UniqueTogetherValidator is skipped + # But `save` should raise an exception on create/update + + child = models.ItemCategory.objects.create(name='value', company='value') + parent = models.ItemParent.objects.create(child=child) + + default_error_detail = ErrorDetail( + string='The fields name, company must make a unique set.', + code='unique') + serializer = serializers.ItemParentSerializer( + data={ + 'child': { + 'name': child.name, + 'company': child.company, + } + } + ) + + self.assertTrue(serializer.is_valid()) + + with self.assertRaises(ValidationError) as ctx: + serializer.save() + self.assertEqual( + ctx.exception.detail, + {'child': [default_error_detail]} + ) + + + def test_unique_field_not_required_for_partial_updates(self): + child = models.ItemCategory.objects.create(name='value', company='value') + serializer = serializers.ItemCategorySerializer( + instance=child, + data={}, + partial=True + ) + self.assertTrue(serializer.is_valid()) + serializer.save()