Making Django Global Settings Dynamic: The Singleton Design Pattern
Tags: webdev, tutorial, django, javascript
Published: 2022-05-06 00:22:08


## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/wrqzscoqsf3ge1bqs2x5.png)

## 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][1], [2][2], and [3][3] among others. I also came across Django packages such as [constance][4] 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`][5], `typing` built-in module, and [PEP8][6].

- 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][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 &mdash; Bootstrap for this project) knowledge is needed.

## Source code

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

{% github Sirneij/django_dynamic_global_settings %}

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`:

```python
# 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.

```python
# 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.

```bash
(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`.

```python
# 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:

```python
# 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:

```python
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:

```python
# 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:

```bash
(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 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:

```python
# 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 &mdash; 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`:

```python
# 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:

```python
# 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:

```html
<!-- 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 ☕](mailto:sirneij@gmail.com). You can also connect with/follow me on [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/).

[1]: https://steelkiwi.com/blog/practical-application-singleton-design-pattern/ 'Singleton Design Pattern Example: Singleton Models in Django'
[2]: https://stackoverflow.com/questions/49735906/how-to-implement-singleton-in-django 'How to implement Singleton in Django'
[3]: https://engineertodeveloper.com/singleton-design-pattern-in-django/ 'How to Implement the Singleton Design Pattern in Django'
[4]: https://django-constance.readthedocs.io/en/latest/ 'Constance - Dynamic Django settings'
[5]: http://mypy-lang.org/ 'mypy'
[6]: https://peps.python.org/pep-0008/ 'PEP 8 – Style Guide for Python Code'