Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Changelog

**12.13.0** (2026-08-27)
* Added `ValidateConstraintsOnSaveMixin`, which validates the models `Meta.constraints` on save and raises a `ValidationError` instead of an `IntegrityError`

**12.12.0** (2026-08-27)
* Added support for Django 6.1
* **Breaking change:** Dropped support for Django 4.2, whose extended support ended in April 2026
Expand Down
2 changes: 1 addition & 1 deletion ambient_toolbox/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Python toolbox of Beyonder Deutschland containing an abundance of useful tools and gadgets."""

__version__ = "12.12.0"
__version__ = "12.13.0"
24 changes: 23 additions & 1 deletion ambient_toolbox/mixins/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,27 @@ class CleanOnSaveMixin:
"""

def save(self, *args, **kwargs):
self.clean()
self._validate_before_save()
super().save(*args, **kwargs)

def _validate_before_save(self):
self.clean()


class ValidateConstraintsOnSaveMixin(CleanOnSaveMixin):
"""
Mixin which ensures model-level validation ("clean()") and constraint validation
("validate_constraints()") are called on saving the current instance.

Constraints are checked the way a ModelForm checks them, so a violation surfaces as a
``ValidationError`` carrying the constraint's ``violation_error_message`` instead of an
``IntegrityError``. The constraint in the database stays the authority for concurrent writes -
this only turns the common case into a catchable error.

Costs one query per constraint per save. ``bulk_create()`` and ``QuerySet.update()`` bypass
``save()`` entirely; there the database constraint still holds, but raises an ``IntegrityError``.
"""

def _validate_before_save(self):
super()._validate_before_save()
self.validate_constraints()
39 changes: 39 additions & 0 deletions docs/features/mixins.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,42 @@ class ModelWithCleanMixin(CleanOnSaveMixin, models.Model):
# to your magic here
pass
````

### ValidateConstraintsOnSaveMixin

Django validates the constraints declared in your models `Meta.constraints` when a `ModelForm` is validated - but not
on a regular model save. There, a violation reaches the database and surfaces as an `IntegrityError`.

If you want to handle such a violation like any other validation problem, derive your model from the
`ValidateConstraintsOnSaveMixin`. It calls `clean()` and afterwards `validate_constraints()` on every save, so a
violation is raised as a `ValidationError` containing the constraints `violation_error_message`.

````python
from django.db import models
from ambient_toolbox.mixins.validation import ValidateConstraintsOnSaveMixin

class Booking(ValidateConstraintsOnSaveMixin, models.Model):
guest_count = models.PositiveIntegerField()

class Meta:
constraints = [
models.CheckConstraint(
condition=models.Q(guest_count__lte=10),
name="booking_guest_count_lte_10",
violation_error_message="A booking can hold ten guests at most.",
)
]
````

````python
from django.core.exceptions import ValidationError

try:
Booking(guest_count=11).save()
except ValidationError as e:
print(e.messages) # ['A booking can hold ten guests at most.']
````

The constraint in the database stays the authority for concurrent writes - the mixin only turns the common case into a
catchable error. Keep in mind that validating constraints costs one query per constraint per save, and that bulk
operations like `bulk_create()` or `QuerySet.update()` don't call `save()`, hence still raise an `IntegrityError`.
30 changes: 30 additions & 0 deletions testapp/migrations/0005_modelwithvalidateconstraintsmixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Generated by Django 6.1 on 2026-08-27 11:23

import ambient_toolbox.mixins.validation
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("testapp", "0004_modelwithm2mtouser"),
]

