2

I have an abstract model that is inherited by 2 children. In the children, one is setting a validator. When I run the code, I see both children having the validator.

Here is the pseudo code:

class myAbstract(models.Model):
    Intro         = models.TextField(blank=True, null=True, )

    class Meta:
        abstract = True

class child1(myAbstract):
    class Meta:
         verbose_name = 'child 1'

class child2(myAbstract):
    def __init__(self, *args, **kwargs):
        super(child2, self).__init__(*args, **kwargs)

        intro = self._meta.get_field('Intro')
        intro.validators.append(MaxLengthValidator(60))

     class Meta:
         verbose_name = 'child 2'

In the admin if I add a child1 and then add a child2 then the validator kicks in for child2 and limits the number of characters. If I start with child2 then child2 doesn't get the validator.

Is this the expected behavior? If there, what is the suggested way of coding this? I thought about moving Intro to the child classes.

Solved: As Alasdair pointed out the validators is a class variable therefore this is the expected behavior.

I tried moving the Intro field to the child but that didn't work. I used this solution:
https://stackoverflow.com/a/3209550/757955 that sets forms.CharField in the modelform.

Community
  • 1
  • 1
brian
  • 773
  • 4
  • 9
  • 24

3 Answers3

0

I did not really expect this behaviour, but there is a lot of magic happenign in the background with models. I see 2 solutions:

  1. Change the instance, not the class. _meta is an class variable, thus _meta.get_field will return class attributes. I would rather try to manipulate the instance fields like so

    def init(...): self.intro.validators.append(MaxLengthValidator(60))

  2. If 1 does not work, or you do not like it, leave the models alone, i.e. do not add the validator, but add the validators to the form that you use for the models. There you have more flexibility and can do what you want.

schacki
  • 9,401
  • 5
  • 29
  • 32
  • Setting the validators in model forms is a good idea. However the first suggestion won't work, because `self.intro` is the value of the field, so doesn't have a `validators` attribute. – Alasdair Aug 21 '12 at 21:14
0

The validators are not set per model instance. When you append the MaxLengthValidator, you are altering the intro field of the parent class.

I don't think there's an easy way around this. You could write a clean() method for each child model, and perform the validation there. However, I think that moving the intro field into the child classes is probably the best option here.

Alasdair
  • 298,606
  • 55
  • 578
  • 516
0

You can add a clean_fields() method to the child model with extra validators.

Helpers:

from typing import Any, Callable, Collection, Optional
from typing_extensions import TypeAlias
from django.db import models
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator

ValueValidator: TypeAlias = Callable[[Any], None]
FieldsValidatorsDict: TypeAlias = dict[str, list[ValueValidator]]


def run_validators_in_fields_validators_dict(
    model_instance: models.Model, validators_dict: FieldsValidatorsDict, exclude: list[str] = None
) -> None:
    """
    Run validators in fields-to-validators dict.

    Useful for adding extra validation to the fields in child model that came from the parent.
    (It's not possible to change `validators` of fields from base class)

    Example:

    ```
    class Animal(models.Model):
        name = models.CharField(validators=[...])

    class Dog(Animal):
        _extra_fields_validators = {"name": [...]}

        def clean_fields(self, exclude = None):
            errors = {}
            try:
                run_validators_in_fields_validators_dict(self, self._extra_fields_validators, exclude)
            except ValidationError as e:
                errors = e.update_error_dict(errors)
            try:
                super().clean_fields(exclude=exclude)
            except ValidationError as e:
                errors = e.update_error_dict(errors)
            if errors:
                raise ValidationError(errors)
    ```
    """
    if exclude is None:
        exclude = []

    errors = {}
    for field_name, validators in validators_dict.items():
        if field_name in exclude:
            continue
        try:
            _run_validators_on_field(model_instance, field_name, validators)
        except ValidationError as e:
            errors[field_name] = e.error_list

    if errors:
        raise ValidationError(errors)


def _run_validators_on_field(model_instance: models.Model, field_name: str, validators: list[ValueValidator]) -> None:
    field = model_instance.__class__._meta.get_field(field_name)
    raw_value = getattr(model_instance, field.attname)
    clean_value = field.to_python(raw_value)
    errors = []
    for validator in validators:
        try:
            validator(clean_value)
        except ValidationError as e:
            errors.extend(e.error_list)
    if errors:
        raise ValidationError(errors)

validate_str_no_underscore = RegexValidator(
    regex=r"^[^_]*\Z",
    message="Should not contain underscore",
)

Usage:

class Animal(models.Model):
    name = models.CharField()

class Dog(Animal):
    _extra_fields_validators: FieldsValidatorsDict = {"name": [validate_str_no_underscore]}

    def clean_fields(self, exclude: Optional[Collection[str]] = None) -> None:
        errors = {}
        try:
            run_validators_in_fields_validators_dict(self, self._extra_fields_validators, exclude)
        except ValidationError as e:
            errors = e.update_error_dict(errors)
        try:
            super().clean_fields(exclude=exclude)
        except ValidationError as e:
            errors = e.update_error_dict(errors)
        if errors:
            raise ValidationError(errors)
Noam Nol
  • 570
  • 4
  • 11