Создание динамических глобальных настроек Django: Паттерн проектирования Singleton


Обновление

Пользовательский интерфейс был обновлен, чтобы включить сортируемый список, используя функцию 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
Войти в полноэкранный режим Выйти из полноэкранного режима

Просмотр на GitHub

После этого приложение работает и может быть доступно через 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.

Оцените статью
Procodings.ru
Добавить комментарий