57

I have a Django model with a start and end date range. I want to enforce validation so that no two records have overlapping date ranges. What's the simplest way to implement this so that I don't have to repeat myself writing this logic?

e.g. I don't want to re-implement this logic in a Form and a ModelForm and an admin form and the model's overridden save().

As far as I know, Django doesn't make it easy to globally enforce these types of criteria.

Googling hasn't been very helpful, since "model validation" typically refers to validating specific model fields, and not the entire model contents, or relations between fields.

Mariusz Jamro
  • 30,615
  • 24
  • 120
  • 162
Cerin
  • 60,957
  • 96
  • 316
  • 522

4 Answers4

70

The basic pattern I've found useful is to put all my custom validation in clean() and then simply call full_clean() (which calls clean() and a few other methods) from inside save(), e.g.:

class BaseModel(models.Model):
    
    def clean(self, *args, **kwargs):
        # add custom validation here
        super().clean(*args, **kwargs)

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

This isn't done by default, as explained here, because it interferes with certain features, but those aren't a problem for my application.

siegy22
  • 4,295
  • 3
  • 25
  • 43
Cerin
  • 60,957
  • 96
  • 316
  • 522
  • 14
    In Python 3, `super(BaseModel, self)` can be simplified to [`super()`](https://docs.python.org/3/library/functions.html#super). – phoenix Mar 14 '19 at 12:30
  • 1
    relying on a save override is dangerous [if you're using bulk operations](https://docs.djangoproject.com/en/4.1/topics/db/models/#overriding-model-methods) as save doesn't ever get called – CatCatMcMeows Nov 20 '22 at 19:01
29

I would override the validate_unique method on the model. To make sure you ignore the current object when validating, you can use the following:

from django.db.models import Model, DateTimeField
from django.core.validators import NON_FIELD_ERRORS, ValidationError

class MyModel(Model):
    start_date = DateTimeField()
    end_date = DateTimeField()

    def validate_unique(self, *args, **kwargs):
        super(MyModel, self).validate_unique(*args, **kwargs)

        qs = self.__class__._default_manager.filter(
            start_date__lt=self.end_date,
            end_date__gt=self.start_date
        )

        if not self._state.adding and self.pk is not None:
            qs = qs.exclude(pk=self.pk)

        if qs.exists():
            raise ValidationError({
                NON_FIELD_ERRORS: ['overlapping date range',],
            })

ModelForm will automatically call this for you through a full_clean(), which you can use manually too.

PPR has a nice discussion of a simple, correct range overlap condition.

Mauro Baraldi
  • 6,346
  • 2
  • 32
  • 43
sciyoshi
  • 952
  • 8
  • 7
  • On django 1.3.1 I'm running into a problem raising the ValidationError the way you describe it. I was able to fix it by passing a {field: (error_msg,)} dict instead of a string error_msg when raising the exception. – adam Feb 27 '12 at 22:47
  • 6
    `from django.core.exceptions import ValidationError, NON_FIELD_ERRORS` `raise ValidationError({NON_FIELD_ERRORS: ('overlapping date range',)})` – adam Feb 27 '12 at 22:52
  • 2
    Is there any interest in extending `validate_unique` instead of just defining `clean`? Just a question of organizing the code? – lajarre Sep 18 '13 at 10:47
  • validate_unique implies [in it's method name] that you're validating a unique constraint. Django's "validating objects" documentation refers to the clean method. In my opinion, it seems more logical to go with the accepted answer and do your validation there. – Sig Myers Nov 28 '14 at 20:27
16

I think you should use this: https://docs.djangoproject.com/en/dev/ref/models/instances/#validating-objects

Just define clean() method in your model like this: (example from the docs link)

def clean(self):
    from django.core.exceptions import ValidationError
    # Don't allow draft entries to have a pub_date.
    if self.status == 'draft' and self.pub_date is not None:
        raise ValidationError('Draft entries may not have a publication date.')
    # Set the pub_date for published items if it hasn't been set already.
    if self.status == 'published' and self.pub_date is None:
        self.pub_date = datetime.datetime.now()
aliteralmind
  • 19,847
  • 17
  • 77
  • 108
alTus
  • 2,177
  • 1
  • 14
  • 23
  • 4
    This is close. I also had to override my model's save(), and call clean() from there. – Cerin Sep 09 '11 at 19:51
  • 1
    But what for? AdminSite (ModelForm) calls clean() automatically. But calling clean() from save() method could produce ValidationError in unexpected moment and it won't be cought as expected. – alTus Sep 09 '11 at 19:54
  • 7
    Not everything calls clean. This needs to be validated regardless of where it's saved. A broken site page is preferable to corrupt data. – Cerin Sep 11 '11 at 08:12
  • 2
    @Cerin seems anyway not a good idea to call `clean` from `save`. Don't follow Django guidelines at half. If you want this to happen at each `save()`, then forget about `clean` and put the code in the `save` method. – lajarre Sep 18 '13 at 10:44
  • 3
    @lajarre, Duplicating error checking code is a horrible idea. You should always follow DRY. – Cerin Sep 18 '13 at 13:58
  • @Cerin what you say is right, but I'm not talking about duplicating. Calling `clean` in `save` can happen to validate the object two times (when Django does `full_clean` then `save`, which happens in several different situations). If you're not happy with writing something in `save`, call `full_clean()` before calling `save()` in your code. – lajarre Sep 18 '13 at 18:21
  • Is using the pre_save receiver function to attach a validation logic a bad idea? We can raise an exception so that save doesn't occur in case of invalid data. – Arpan Mukherjee Jul 18 '20 at 16:54
1

I think this can help you, We can create multiple validators like this use in models.

from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django.db import models

def validate_even(value):
    if value % 2 != 0:
        raise ValidationError(
            _('%(value)s is not an even number'),
            params={'value': value},
        )

class MyModel(models.Model):
    even_field = models.IntegerField(validators=[validate_even])
Mr Singh
  • 3,936
  • 5
  • 41
  • 60