Lokicor
Lokicor

Reputation: 137

Max and min values for a Django model field, according to the values already introduced

I want to create a model in Django where I could introduce integer values for a given field, with limits depending on the values already introduced for that field. Specifically, I would like the value introduced to be in the range of (min = 1 | max = maximum value for that field +1) The code should be something like that in the models.py file:

from django.db import models
from django.core.validators import MaxValueValidator, MinValueValidator

class MyModel(models.Model):
    max_myfield = MyModel.objects.filter(myfield=self.myfield).aggregate(Max('myfield'))["myfield_max"]
    myfield = models.IntegerField(validators=[MinValueValidator=1, MaxValueValidator=max_myfield + 1])

The problem with this is that max_myfield is using the MyModel itself, raising a NameError as it is not defined yet.

In addition, if there is no record in the table, the max value shall be also 1, like the minimum.

EDIT: I want to simplify the question to the following: What should I do in the models.py file of a Django application, if I want to define a model with fields which validators shall depend somehow on the model fields values?

Upvotes: 1

Views: 7665

Answers (1)

Don Kirkby
Don Kirkby

Reputation: 56620

The trick to this is a little something I just noticed in the validator docs:

class MaxValueValidator(limit_value, message=None)
Raises a ValidationError with a code of 'max_value' if value is greater than limit_value, which may be a callable.

You don't want your maximum value to be the same for every record, so you can pass in a method that queries the database to find the current max and add 1. To avoid referencing the class before it's declared you can either pull the method out of the class or wrap the call in a lambda expression. I chose to pull it out.

Try changing your models.py to look like this:

from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from django.db.models import Max


def get_max_myfield() -> int:
    max_found = MyModel.objects.aggregate(Max('myfield'))["myfield__max"]
    if max_found is None:
        return 1
    return max_found + 1


class MyModel(models.Model):
    myfield = models.IntegerField(validators=[
        MinValueValidator(1),
        MaxValueValidator(get_max_myfield)])

Here's the same model code in an example that lets you run a complete Django app in one file and experiment with it. The first three calls to clean_fields() validate correctly, and the fourth one complains that myfield is too big.

# Tested with Django 3.1 and Python 3.8.
import logging
import sys

import django
from django.apps import apps
from django.apps.config import AppConfig
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import connections, models, DEFAULT_DB_ALIAS
from django.db.models import Max
from django.db.models.base import ModelBase
from django.core.validators import MaxValueValidator, MinValueValidator

NAME = 'udjango'
DB_FILE = NAME + '.db'


def main():
    setup()
    logger = logging.getLogger(__name__)

    def get_max_myfield() -> int:
        max_found = MyModel.objects.aggregate(Max('myfield'))["myfield__max"]
        if max_found is None:
            return 1
        return max_found + 1

    class MyModel(models.Model):
        myfield = models.IntegerField(validators=[
            MinValueValidator(1),
            MaxValueValidator(get_max_myfield)])

    syncdb(MyModel)
    
    m1 = MyModel(myfield=1)
    m1.clean_fields()
    m1.save()

    m2a = MyModel(myfield=2)
    m2a.clean_fields()
    m2a.save()

    m2b = MyModel(myfield=2)
    m2b.clean_fields()
    m2b.save()

    m101 = MyModel(myfield=101)
    try:
        m101.clean_fields()
        assert False, "Should have raised ValidationError."
    except ValidationError:
        logger.info("Raised validation error, as expected.")

    logger.info('Max allowed is %d.', get_max_myfield())


def setup():
    with open(DB_FILE, 'w'):
        pass  # wipe the database
    settings.configure(
        DEBUG=True,
        DATABASES={
            DEFAULT_DB_ALIAS: {
                'ENGINE': 'django.db.backends.sqlite3',
                'NAME': DB_FILE}},
        LOGGING={'version': 1,
                 'disable_existing_loggers': False,
                 'formatters': {
                    'debug': {
                        'format': '%(asctime)s[%(levelname)s]'
                                  '%(name)s.%(funcName)s(): %(message)s',
                        'datefmt': '%Y-%m-%d %H:%M:%S'}},
                 'handlers': {
                    'console': {
                        'level': 'DEBUG',
                        'class': 'logging.StreamHandler',
                        'formatter': 'debug'}},
                 'root': {
                    'handlers': ['console'],
                    'level': 'INFO'},
                 'loggers': {
                    "django.db": {"level": "DEBUG"}}})
    app_config = AppConfig(NAME, sys.modules['__main__'])
    apps.populate([app_config])
    django.setup()
    original_new_func = ModelBase.__new__

    @staticmethod
    def patched_new(cls, name, bases, attrs):
        if 'Meta' not in attrs:
            class Meta:
                app_label = NAME
            attrs['Meta'] = Meta
        return original_new_func(cls, name, bases, attrs)
    ModelBase.__new__ = patched_new


def syncdb(model):
    """ Standard syncdb expects models to be in reliable locations.

    Based on https://github.com/django/django/blob/1.9.3
    /django/core/management/commands/migrate.py#L285
    """
    connection = connections[DEFAULT_DB_ALIAS]
    with connection.schema_editor() as editor:
        editor.create_model(model)


main()

Upvotes: 1

Related Questions