DEV Community

Cover image for Making Django Global Settings Dynamic: The Singleton Design Pattern
John Owolabi Idogun
John Owolabi Idogun

Posted on • Edited on

Making Django Global Settings Dynamic: The Singleton Design Pattern

Update

The user interface has been updated to include sortable list using HTML5 drag-and-drop feature with some bunch of JavaScript. As a result, the codes in core/views.py and by effect, core/tests.py have been modified. All these changes are available in this article's GitHub repository.

New Interface

Motivation

I worked on a task a while ago where I was to make some Django's global setting's variables dynamic. With the contraint that data persistence is important and that the persisted setting's data shouldn't have more than one occurrence throughout the app. These setting's variables should be accompanied with an interface where their values can be changed/updated dynamically and the updated values should immediately be available to other modules requiring their usage. After a couple of research or googling, I found 1, 2, and 3 among others. I also came across Django packages such as constance and co., which help make Django settings dynamic. The settings can then be updated via Django's Admin's interface. Using these packages was an overkill for my use case and I also need more flexibility and control on its implementation so as to have 100% code testing coverage. So, I decided to roll out my implementation, standing on the shoulders of these blog posts and packages.

Assumptions

  • It is assummed that readers are pretty familiar with Django and JavaScript as well as the typed extension of Python using mypy, typing built-in module, and PEP8.

  • You should also be familiar with writing tests for Django models,methods, views and functions. I didn't mean you should be militant at that though.

  • I also assumed that you have gone through at least, this blog post, 1, to get more acquainted with the pattern being discussed and the formal problem being solved.

  • And, of course, HTML, and CSS (and its frameworks — Bootstrap for this project) knowledge is needed.

Source code

The entire source code for this article can be accessed via:

GitHub logo Sirneij / django_dynamic_global_settings

A simple demonstration of changing django global settings dynamically at runtime without server restart

dynamic_settings

main Issues Forks Stars License

This repository accompanies this tutorial on dev.to. It has been deployed to heroku and can be accessed live via this link.

Run locally

It can be run locally by first editing dynamic_settings/settings.py to reflect your PostgreSQL database configuration or create a .env file in your root directory and put the following in:

DB_NAME=your database name
DB_USER=your database user's username
DB_PASSWORD=your database password
Enter fullscreen mode Exit fullscreen mode

Then, create a virtual environment using any of venv, poetry, virtualenv, and pipenv. I used virtualenv while developing the app. Having created the virtual environment, activate it and install the project's dependencies by issuing the following command in your terminal:

(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Then, migrate the database:

(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

Thereafter, run the project:

(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> python manage.py run
Enter fullscreen mode Exit fullscreen mode



Aside this, the application is live and can be accessed via https://dynamic-settings.herokuapp.com/ .

Implementation

Step 1: Preliminaries

Ensure you have activated your virtual environment, installed Django, created a Django project with a suitable name (I called mine, dynamic_settings), and proceeded to create a Django app. From my end, my app's name is core. Open up your settings.py file and append your newly created app to your project's INSTALLED_APPS:

# dynamic_settings -> settings.py
...

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'core.apps.CoreConfig', # add this line
]
...
Enter fullscreen mode Exit fullscreen mode

Also, using this opportunity, configure your templates directory and change your database to PostgreSQL. PostgreSQL was chosen because I needed to use it's special ArrayField in our model definition.

# dynamic_settings -> settings.py
...

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / 'templates'], # make this line look like this
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

...

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'database name',
        'USER': 'database username',
        'PASSWORD': 'database user password',
        'HOST': 'localhost',
        'PORT': 5432,
    },
}
...
Enter fullscreen mode Exit fullscreen mode

Because of this, you need to install psycopg2-binary so that Django can talk effortlessly with your PostgreSQL database.

(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> pip install psycopg2-binary
Enter fullscreen mode Exit fullscreen mode

To wrap up the preliminaries, create a urls.py file in your newly created Django app and link it to your project's urls.py.

# dynamic_settings -> urls.py
...
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('core.urls', namespace='core')), # this line added
]
...
Enter fullscreen mode Exit fullscreen mode

Now to the main deal.

Step 2: Singleton Model

Open up your app's models.py and fill it with the following:

# core -> models.py

from typing import Any

from django.contrib.postgres.fields import ArrayField
from django.db import models


def get_default_vpn_provider() -> list[str]:
    """Return a list of providers."""
    return [gvp[0] for gvp in GenericSettings.VPN_PROVIDERS]


