Skip to content

Commit 9989d06

Browse files
committed
Merge remote-tracking branch 'last-partizan/49-fix-unique-together' into issue-49
2 parents 2136278 + 1a1a2a7 commit 9989d06

6 files changed

Lines changed: 146 additions & 2 deletions

File tree

drf_writable_nested/mixins.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from django.utils.translation import gettext_lazy as _
1111
from rest_framework import serializers
1212
from rest_framework.exceptions import ValidationError
13-
from rest_framework.validators import UniqueValidator
13+
from rest_framework.validators import UniqueTogetherValidator, UniqueValidator
1414

1515

1616
class BaseNestedModelSerializer(serializers.ModelSerializer):
@@ -400,12 +400,14 @@ class Meta:
400400
(`UniqueFieldsMixin` and `NestedCreateMixin` or `NestedUpdateMixin`)
401401
you should put `UniqueFieldsMixin` ahead.
402402
"""
403-
_unique_fields = [] # type: List[Tuple[str,UniqueValidator]]
403+
_unique_fields = [] # type: List[Tuple[str, UniqueValidator]]
404+
_unique_together_validators = [] # type: List[UniqueTogetherValidator]
404405

405406
def get_fields(self):
406407
self._unique_fields = []
407408

408409
fields = super(UniqueFieldsMixin, self).get_fields()
410+
409411
for field_name, field in fields.items():
410412
unique_validators = [validator
411413
for validator in field.validators
@@ -419,6 +421,10 @@ def get_fields(self):
419421

420422
return fields
421423

424+
def get_unique_together_validators(self):
425+
self._unique_together_validators = super().get_unique_together_validators()
426+
return []
427+
422428
def _validate_unique_fields(self, validated_data):
423429
for unique_field in self._unique_fields:
424430
field_name, unique_validator = unique_field
@@ -434,6 +440,8 @@ def _validate_unique_fields(self, validated_data):
434440
self.fields[field_name])
435441
except ValidationError as exc:
436442
raise ValidationError({field_name: exc.detail})
443+
for validator in self._unique_together_validators:
444+
validator(validated_data, self)
437445

438446
def create(self, validated_data):
439447
self._validate_unique_fields(validated_data)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 5.2.6 on 2025-09-14 14:12
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('tests', '0002_alter_profile_sites_setnullforeignkey_and_more'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='ItemCategory',
16+
fields=[
17+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18+
('name', models.CharField(max_length=50)),
19+
('company', models.CharField(max_length=50)),
20+
],
21+
options={
22+
'unique_together': {('name', 'company')},
23+
},
24+
),
25+
migrations.CreateModel(
26+
name='ItemParent',
27+
fields=[
28+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
29+
('child', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tests.itemcategory')),
30+
],
31+
),
32+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Generated by Django 4.2.6 on 2025-09-15 04:06
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("tests", "0003_add_itemcategory_and_more"),
10+
("tests", "0003_i49product_i49veterinary_alter_accesskey_id_and_more"),
11+
]
12+
13+
operations = []

tests/models.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ class UFMParent(models.Model):
128128
child = models.ForeignKey(UFMChild, on_delete=models.CASCADE)
129129

130130

131+
class ItemCategory(models.Model):
132+
name = models.CharField(max_length=50)
133+
company = models.CharField(max_length=50)
134+
135+
class Meta:
136+
unique_together = (
137+
("name", "company"),
138+
)
139+
140+
141+
class ItemParent(models.Model):
142+
child = models.ForeignKey(ItemCategory, on_delete=models.CASCADE)
143+
144+
131145
# Models for different relations
132146

133147
class ForeignKeyChild(models.Model):

tests/serializers.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,20 @@ class Meta:
229229
model = models.UFMParent
230230
fields = ('pk', 'child')
231231

232+
# UniqueFieldsMixin, unique_together validation serializers
233+
234+
class ItemCategorySerializer(UniqueFieldsMixin, serializers.ModelSerializer):
235+
class Meta:
236+
model = models.ItemCategory
237+
fields = "__all__"
238+
239+
240+
class ItemParentSerializer(WritableNestedModelSerializer):
241+
child = ItemCategorySerializer()
242+
243+
class Meta:
244+
model = models.ItemParent
245+
fields = "__all__"
232246

233247
# Different relations
234248

tests/test_unique_fields_mixin.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,66 @@ def test_issue_49(self):
131131

132132
assert len(models.I49Product.objects.all()) == 1
133133
assert len(models.I49ProductDetail.objects.all()) == 1
134+
135+
136+
class UniqueFieldsMixinUniqueTogetherTestCase(TestCase):
137+
def test_create_update_success(self):
138+
serializer = serializers.ItemParentSerializer(
139+
data={'child': {'name': 'Video Cards', 'company': 'Example'}})
140+
self.assertTrue(serializer.is_valid())
141+
parent = serializer.save() # type: models.ItemParent
142+
143+
serializer = serializers.ItemParentSerializer(
144+
instance=parent,
145+
data={
146+
'pk': parent.pk,
147+
'child': {
148+
'pk': parent.child.pk,
149+
'name': 'value',
150+
'company': 'value',
151+
}
152+
}
153+
)
154+
self.assertTrue(serializer.is_valid())
155+
serializer.save()
156+
157+
def test_create_update_failed(self):
158+
# In this case everything is valid on the validation stage, because
159+
# UniqueTogetherValidator is skipped
160+
# But `save` should raise an exception on create/update
161+
162+
child = models.ItemCategory.objects.create(name='value', company='value')
163+
parent = models.ItemParent.objects.create(child=child)
164+
165+
default_error_detail = ErrorDetail(
166+
string='The fields name, company must make a unique set.',
167+
code='unique')
168+
serializer = serializers.ItemParentSerializer(
169+
data={
170+
'child': {
171+
'name': child.name,
172+
'company': child.company,
173+
}
174+
}
175+
)
176+
177+
self.assertTrue(serializer.is_valid())
178+
179+
with self.assertRaises(ValidationError) as ctx:
180+
serializer.save()
181+
self.assertEqual(
182+
ctx.exception.detail,
183+
{'child': [default_error_detail]}
184+
)
185+
186+
187+
def test_unique_field_not_required_for_partial_updates(self):
188+
child = models.ItemCategory.objects.create(name='value', company='value')
189+
serializer = serializers.ItemCategorySerializer(
190+
instance=child,
191+
data={},
192+
partial=True
193+
)
194+
self.assertTrue(serializer.is_valid())
195+
serializer.save()
196+

0 commit comments

Comments
 (0)