- Обновление
- Мотивация
- Предположения
- Исходный код
- Sirneij / django_dynamic_global_settings
- Простая демонстрация динамического изменения глобальных настроек django во время выполнения без перезапуска сервера
- dynamic_settings
- Запуск локально
- Реализация
- Шаг 1: Предварительные шаги
- Шаг 2: Синглтонная модель
- Шаг 3: Тестирование модели
- Шаг 4: Логика представления и API
- Шаг 5: Предоставьте интерфейс и клиент JavaScript
- Outro
Обновление
Пользовательский интерфейс был обновлен, чтобы включить сортируемый список, используя функцию HTML5 drag-and-drop с некоторым количеством JavaScript. В результате были изменены коды в core/views.py
и, как следствие, core/tests.py
. Все эти изменения доступны в репозитории GitHub этой статьи.
Мотивация
Некоторое время назад я работал над задачей, в которой мне нужно было сделать некоторые переменные глобальных настроек Django динамическими. С условием, что сохранение данных важно и что сохраняемые данные настройки не должны иметь более одного появления в приложении. Эти переменные настройки должны сопровождаться интерфейсом, где их значения могут быть изменены/обновлены динамически, и обновленные значения должны быть немедленно доступны другим модулям, требующим их использования. После нескольких исследований или гугления, я нашел 1, 2 и 3 среди прочих. Я также наткнулся на пакеты Django, такие как constance и другие, которые помогают сделать настройки Django динамическими. Затем эти настройки можно обновить через интерфейс администратора Django. Использование этих пакетов было излишним для моего случая использования, и мне также нужна была большая гибкость и контроль над их реализацией, чтобы иметь 100%
покрытие тестирования кода. Поэтому я решил развернуть свою реализацию, опираясь на эти блог-посты и пакеты.
Предположения
-
Предполагается, что читатели хорошо знакомы с Django и JavaScript, а также с типизированным расширением Python с помощью
mypy
, встроенного модуляtyping
и PEP8. -
Вы также должны быть знакомы с написанием тестов для моделей, методов, представлений и функций Django. Однако я не имел в виду, что вы должны быть воинственно настроены на это.
-
Я также предполагал, что вы прошли, по крайней мере, через эту статью блога, 1, чтобы лучше познакомиться с обсуждаемым паттерном и решаемой формальной проблемой.
-
И, конечно, необходимы знания HTML и CSS (и его фреймворков – Bootstrap для данного проекта).
Исходный код
Весь исходный код этой статьи доступен по ссылке:
Sirneij / django_dynamic_global_settings
Простая демонстрация динамического изменения глобальных настроек django во время выполнения без перезапуска сервера
dynamic_settings
Этот репозиторий сопровождает данный учебник на dev.to. Он был развернут на heroku и может быть доступен в реальном времени по этой ссылке.
Запуск локально
Его можно запустить локально, предварительно отредактировав dynamic_settings/settings.py
, чтобы отразить конфигурацию базы данных PostgreSQL, или создайте файл .env
в корневом каталоге и поместите в него следующее:
DB_NAME=your database name DB_USER=your database user's username DB_PASSWORD=your database password
Затем создайте виртуальную среду, используя любой из venv
, poetry
, virtualenv
и pipenv
. При разработке приложения я использовал virtualenv
. Создав виртуальную среду, активируйте ее и установите зависимости проекта, выполнив в терминале следующую команду:
(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> pip install -r requirements.txt
Затем выполните миграцию
базы данных:
(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> python manage.py migrate
После этого запустите проект:
(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> python manage.py run
После этого приложение работает и может быть доступно через https://dynamic-settings.herokuapp.com/ .
Реализация
Шаг 1: Предварительные шаги
Убедитесь, что вы активировали виртуальную среду, установили Django, создали проект Django с подходящим именем (я назвал свой dynamic_settings
) и приступили к созданию приложения Django. С моей стороны, имя моего приложения core
. Откройте файл settings.py
и добавьте только что созданное приложение в 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
]
...
Также, используя эту возможность, настройте каталог templates
и измените вашу базу данных на PostgreSQL
. PostgreSQL был выбран потому, что мне нужно было использовать его специальный ArrayField
в определении нашей модели.
# 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,
},
}
...
Из-за этого вам нужно установить psycopg2-binary
, чтобы Django мог легко общаться с вашей базой данных PostgreSQL.
(env) sirneij@pop-os ~/D/P/T/dynamic_settings (main)> pip install psycopg2-binary
Чтобы завершить предварительную подготовку, создайте файл urls.py
в вашем только что созданном приложении Django и свяжите его с файлом urls.py
вашего проекта.
# dynamic_settings -> urls.py
...
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('core.urls', namespace='core')), # this line added
]
...
Теперь перейдем к основному шагу.
Шаг 2: Синглтонная модель
Откройте models.py
вашего приложения и заполните его следующим образом:
# 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
Эта модель в основном имеет два поля, а именно, default_vpn_provider
и default_from_email
, оба являются ArrayFields
строками. В терминах Python это просто списки строк, list[str]
. Что делает эту модель синглтоном, так это метод save
overide:
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)
Он гарантирует, что только одна строка может быть сохранена. Все остальные удаляются. Также был определен удобный classmethod
, load()
для получения или создания экземпляра модели, чей id
равен 1
. Все еще в соответствии с вышеприведенным утверждением. Сделайте миграции, а затем мигрируйте свои модели.
Шаг 3: Тестирование модели
Теперь перейдем к нашим тестам. Откройте файл tests.py
и приведите его в следующий вид:
# 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)
Они гарантируют, что наши утверждения правильно протестированы и подтверждены, а модель имеет 100%
покрытие. Чтобы узнать покрытие нашего кода, установите coverage.py
и запустите тесты:
(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
Исходный код содержит некоторые конфигурации, которые помогают coverage
узнать, какие файлы следует исключить из отчетов. Последняя команда создает папку htmlcov/
в вашем корневом каталоге. Откройте ее и найдите index.html
. Просмотрите его в браузере. Вы можете щелкнуть по перечисленным файлам и проверить, где вы прошли, а где нет. Для этих тестов у нас 100%
покрытие кода!!! Далее, давайте реализуем логику представления нашего приложения.
Шаг 4: Логика представления и API
Пусть ваш views.py
выглядит следующим образом:
# 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})
Это довольно простые представления. Первое, index
, просто загружает наш файл index.html
и делает доступными определенные контекстные значения. Что касается change_settings
, то оно делает именно то, что следует из его названия – изменяет переменную settings. Она возвращает JsonResponse
, устанавливая success
либо True
, либо False
. Вместо этого должны были возвращаться коды статуса lame или HTTP. Добавьте эти представления в 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'),
]
Пришло время протестировать их снова:
# 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)
Шаг 5: Предоставьте интерфейс и клиент JavaScript
Для этого шага просто создайте файл index.html
в вашей директории templates
. Свяжите boostrap
, и jQuery
CDN. Пусть файл выглядит следующим образом:
<!-- 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>
Здесь нет ничего особенного. Просто куча HTML и несколько JavaScripts. Если они вас беспокоят, посмотрите мои предыдущие статьи. Они точно помогут вам.
Ух ты… Какая долгая поездка?! Надеюсь, оно того стоило.
Outro
Понравилась эта статья? Можете связаться со мной по поводу работы, чего-то стоящего или угостить кофе ☕. Вы также можете связаться со мной или следить за мной на LinkedIn.