Skip to content

Commit 9cf6efb

Browse files
authored
Support violation_error_code and violation_error_message from UniqueConstraint in UniqueTogetherValidator (#9766)
* fix(serializer): restore get_unique_together_constraints method signature Extracted error message logic to a separate method. fix: conditionally include violation_error_code for Django >= 5.0 fix(validators): use custom error message and code from model constraints * fix: add model parents to create unique_constraint_by_fields * fix: order of model classes in the unique_constraint_by_fields
1 parent a323cf7 commit 9cf6efb

File tree

3 files changed

+80
-2
lines changed

3 files changed

+80
-2
lines changed

rest_framework/serializers.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,6 +1569,17 @@ def get_validators(self):
15691569
self.get_unique_for_date_validators()
15701570
)
15711571

1572+
def _get_constraint_violation_error_message(self, constraint):
1573+
"""
1574+
Returns the violation error message for the UniqueConstraint,
1575+
or None if the message is the default.
1576+
"""
1577+
violation_error_message = constraint.get_violation_error_message()
1578+
default_error_message = constraint.default_violation_error_message % {"name": constraint.name}
1579+
if violation_error_message == default_error_message:
1580+
return None
1581+
return violation_error_message
1582+
15721583
def get_unique_together_validators(self):
15731584
"""
15741585
Determine a default set of validators for any unique_together constraints.
@@ -1595,6 +1606,13 @@ def get_unique_together_validators(self):
15951606
for name, source in field_sources.items():
15961607
source_map[source].append(name)
15971608

1609+
unique_constraint_by_fields = {
1610+
constraint.fields: constraint
1611+
for model_cls in (*self.Meta.model._meta.parents, self.Meta.model)
1612+
for constraint in model_cls._meta.constraints
1613+
if isinstance(constraint, models.UniqueConstraint)
1614+
}
1615+
15981616
# Note that we make sure to check `unique_together` both on the
15991617
# base model class, but also on any parent classes.
16001618
validators = []
@@ -1621,11 +1639,17 @@ def get_unique_together_validators(self):
16211639
)
16221640

16231641
field_names = tuple(source_map[f][0] for f in unique_together)
1642+
1643+
constraint = unique_constraint_by_fields.get(tuple(unique_together))
1644+
violation_error_message = self._get_constraint_violation_error_message(constraint) if constraint else None
1645+
16241646
validator = UniqueTogetherValidator(
16251647
queryset=queryset,
16261648
fields=field_names,
16271649
condition_fields=tuple(source_map[f][0] for f in condition_fields),
16281650
condition=condition,
1651+
message=violation_error_message,
1652+
code=getattr(constraint, 'violation_error_code', None),
16291653
)
16301654
validators.append(validator)
16311655
return validators

rest_framework/validators.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,15 @@ class UniqueTogetherValidator:
111111
message = _('The fields {field_names} must make a unique set.')
112112
missing_message = _('This field is required.')
113113
requires_context = True
114+
code = 'unique'
114115

115-
def __init__(self, queryset, fields, message=None, condition_fields=None, condition=None):
116+
def __init__(self, queryset, fields, message=None, condition_fields=None, condition=None, code=None):
116117
self.queryset = queryset
117118
self.fields = fields
118119
self.message = message or self.message
119120
self.condition_fields = [] if condition_fields is None else condition_fields
120121
self.condition = condition
122+
self.code = code or self.code
121123

122124
def enforce_required_fields(self, attrs, serializer):
123125
"""
@@ -198,7 +200,7 @@ def __call__(self, attrs, serializer):
198200
if checked_values and None not in checked_values and qs_exists_with_condition(queryset, self.condition, condition_kwargs):
199201
field_names = ', '.join(self.fields)
200202
message = self.message.format(field_names=field_names)
201-
raise ValidationError(message, code='unique')
203+
raise ValidationError(message, code=self.code)
202204

203205
def __repr__(self):
204206
return '<{}({})>'.format(
@@ -217,6 +219,7 @@ def __eq__(self, other):
217219
and self.missing_message == other.missing_message
218220
and self.queryset == other.queryset
219221
and self.fields == other.fields
222+
and self.code == other.code
220223
)
221224

222225

tests/test_validators.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,26 @@ class Meta:
616616
]
617617

618618

619+
class UniqueConstraintCustomMessageCodeModel(models.Model):
620+
username = models.CharField(max_length=32)
621+
company_id = models.IntegerField()
622+
role = models.CharField(max_length=32)
623+
624+
class Meta:
625+
constraints = [
626+
models.UniqueConstraint(
627+
fields=("username", "company_id"),
628+
name="unique_username_company_custom_msg",
629+
violation_error_message="Username must be unique within a company.",
630+
**(dict(violation_error_code="duplicate_username") if django_version[0] >= 5 else {}),
631+
),
632+
models.UniqueConstraint(
633+
fields=("company_id", "role"),
634+
name="unique_company_role_default_msg",
635+
),
636+
]
637+
638+
619639
class UniqueConstraintSerializer(serializers.ModelSerializer):
620640
class Meta:
621641
model = UniqueConstraintModel
@@ -628,6 +648,12 @@ class Meta:
628648
fields = ('title', 'age', 'tag')
629649

630650

651+
class UniqueConstraintCustomMessageCodeSerializer(serializers.ModelSerializer):
652+
class Meta:
653+
model = UniqueConstraintCustomMessageCodeModel
654+
fields = ('username', 'company_id', 'role')
655+
656+
631657
class TestUniqueConstraintValidation(TestCase):
632658
def setUp(self):
633659
self.instance = UniqueConstraintModel.objects.create(
@@ -778,6 +804,31 @@ class Meta:
778804
)
779805
assert serializer.is_valid()
780806

807+
def test_unique_constraint_custom_message_code(self):
808+
UniqueConstraintCustomMessageCodeModel.objects.create(username="Alice", company_id=1, role="member")
809+
expected_code = "duplicate_username" if django_version[0] >= 5 else UniqueTogetherValidator.code
810+
811+
serializer = UniqueConstraintCustomMessageCodeSerializer(data={
812+
"username": "Alice",
813+
"company_id": 1,
814+
"role": "admin",
815+
})
816+
assert not serializer.is_valid()
817+
assert serializer.errors == {"non_field_errors": ["Username must be unique within a company."]}
818+
assert serializer.errors["non_field_errors"][0].code == expected_code
819+
820+
def test_unique_constraint_default_message_code(self):
821+
UniqueConstraintCustomMessageCodeModel.objects.create(username="Alice", company_id=1, role="member")
822+
serializer = UniqueConstraintCustomMessageCodeSerializer(data={
823+
"username": "John",
824+
"company_id": 1,
825+
"role": "member",
826+
})
827+
expected_message = UniqueTogetherValidator.message.format(field_names=', '.join(("company_id", "role")))
828+
assert not serializer.is_valid()
829+
assert serializer.errors == {"non_field_errors": [expected_message]}
830+
assert serializer.errors["non_field_errors"][0].code == UniqueTogetherValidator.code
831+
781832

782833
# Tests for `UniqueForDateValidator`
783834
# ----------------------------------

0 commit comments

Comments
 (0)