Search code examples
pythondjangooverridingdjango-settings

Overriding settings in Django when used by the models


We are using Django for Speedy Net and Speedy Match (currently Django 2.1). Some of our settings are used by the models. For example:

class USER_SETTINGS(object):
    MIN_USERNAME_LENGTH = 6
    MAX_USERNAME_LENGTH = 40

    MIN_SLUG_LENGTH = 6
    MAX_SLUG_LENGTH = 200

    # Users can register from age 0 to 180, but can't be kept on the site after age 250.
    MIN_AGE_ALLOWED_IN_MODEL = 0  # In years.
    MAX_AGE_ALLOWED_IN_MODEL = 250  # In years.

    MIN_AGE_ALLOWED_IN_FORMS = 0  # In years.
    MAX_AGE_ALLOWED_IN_FORMS = 180  # In years.

    MIN_PASSWORD_LENGTH = 8
    MAX_PASSWORD_LENGTH = 120

    MAX_NUMBER_OF_FRIENDS_ALLOWED = 800

    PASSWORD_VALIDATORS = [
        {
            'NAME': 'speedy.core.accounts.validators.PasswordMinLengthValidator',
        },
        {
            'NAME': 'speedy.core.accounts.validators.PasswordMaxLengthValidator',
        },
    ]

(which is defined in https://github.com/speedy-net/speedy-net/blob/staging/speedy/net/settings/global_settings.py). And then in the models I use:

from django.conf import settings as django_settings

class User(ValidateUserPasswordMixin, PermissionsMixin, Entity, AbstractBaseUser):
    settings = django_settings.USER_SETTINGS

(and then use attributes of settings, such as settings.MIN_SLUG_LENGTH, in the class).

The problem is, when I try to override such settings in tests (you can see my question & answer on Can I define classes in Django settings, and how can I override such settings in tests?), User.settings remains the same and is not overridden by the settings I tried to override. This is a problem since in the model I passed settings.MIN_SLUG_LENGTH for example to validators, which are also passed other values by other models. Is it possible to define the models and settings in such a way which the correct settings will be used both in production and in tests, including when I want to override them?

I'm aware of this quote from https://docs.djangoproject.com/en/dev/topics/testing/tools/#overriding-settings:

Warning

The settings file contains some settings that are only consulted during initialization of Django internals. If you change them with override_settings, the setting is changed if you access it via the django.conf.settings module, however, Django’s internals access it differently. Effectively, using override_settings() or modify_settings() with these settings is probably not going to do what you expect it to do.

We do not recommend altering the DATABASES setting. Altering the CACHES setting is possible, but a bit tricky if you are using internals that make using of caching, like django.contrib.sessions. For example, you will have to reinitialize the session backend in a test that uses cached sessions and overrides CACHES.

Finally, avoid aliasing your settings as module-level constants as override_settings() won’t work on such values since they are only evaluated the first time the module is imported.

Which I understand are relevant in this case, but how do I define the settings in such a way that I can override them?

Function _1___set_up in speedy/core/base/test/models.py is a workaround to make the tests work, but this is a hack and I don't think it's a good solution for the long term.


Solution

  • The problem, as you quoted:

    avoid aliasing your settings as module-level constants as override_settings() won’t work on such values since they are only evaluated the first time the module is imported.

    There are 3 ways around this, where Way 1 > Way 3 > Way 2.

    Way 1: Don't alias with class attribute, but classproperty

    Recommended; arguably the correct way.

    • Pro: Most expressive, easier to debug.
    • Con: More code in model.
    from django.utils.decorators import classproperty
    
    class User(PermissionsMixin, Entity, AbstractBaseUser):
        # settings = django_settings.USER_SETTINGS
        @classproperty
        def settings(cls):
            return django_settings.USER_SETTINGS
    

    Caveat: Class attributes that depend on the settings class attribute won't work.

    Although Way 2 allows the following code to still be valid, these are evaluated at class definition (import) time and cannot reasonably change based on override_settings(), unless they are classproperty too.

    AGE_VALID_VALUES_IN_MODEL = range(settings.MIN_AGE_ALLOWED_IN_MODEL, settings.MAX_AGE_ALLOWED_IN_MODEL)
    AGE_VALID_VALUES_IN_FORMS = range(settings.MIN_AGE_ALLOWED_IN_FORMS, settings.MAX_AGE_ALLOWED_IN_FORMS)
    

    Way 2: Patch settings class so instances read django_settings

    Not recommended; impacts the runtime evaluation of USER_SETTINGS also in production, not only in tests (@hynekcer).

    • Pro: No code change in model.
    • Con: Least expressive, more difficult to debug.

    1. Define a function overridable_settings:
    def overridable_settings(settings_class):
        old__getattribute__ = settings_class.__getattribute__
        settings_name = settings_class.__name__
    
        def patched__getattribute__(_self, item):
            from django.conf import settings as django_settings
            settings = getattr(django_settings, settings_name)
            return old__getattribute__(settings, item)
    
        settings_class.__getattribute__ = patched__getattribute__
        return settings_class()
    
    1. django_settings.USER_SETTINGS is now an instance of the settings class. Instead of get_django_settings_class_with_override_settings, define override_settings:
    import copy
    
    def override_settings(settings, **overrides):
        copied_settings = copy.deepcopy(settings)
        for setting, value in overrides.items():
            setattr(copied_settings, setting, value)
        assert copied_settings != settings
        return copied_settings
    

    Usage:

    @overridable_settings
    class USER_SETTINGS(object):
    
    from speedy.core.base.test import utils
    
    # @override_settings(USER_SETTINGS=get_django_settings_class_with_override_settings(django_settings_class=django_settings.USER_SETTINGS, MIN_SLUG_LENGTH=tests_settings.OVERRIDE_USER_SETTINGS.MIN_SLUG_LENGTH))
    @override_settings(USER_SETTINGS=utils.override_settings(django_settings.USER_SETTINGS, MIN_SLUG_LENGTH=tests_settings.OVERRIDE_USER_SETTINGS.MIN_SLUG_LENGTH))
    def test_slug_min_length_fail_username_min_length_ok(self):
    

    Way 3: Create receiver of signal setting_changed to update alias

    • Pro: Minimal code change to model.
    • Con: Less expressive than Way 1 with regard to the dependent attributes in the Caveat.

    From https://docs.djangoproject.com/en/dev/topics/testing/tools/#overriding-settings:

    When overriding settings, make sure to handle the cases in which your app’s code uses a cache or similar feature that retains state even if the setting is changed. Django provides the django.test.signals.setting_changed signal that lets you register callbacks to clean up and otherwise reset state when settings are changed.

    Django itself uses this signal to reset various data.

    from django.core.signals import setting_changed
    from django.dispatch.dispatcher import receiver
    
    def register_django_setting_alias(setting_alias, django_setting):
        def decorator(cls):
            @receiver(setting_changed, weak=False)
            def update_setting_alias(setting, value, **_):
                if setting == django_setting:
                    setattr(cls, setting_alias, value)
            return cls
        return decorator
    

    Usage:

    @register_django_setting_alias('settings', 'USER_SETTINGS')
    class User(PermissionsMixin, Entity, AbstractBaseUser):
        settings = django_settings.USER_SETTINGS