Doddie Doddie - 2 months ago 4
Python Question

How do I allow a function to be redefined in Django?

I have a 'core' Django product that includes default implementations of common tasks, but I want to allow that implementation to be redefined (or customised if that makes it easier).

For example in the core product, I might have a view which allows a user to click a button to resend 'all notifications':

# in core/views.py
... imports etc...
from core.tasks import resend_notifications

def handle_user_resend_request(request, user_id):
user = get_object_or_404(id=user_id)

if request.method == 'POST':
for follower in user.followers:
resend_notifications(follower.id)

... etc etc ...


# in core/tasks.py
... imports etc...

def resend_notifications(id):
send_email(User.objects.get(id=id))


And then in some deployments of this product, perhaps the 'resend_notifications' needs to look like:

# in customer_specific/tasks.py
... imports etc ...

def resend_notifications(id):
person = User.objects.get(id=id)
if '@super-hack.email.com' in person.email:
# This is not a real email, send via the magic portal
send_via_magic(person)
else:
send_email(person)
# and send via fax for good measure
send_fax(person)


How do I get the
resend_notifications
function in the
views.py
file to point to the customer_specific version?

Should I be defining this in the Django config and sharing access that way? What if the tasks are actually Celery tasks?

NB: The tasks I have are actually defined as Celery tasks (I removed this extra detail because I think the question is more general). I have tried with a custom decorator tag that mutates a global object, but that is definitely not the way to go for a number of reasons.

PS: I feel like this is a dependency injection question, but that is not a common thing in Django.

Answer

This ended up being solved via a Django settings object that can be reconfigured by the deployment config. It was largely inspired by the technique here: settings.py from django-rest-framework.

For example, I have a settings file like this in my project:

yourproject/settings.py

"""
Settings for <YOUR PROJECT> are all namespaced in the YOUR_PROJECT config option.
For example your project's config file (usually called `settings.py` or 'production.py') might look like this:

YOUR_PROJECT = {
    'PROCESS_TASK': (
        'your_project.tasks.process_task',
    )
}

This module provides the `yourproject_settings` object, that is used
to access settings, checking for user settings first, then falling
back to the defaults.
"""
# This file was effectively borrow from https://github.com/tomchristie/django-rest-framework/blob/8385ae42c06b8e68a714cb67b7f0766afe316883/rest_framework/settings.py

from __future__ import unicode_literals
from django.conf import settings
from django.utils.module_loading import import_string


DEFAULTS = {
    'RESEND_NOTIFICATIONS_TASK': 'core.tasks.resend_notifications',
}


# List of settings that may be in string import notation.
IMPORT_STRINGS = (
    'RESEND_NOTIFICATIONS_TASK',
)


MANDATORY_SETTINGS = (
    'RESEND_NOTIFICATIONS_TASK',
)


def perform_import(val, setting_name):
    """
    If the given setting is a string import notation,
    then perform the necessary import or imports.
    """
    if val is None:
        return None
    if callable(val):
        return val
    if isinstance(val, (list, tuple)):
        return [perform_import(item, setting_name) for item in val]

    try:
        return import_string(val)
    except (ImportError, AttributeError) as e:
        msg = "Could not import '%s' for setting '%s'. %s: %s." % (val, setting_name, e.__class__.__name__, e)
        raise ImportError(msg)


class YourProjectSettings(object):
    """
    A settings object, that allows settings to be accessed as properties.
    For example:

        from your_project.settings import yourproject_settings as the_settings
        print(the_settings.RESEND_NOTIFICATIONS_TASK)

    Any setting with string import paths will be automatically resolved
    and return the class, rather than the string literal.
    """
    namespace = 'YOUR_PROJECT'

    def __init__(self, mandatory=None, defaults=None, import_strings=None):
        self.mandatory = mandatory or MANDATORY_SETTINGS
        self.defaults = defaults or DEFAULTS
        self.import_strings = import_strings or IMPORT_STRINGS

        self.__check_settings()

    @property
    def user_settings(self):
        if not hasattr(self, '_user_settings'):
            self._user_settings = getattr(settings, self.__class__.namespace, {})
        return self._user_settings

    def __getattr__(self, attr):
        if attr not in self.defaults and attr not in self.mandatory:
            raise AttributeError("Invalid Pyrite setting: '%s'" % attr)

        try:
            # Check if present in user settings
            val = self.user_settings[attr]
        except KeyError:
            # Fall back to defaults
            val = self.defaults[attr]

        # Coerce import strings into classes
        if attr in self.import_strings:
            val = perform_import(val, attr)

        # Cache the result
        setattr(self, attr, val)
        return val

    def __check_settings(self):
        for setting in self.mandatory:
            if setting not in self.user_settings:
                raise RuntimeError(
                    'The "{}" setting is required as part of the configuration for "{}", but has not been supplied.'.format(
                    setting, self.__class__.namespace))


yourproject_settings = YourProjectSettings(MANDATORY_SETTINGS, DEFAULTS, IMPORT_STRINGS)

This allows me to either:

  • Use the default value (i.e. 'core.tasks.resend_notications'); OR
  • To redefine the binding in my config file:

    site_config/special.py

    ... other django settings like DB / DEBUG / Static files etc
    
    YOUR_PROJECT = {
        'RESEND_NOTIFICATIONS_TASK': 'customer_specific.tasks.resend_notifications',
    }
    
    ... etc. ...
    

Then in my view function, I access the correct function via the settings:

core/views.py

... imports etc...
from yourproject.settings import yourproject_settings as my_settings

def handle_user_resend_request(request, user_id):
    user = get_object_or_404(id=user_id)

    if request.method == 'POST':
        for follower in user.followers:
            my_settings.RESEND_NOTIFICATIONS_TASK(follower.id)

    ... etc etc ...
Comments