def get_from_email() -> list[str]:
    """Return a list of email addresses."""
    return [gea[0] for gea in GenericSettings.FROM_EMAIL_ADDRESSES]


class GenericSettings(models.Model):
    VPN_PROVIDER_ACCESS = 'Access'
    VPN_PROVIDER_CYBERGHOST = 'CyberGhost'
    VPN_PROVIDER_EXPRESSVPN = 'ExpressVPN'

    VPN_PROVIDERS = [
        (VPN_PROVIDER_ACCESS, 'Access'),
        (VPN_PROVIDER_CYBERGHOST, 'CyberGhost'),
        (VPN_PROVIDER_EXPRESSVPN, 'ExpressVPN'),
    ]

    ADMIN_FROM_EMAIL = 'admin@dynamic_settings.com'
    USER_FROM_EMAIL = 'user@dynamic_settings.com'

    FROM_EMAIL_ADDRESSES = [
        (ADMIN_FROM_EMAIL, 'From email address for admins'),
        (USER_FROM_EMAIL, 'From email address for users'),
    ]

    default_vpn_provider = ArrayField(
        models.CharField(max_length=20), default=get_default_vpn_provider
    )
    default_from_email = ArrayField(
        models.CharField(max_length=50), default=get_from_email
    )

    def save(self, *args, **kwargs):  # type: ignore
        """Save object to the database. All other entries, if any, are removed."""
        self.__class__.objects.exclude(id=self.id).delete()
        super().save(*args, **kwargs)

    def __str__(self) -> str:
        """String representation of the model."""
        return f'GenericSettings for {self.id}'

    @classmethod
    def load(cls) -> Any:
        """Load the model instance."""
        obj, _ = cls.objects.get_or_create(id=1)
        return obj
Enter fullscreen mode Exit fullscreen mode

This model basically has two fields namely, default_vpn_provider and default_from_email, both are ArrayFields of strings. In Python terms, they are simply lists of strings, list[str]. What makes this model a singleton is the save overide method:

