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.
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:
Sirneij / django_dynamic_global_settings
A simple demonstration of changing django global settings dynamically at runtime without server restart
dynamic_settings
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
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
Then, migrate
the database:
(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> python manage.py migrate
Thereafter, run the project:
(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> python manage.py run
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
]
...
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 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})
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.
Top comments (0)