operations = [
migrations.CreateModel(
name="ModelWithValidateConstraintsMixin",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("value", models.PositiveIntegerField(default=0)),
],
options={
"constraints": [
models.CheckConstraint(
condition=models.Q(("value__lte", 10)),
name="testapp_model_with_validate_constraints_mixin_value_lte_10",
violation_error_message="Value must not exceed 10.",
)
],
},
bases=(ambient_toolbox.mixins.validation.ValidateConstraintsOnSaveMixin, models.Model),
),
]
25 changes: 24 additions & 1 deletion testapp/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
from django.db import models
from django.db.models.signals import post_save, pre_save
Expand All @@ -6,7 +7,7 @@
from ambient_toolbox.managers import GloballyVisibleQuerySet
from ambient_toolbox.mixins.bleacher import BleacherMixin
from ambient_toolbox.mixins.models import PermissionModelMixin, SaveWithoutSignalsMixin
from ambient_toolbox.mixins.validation import CleanOnSaveMixin
from ambient_toolbox.mixins.validation import CleanOnSaveMixin, ValidateConstraintsOnSaveMixin
from ambient_toolbox.models import CommonInfo
from testapp.managers import ModelWithGetOrNoneManager, ModelWithSelectorQuerySet
from testapp.selectors import ModelWithSelectorGloballyVisibleSelector
Expand Down Expand Up @@ -97,6 +98,28 @@ def clean(self):
return True


class ModelWithValidateConstraintsMixin(ValidateConstraintsOnSaveMixin, models.Model):
INVALID_VALUE = 7

value = models.PositiveIntegerField(default=0)

class Meta:
constraints = [
models.CheckConstraint(
condition=models.Q(value__lte=10),
name="testapp_model_with_validate_constraints_mixin_value_lte_10",
violation_error_message="Value must not exceed 10.",
)
]

def __str__(self):
return str(self.value)

def clean(self):
if self.value == self.INVALID_VALUE:
raise ValidationError("Seven is not a valid value.")


class MyPermissionModelMixin(PermissionModelMixin, models.Model):
def __str__(self):
return str(self.id)
Expand Down
54 changes: 53 additions & 1 deletion tests/mixins/test_validation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from unittest import mock

from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.test import TestCase

from testapp.models import ModelWithCleanMixin
from testapp.models import ModelWithCleanMixin, ModelWithValidateConstraintsMixin


class CleanOnSaveMixinTest(TestCase):
Expand All @@ -16,3 +18,53 @@ def test_clean_is_called(self):
obj.save()

mocked_method.assert_called_once()


class ValidateConstraintsOnSaveMixinTest(TestCase):
def test_valid_object_is_saved(self):
obj = ModelWithValidateConstraintsMixin(value=1)

self.assertIsNone(obj.save())
self.assertEqual(ModelWithValidateConstraintsMixin.objects.get(pk=obj.pk).value, 1)

def test_clean_and_constraint_validation_are_called(self):
obj = ModelWithValidateConstraintsMixin(value=1)
with (
mock.patch.object(obj, "clean") as mocked_clean,
mock.patch.object(obj, "validate_constraints") as mocked_validate_constraints,
):
obj.save()

mocked_clean.assert_called_once()
mocked_validate_constraints.assert_called_once()

def test_clean_is_called_before_constraint_validation(self):
obj = ModelWithValidateConstraintsMixin(value=1)
call_order = []
with (
mock.patch.object(obj, "clean", side_effect=lambda: call_order.append("clean")),
mock.patch.object(
obj, "validate_constraints", side_effect=lambda: call_order.append("validate_constraints")
),
):
obj.save()

self.assertEqual(call_order, ["clean", "validate_constraints"])

def test_constraint_violation_raises_validation_error(self):
obj = ModelWithValidateConstraintsMixin(value=11)

with self.assertRaisesMessage(ValidationError, "Value must not exceed 10."):
obj.save()

self.assertFalse(ModelWithValidateConstraintsMixin.objects.exists())

def test_clean_violation_raises_validation_error(self):
obj = ModelWithValidateConstraintsMixin(value=7)

with self.assertRaisesMessage(ValidationError, "Seven is not a valid value."):
obj.save()

def test_bulk_create_bypasses_constraint_validation(self):
with self.assertRaises(IntegrityError):
ModelWithValidateConstraintsMixin.objects.bulk_create([ModelWithValidateConstraintsMixin(value=11)])