def save(self, *args, **kwargs):  # type: ignore
    """Save object to the database. All other entries, if any, are removed."""
    self.__class__.objects.exclude(id=self.id).delete() # This line does the magic
    super().save(*args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

It ensures that only one row is allowed to be saved. Any other ones are deleted. A nifty classmethod, load() was also defined to get or create a model instance whose id is 1. Still in conformity with the above claim. Make migrations and then migrate your models.

Step 3: Test the model

Now to our tests. Open up tests.py file and make it look like the following:

# core -> tests.py

from django.test import TestCase

from core.models import GenericSettings


class ModelGenericSettingsTests(TestCase):
    def setUp(self) -> None:
        """Create the setup of the test."""
        self.generic_settings = GenericSettings.objects.create()

    def test_unicode(self) -> None:
        """Test the representation of the model."""
        self.assertEqual(
            str(self.generic_settings),
            f'GenericSettings for {self.generic_settings.id}',
        )

    def test_first_instance(self) -> None:
        """Test first instance function."""
        self.assertEqual(self.generic_settings.id, 1)

    def test_load(self) -> None:
        """Test the load function."""
        self.assertEqual(GenericSettings.load().id, 1)

    def test_many_instances(self) -> None:
        """Test many instances of the model."""

        def test_for_instance() -> None:
            """Test each instance of the model."""
            new_settings = GenericSettings.objects.create()
            self.assertEqual(
                new_settings.default_vpn_provider,
                ['Access', 'CyberGhost', 'ExpressVPN'],
            )
            self.assertEqual(
                new_settings.default_from_email,
                ['admin@dynamic_settings.com', 'user@dynamic_settings.com'],
            )

        test_for_instance()
        test_for_instance()
        test_for_instance()
        self.assertEqual(GenericSettings.objects.count(), 1)
Enter fullscreen mode Exit fullscreen mode

They ensure our claims are properly tested and validated and the model has 100% coverage. To know our code coverage, install coverage.py and run the tests:

(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> pip install coverage

(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> coverage run  manage.py test core

(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> coverage html
Enter fullscreen mode Exit fullscreen mode

The source code contains some configs that help coverage know the files to exclude from reports. The last command generates an htmlcov/ folder in your root directory. Open it up and locate index.html. View it in the browser. You can click on the files listed and check where you have covered and not covered. For these our tests, we have a 100% code coverage!!! Next, let's implement the view logic of our app.

Step 4: View and API logic

Make your views.py look like this:

# core -> views.py

from django.http.response import JsonResponse
from django.shortcuts import render

from .models import GenericSettings


def index(request):
    """App's entry point."""
    generic_settings = GenericSettings.load()
    context = {
        'generic_settings': generic_settings,
        'vpn_providers': GenericSettings.VPN_PROVIDERS,
        'email_providers': GenericSettings.FROM_EMAIL_ADDRESSES,
    }
    return render(request, 'index.html', context)


def change_settings(request):
    """Route that handles post requests."""
    if request.method == 'POST':
        provider_type = request.POST.get('provider_type')
        if provider_type:
            if provider_type.lower() == 'vpn':
                generic_settings = GenericSettings.load()
                vpn_provider = request.POST.get('default_vpn_provider')
                default_vpn_provider = generic_settings.default_vpn_provider
                # put the selected otp provider at the begining.
                default_vpn_provider.insert(
                    0,
                    default_vpn_provider.pop(default_vpn_provider.index(vpn_provider)),
                )
                generic_settings.save(update_fields=['default_vpn_provider'])

                response = JsonResponse({'success': True})

            elif provider_type.lower() == 'email':
                generic_settings = GenericSettings.load()
                selected_email_provider = request.POST.get('default_from_email')
                default_email_provider = generic_settings.default_from_email
                # put the selected sms provider at the begining.
                default_email_provider.insert(
                    0,
                    default_email_provider.pop(
                        default_email_provider.index(selected_email_provider)
                    ),
                )
                generic_settings.save(update_fields=['default_from_email'])

                response = JsonResponse({'success': True})

            return response

        return JsonResponse({'success': False})
    return JsonResponse({'success': False})

Enter fullscreen mode Exit fullscreen mode

They're pretty simple views. The first, index, just loads our index.html file and make available the context values defined. As for change_settings, it does exactly what its name implies — change the settings variable. It returns JsonResponse, setting success to be either True or False. Though lame or HTTP status codes should have been returned instead. Add these views to your app's urls.py:

# core -> urls.py

from django.urls import path

from . import views

app_name = 'core'

urlpatterns = [
    path('', views.index, name='index'),
    path('change/', views.change_settings, name='change_settings'),
]
Enter fullscreen mode Exit fullscreen mode

It's time to test them again:

# core -> tests.py
from django.test import Client, TestCase
from django.urls import reverse

class IndexTest(TestCase):
    def setUp(self) -> None:
        self.client = Client()

    def test_context(self) -> None:
        response = self.client.get(reverse('core:index'))
        self.assertEqual(response.context['generic_settings'], GenericSettings.load())
        self.assertEqual(response.templates[0].name, 'index.html')


class ChangeTestingTest(TestCase):
    def setUp(self) -> None:
        self.client = Client()
        self.data_vpn = {'provider_type': 'vpn', 'default_vpn_provider': 'CyberGhost'}
        self.data_email = {
            'provider_type': 'email',
            'default_from_email': 'user@dynamic_settings.com',
        }

    def test_get(self) -> None:
        response = self.client.get(reverse('core:change_settings'))
        self.assertEqual(response.json()['success'], False)

    def test_post_without_data(self) -> None:
        response = self.client.post(reverse('core:change_settings'))
        self.assertEqual(response.json()['success'], False)

    def test_post_with_vpn_data(self) -> None:
        response = self.client.post(
            reverse('core:change_settings'), self.data_vpn, format='json'
        )
        self.assertEqual(response.json()['success'], True)

    def test_post_with_email_data(self) -> None:
        response = self.client.post(
            reverse('core:change_settings'), self.data_email, format='json'
        )
        self.assertEqual(response.json()['success'], True)
Enter fullscreen mode Exit fullscreen mode

Step 5: Provide an interface and JavaScript client

For this step, just create an index.html file in your templates directory. Link boostrap, and jQuery CDNs. Just make the file look like this:

<!-- templates -> index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Dynamic Settings Variable</title>
    <!-- CSS only -->
    <link
      href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
      rel="stylesheet"
      integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
      crossorigin="anonymous"
    />
  </head>
  <body>
    <div class="content">
      <div class="row justify-content-center mt-5">
        <div class="col-md-10 grid-margin stretch-card">
          <div class="card">
            <div class="card-header">
              <h4>Current Prioritized providers</h4>
            </div>
            <div class="card-body">
              <div class="d-flex">
                <div class="input-group flex-nowrap">
                  <span class="input-group-text" id="addon-wrapping">VPN</span>
                  {% for p in generic_settings.default_vpn_provider %}
                  <button
                    type="button"
                    class="btn btn-{% if forloop.first %}success{% else %}danger{% endif %}"
                    disabled
                  >
                    {{p|capfirst}}
                  </button>
                  {% endfor %}
                </div>

                <div class="input-group flex-nowrap">
                  <span class="input-group-text" id="addon-wrapping"
                    >Email</span
                  >
                  {% for p in generic_settings.default_from_email %}
                  <button
                    type="button"
                    class="btn btn-{% if forloop.first %}success{% else %}danger{% endif %}"
                    disabled
                  >
                    {{p}}
                  </button>
                  {% endfor %}
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
      <div class="row justify-content-center mt-4">
        {% csrf_token %}
        <div class="col-md-5 grid-margin stretch-card">
          <div class="card">
            <div class="card-header">
              <h4>Change VPN Provider</h4>
            </div>
            <div class="card-body">
              <div class="form-row">
                <div class="col-md-10 mb-3">
                  <label for="vpnProvider">Select VPN Provider</label>
                  <select class="form-select mb-3" id="vpnProvider">
                    {% for provider in vpn_providers %}
                    <option value="{{ provider.0 }}"
                  {% if generic_settings.default_vpn_provider.0 == provider.0 %}selected{% endif %}>
                    {{ provider.1 }}
                  </option>
                    {% endfor %}
                  </select>
                </div>
              </div>
            </div>
          </div>
        </div>
        <div class="col-md-5 grid-margin stretch-card">
          <div class="card">
            <div class="card-header">
              <h4>Change Email Provider</h4>
            </div>
            <div class="card-body">

              <div class="form-row">
                <div class="col-md-10 mb-3">
                  <label for="emailProvider">Select Email Provider</label>
                  <select class="form-select mb-3" id="emailProvider">
                    {% for provider in email_providers %}
                    <option value="{{ provider.0 }}"
            {% if generic_settings.default_from_email.0 == provider.0 %}selected{% endif %}>
                      {{ provider.1 }}
                    </option>
                    {% endfor %}
                  </select>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>

    <!-- JavaScript Bundle with Popper -->
    <script
      src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
      integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
      crossorigin="anonymous"
    ></script>

    <script>
      'use strict';
      const csrftoken = $('[name=csrfmiddlewaretoken]').val();
      if (csrftoken) {
        function csrfSafeMethod(method) {
          // these HTTP methods do not require CSRF protection
          return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method);
        }
        $.ajaxSetup({
          beforeSend: function (xhr, settings) {
            if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
              xhr.setRequestHeader('X-CSRFToken', csrftoken);
            }
          },
        });
      }

      const changeProvidersPriority = (
        providerSelector,
        providerModelField,
        providerType,
        providerTypeText
      ) => {
        providerSelector.addEventListener('change', (e) => {
          e.preventDefault();
          if (
            !confirm(
              `Are you sure you want to change ${providerTypeText} Providers priority?`
            )
          ) {
            return;
          }
          const data = new FormData();
          data.append(providerModelField, e.target.value);
          data.append('provider_type', providerType);
          $.ajax({
            url: "{% url 'core:change_settings' %}",
            method: 'POST',
            data: data,
            dataType: 'json',
            success: function (response) {
              if (response.success) {
                alert(
                  `${providerTypeText} Providers priority changed successfully.`
                );
                window.location.href = location.href;
              }
            },
            error: function (error) {
              console.error(error);
            },
            cache: false,
            processData: false,
            contentType: false,
          });
        });
      };

      const vpnProviderSelect = document.getElementById('vpnProvider');
      const emailProviderSelect = document.getElementById('emailProvider');

      changeProvidersPriority(
        vpnProviderSelect,
        'default_vpn_provider',
        'VPN',
        'VPN'
      );

      changeProvidersPriority(
        emailProviderSelect,
        'default_from_email',
        'Email',
        'Email address'
      );
    </script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Nothing much here. Just a bunch of HTML and some JavaScripts. If you're bottered about them, checkout my previous articles. They sure will help you.

Waoh... What a long ride?!! I hope it was worth it though.

Outro

Enjoyed this article? Consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn.

Top comments (0)