Making Django Global Settings Dynamic: The Singleton Design Pattern

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 occurren…


This content originally appeared on DEV Community and was authored by John Idogun

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




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
]
...

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,
    },
}
...

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

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
]
...

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

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)

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)

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

The source code contains some configs that help coverage know the files to exclude from reports. The last command generates a htmlcov/ folder in your root directory. Open it up and locate index.html. View it in the browser. Your 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})

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'),
]

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)

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>

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.


This content originally appeared on DEV Community and was authored by John Idogun


Print Share Comment Cite Upload Translate Updates
APA

John Idogun | Sciencx (2022-05-06T00:22:08+00:00) Making Django Global Settings Dynamic: The Singleton Design Pattern. Retrieved from https://www.scien.cx/2022/05/06/making-django-global-settings-dynamic-the-singleton-design-pattern/

MLA
" » Making Django Global Settings Dynamic: The Singleton Design Pattern." John Idogun | Sciencx - Friday May 6, 2022, https://www.scien.cx/2022/05/06/making-django-global-settings-dynamic-the-singleton-design-pattern/
HARVARD
John Idogun | Sciencx Friday May 6, 2022 » Making Django Global Settings Dynamic: The Singleton Design Pattern., viewed ,<https://www.scien.cx/2022/05/06/making-django-global-settings-dynamic-the-singleton-design-pattern/>
VANCOUVER
John Idogun | Sciencx - » Making Django Global Settings Dynamic: The Singleton Design Pattern. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2022/05/06/making-django-global-settings-dynamic-the-singleton-design-pattern/
CHICAGO
" » Making Django Global Settings Dynamic: The Singleton Design Pattern." John Idogun | Sciencx - Accessed . https://www.scien.cx/2022/05/06/making-django-global-settings-dynamic-the-singleton-design-pattern/
IEEE
" » Making Django Global Settings Dynamic: The Singleton Design Pattern." John Idogun | Sciencx [Online]. Available: https://www.scien.cx/2022/05/06/making-django-global-settings-dynamic-the-singleton-design-pattern/. [Accessed: ]
rf:citation
» Making Django Global Settings Dynamic: The Singleton Design Pattern | John Idogun | Sciencx | https://www.scien.cx/2022/05/06/making-django-global-settings-dynamic-the-singleton-design-pattern/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.