Создание многопользовательского приложения с помощью Django

В этом руководстве объясняется, как реализовать веб-приложение с несколькими пользователями в Django, используя django-tenants и django-tenants-users пакеты, помогающие ответить на вопросы:

  • Как создать мультитенантное веб-приложение с помощью Django?
  • Как поддерживать несколько клиентов в проекте Django?

Описанное решение идеально подходит для проектов среднего и крупного размера "Программное обеспечение как услуга" (SaaS).

Содержимое

Цели

К концу этого урока вы сможете:

  1. Объясните различные стратегии архитектуры с несколькими арендаторами
  2. Преобразуйте однопользовательский проект Django в многопользовательский
  3. Установка и конфигурирование пакета django-tenants и django-tenant-users
  4. Управление арендаторами и пользователями-арендаторами (предоставление арендаторов, удаление арендаторов, назначение пользователей и т.д.)
  5. Работа с мультитенантными проектами Django

Мультитенантные подходы

Мультитенантные приложения предназначены для использования несколькими клиентами. Каждый пользователь называется клиентом и обычно представляет группу пользователей, например организацию.

Вообще говоря, существует три подхода к такому:

  • Изолированный подход: у каждого клиента есть своя собственная база данных и собственные экземпляры приложений
  • Полуизолированный подход: экземпляры базы данных и приложения являются общими для всех клиентов; ограничения устанавливаются на уровне базы данных - например, с помощью схем базы данных
  • Общий подход: экземпляры базы данных и приложения являются общими для всех клиентов; нет ограничений на уровне базы данных; предоставление в аренду осуществляется с помощью внешних ключей

Изолированный подход

Изолированный подход имеет смысл, если вам нужна высокая степень изолированности между арендаторами и вы не планируете привлекать многих из них. Для большинства проектов такой подход является излишним, поскольку довольно сложно обеспечить и отменить предоставление услуг арендаторам. Кроме того, стоимость облачных ресурсов, как правило, выше по сравнению с альтернативными вариантами.

Если вы выберете этот подход, вам не нужно будет изменять свой код на Django. Вместо этого большая часть работы будет перенесена на настройку вашей инфраструктуры. Каждый раз, когда вы подключаете клиента, вам нужно создавать новую базу данных и экземпляр приложения. В большинстве изолированных установок используется виртуализация или контейнеризация.

Общий подход

Общий подход подходит для небольших проектов. Тем не менее, он может значительно усложнить работу, поскольку для каждой модели требуются внешние ключи. Вам также необходимо убедиться, что вы случайно не допустили утечки данных других клиентов.

С каждой моделью, которая может принадлежать арендатору, вам нужно будет сделать что-то вроде этого:

class Project(models.Model):
    name = models.CharField(max_length=255)
    description = models.TextField(blank=True, null=True)

    # an extra field that determines to which tenant the model instance belongs
    tenant = models.ForeignKey(to="tenants.Tenant", on_delete=models.CASCADE)

Оптимально, если вы захотите создать абстрактную модель для этого, например, TenantOwnableModel, и переопределить модель менеджера get_queryset() для автоматической фильтрации в зависимости от клиента.

Для этого подхода вы можете использовать пакет django-multitenant. В качестве альтернативы, если вы хотите реализовать функциональность клиента самостоятельно и просто избежать утечки данных других клиентов, есть django-scopes.

Полуизолированный подход

В большинстве случаев вам лучше всего использовать полуизолированный подход, что мы и делаем в этом руководстве. Этот подход обеспечивает баланс между изолированным и общим подходами. Django изначально не поддерживает это, но вы можете использовать такие пакеты, как django-tenants и django-tenant-users, чтобы заставить его работать.

Давайте сначала объясним, как работают эти два пакета, а затем перейдем к практическому руководству.

Что такое django-tenants?

django-tenants - это простой в использовании пакет, который позволяет проектам Django поддерживать несколько клиентов. Это позволяет преобразовать любой однопользовательский проект в многопользовательский без существенного изменения кода. По своей сути, он использует Схемы PostgreSQL.

Схемы PostgreSQL - это логические контейнеры, в которых хранятся объекты базы данных, такие как таблицы, представления, функции и индексы. Иерархически схемы находятся ниже баз данных, что означает, что база данных может содержать несколько схем.

PostgreSQL Hierarchy

В django-tenants каждому клиенту присваивается своя схема и доменное имя (обычно это поддомен). Например, demo1.duplxey.com указывает на схему demo1, demo2.duplxey.com указывает на demo2 и так далее.

По мере поступления запросов, TenantMainMiddleware проверяет доменное имя, сопоставляет его с именем арендатора и вводит имя арендатора в запрос. Это также переключает активную схему PostgreSQL, что означает, что возвращаются только запрошенные данные клиента.

Django Tenants Routing

Кроме того, пакет предоставляет общедоступную схему, которую можно использовать для кросс-клиентских приложений и данных, таких как локализация, аутентификация и биллинговые системы.

Что такое django-tenant-users?

django-tenant-users - это пакет, который создается поверх django-tenants. Он предоставляет мультитенантным проектам Django:

  • Управление пользователями клиента
  • Глобальная аутентификация с разрешениями, специфичными для клиента
  • Отдельные разрешения для схемы

Объединение обоих пакетов позволяет создать готовый к работе мультитенантный проект.

Введение в проект

В этом руководстве мы будем работать над веб-приложением для управления проектами, похожим на Jira. Каждый клиент сможет создавать свои проекты и управлять ими, а также добавлять к ним задачи. Кроме того, веб-приложение будет поддерживать общедоступный блог в маркетинговых целях.

Чтобы сэкономить ваше время, я собрал примерный проект, который мы будем использовать в качестве отправной точки. В настоящее время он поддерживает только одного клиента, однако к концу этого руководства мы преобразуем его в мультитенантный проект.

Начните с клонирования base ветви django-multi-tenant репозитория:

$ git clone https://github.com/duplxey/django-multi-tenant.git \
    --single-branch --branch base && cd django-multi-tenant

Далее создайте новую виртуальную среду и активируйте ее:

$ python3 -m venv venv && source venv/bin/activate

Установите требования:

(venv)$ pip install -r requirements.txt

Прежде чем двигаться дальше, пожалуйста, уделите несколько минут краткому просмотру исходного кода.

Затем используйте локально установленный PostgreSQL или запустите его с помощью Docker:

$ docker run --name sprinty-postgres -p 5432:5432 \
    -e POSTGRES_USER=sprinty -e POSTGRES_PASSWORD=complexpassword123 \
    -e POSTGRES_DB=sprinty -d postgres

Перенос базы данных:

(venv)$ python manage.py migrate

Наконец, запустите сервер разработки:

(venv)$ python manage.py runserver

Ваше веб-приложение должно быть доступно по адресу http://localhost:8000/api/. Вы увидите три маршрута API: /api/blog/, /api/projects/, и /api/tasks/. Каждый из них поддерживает базовые операции CRUD.

django-tenants

В этом разделе мы установим и настроим django-tenants.

Пользовательское приложение

Перед установкой django-tenants создайте новое приложение Django с именем tenants:

(venv)$ python manage.py startapp tenants

Это приложение будет содержать наши пользовательские модели, связанные с пользователями и арендаторами, которые мы создадим позже.

Далее зарегистрируйте его в core/settings.py:

# core/settings.py

INSTALLED_APPS = [
    # ...
    "blog.apps.BlogConfig",
    "tasks.apps.TasksConfig",
    "tenants.apps.TenantsConfig",  # new
]

Установка

Начните с установки django-tenants через pip:

(venv)$ pip install django-tenants==3.7.0

Добавить django-tenants в начало INSTALLED_APPS:

# core/settings.py

INSTALLED_APPS = [
    "django_tenants",  # new, must be first
    "django.contrib.admin",
    "django.contrib.auth",
    # ...
]

Если вы не добавите пакет в начало списка, позже у вас могут возникнуть проблемы. Например, Раскраска сайта администратора Django не будет работать должным образом.

Затем добавьте TenantMainMiddleware в начало списка MIDDLEWARE:

# core/settings.py

MIDDLEWARE = [
    "django_tenants.middleware.main.TenantMainMiddleware",  # new, must be first
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    # ...
]

Это промежуточное программное обеспечение гарантирует, что правильная схема базы данных будет выбрана на основе узла запроса. Кроме того, он вводит экземпляр tenant в request, так что вы можете получить к нему доступ непосредственно в своих представлениях.

Переключите компонент database engine и установите параметр DATABASE_ROUTERS:

# core/settings.py

DATABASES = {
    "default": {
        "ENGINE": "django_tenants.postgresql_backend",  # changed
        "NAME": "sprinty",
        "USER": "sprinty",
        "PASSWORD": "complexpassword123",
        "HOST": "localhost",
        "PORT": "5432",
    }
}

# new
DATABASE_ROUTERS = [
    "django_tenants.routers.TenantSyncRouter",
]

Позже, когда мы разделим наш INSTALLED_APPS на TENANT_APPS и SHARED_APPS, этот маршрутизатор обеспечит правильную синхронизацию общедоступных приложений и приложений, доступных только для клиентов, с базой данных.

Наконец, определите параметры BASE_DOMAIN и PUBLIC_SCHEMA_NAME:

# core/settings.py

BASE_DOMAIN = "localhost"
PUBLIC_SCHEMA_NAME = "public"

Примечания:

  1. BASE_DOMAIN это пользовательская настройка, которую мы будем использовать для заполнения базы данных.
  2. PUBLIC_SCHEMA_NAME определяет имя общедоступной схемы PostgreSQL.

Пользователь, клиент и домен

Продвигаясь вперед, давайте определим пользовательские модели, связанные с арендаторами:

# tenants/models.py

from django.contrib.auth.models import AbstractUser
from django.db import models
from django_tenants.models import TenantMixin, DomainMixin

from core.models import TimeStampedModel


class User(AbstractUser):
    pass


class Tenant(TenantMixin, TimeStampedModel):
    name = models.CharField(max_length=100)


class Domain(DomainMixin, TimeStampedModel):
    pass

Примечания:

  1. User это пользовательская модель пользователя, которая наследуется от AbstractUser в Django. Определение пользовательского пользователя не является обязательным. Тем не менее, это хорошая идея, поскольку большинство мультитенантных проектов Django требуют модификации пользовательской модели.
  2. Tenant должен наследоваться от TenantMixin, который предоставляет методы управления арендаторами.
  3. Domain должен наследоваться от DomainMixin. Эта модель будет иметь отношение 1:1 к вашей модели арендатора. Это предоставит арендаторам их доменные имена.

Зарегистрируйте их в tenants/admin.py вот так:

# tenants/admin.py

from django.contrib import admin
from django_tenants.admin import TenantAdminMixin

from tenants.models import Tenant, Domain, User


class TenantAdmin(TenantAdminMixin, admin.ModelAdmin):
    list_display = ["schema_name", "name", "created_at", "updated_at"]


class DomainAdmin(admin.ModelAdmin):
    list_display = ["domain", "tenant", "is_primary", "created_at", "updated_at"]


class UserAdmin(admin.ModelAdmin):
    list_display = ["id", "email", "is_active"]
    list_display_links = ["id", "email"]
    search_fields = ["email"]
    fieldsets = [
        (
            None,
            {
                "fields": [
                    "email",
                    "password",
                ],
            },
        ),
        (
            "Administrative",
            {
                "fields": [
                    "tenants",
                    "last_login",
                    "is_active",
                    "is_verified",
                ],
            },
        ),
    ]


admin.site.register(Tenant, TenantAdmin)
admin.site.register(Domain, DomainAdmin)
admin.site.register(User, UserAdmin)

Наконец, укажите пути к только что созданным моделям в core/settings.py:

# core/settings.py

AUTH_USER_MODEL = "tenants.User"
TENANT_MODEL = "tenants.Tenant"
TENANT_DOMAIN_MODEL = "tenants.Domain"

Общие и клиентские приложения

django-tenants предоставляет две настройки, связанные с приложением:

  1. SHARED_APPS это список приложений, общих для всех клиентов. Их SQL-таблицы создаются в схеме public.
  2. TENANT_APPS это список приложений, выделенных для каждого клиента. Их SQL-таблицы создаются в схеме каждого клиента (например, demo1, demo2).

Давайте разделим INSTALLED_APPS соответственно:

# core/settings.py

SHARED_APPS = [
    "django_tenants",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "django_filters",
    "tenants.apps.TenantsConfig",
    "blog.apps.BlogConfig",
]

TENANT_APPS = [
    "tasks.apps.TasksConfig",
]

INSTALLED_APPS = list(SHARED_APPS) + [
    app for app in TENANT_APPS if app not in SHARED_APPS
]

В приведенной выше конфигурации мы указываем, что все приложения, кроме tasks, должны быть синхронизированы с общедоступной схемой. Это означает, что данные блога будут доступны всем арендаторам, в то время как tasks будут оставаться изолированными внутри каждого арендатора.

Общедоступные URL-адреса и URL-адреса клиентов

В настоящее время мы используем один и тот же urls.py файл как для общедоступных, так и для клиентских схем.

Это нехорошо, потому что приложение tasks больше не зарегистрировано в схеме public, а это означает, что мы столкнемся с ошибками при нажатии, например, http://localhost:8000/api/tasks/.

# core/urls.py

# ...
from blog.views import ArticleViewSet
from core.views import index_view
from tasks.views import ProjectViewSet, TaskViewSet

router = DefaultRouter()
router.register("blog", ArticleViewSet)
router.register("projects", ProjectViewSet)
router.register("tasks", TaskViewSet)  # <- this line will fail, because
                                       #    `tasks` DB tables aren't created
                                       #    on the `public` schema
# ...

Чтобы исправить это, мы можем отделить общедоступные URL-адреса от URL-адресов клиентов.

Создайте файл с именем urls_public.py в основном приложении:

# core/urls_public.py

"""
URLs for the 'public' schema.
"""

from django.contrib import admin
from django.urls import include, path
from rest_framework.routers import DefaultRouter

from blog.views import ArticleViewSet
from core.views import index_view

router = DefaultRouter()
router.register("blog", ArticleViewSet)

urlpatterns = [
    path("", index_view, name="index"),
    path("api/", include(router.urls)),
    path("admin/", admin.site.urls),
]

Затем зарегистрируйте конфигурацию публичного URL-адреса, указанную ниже ROOT_URLCONF:

# core/settings.py

ROOT_URLCONF = "core.urls"
PUBLIC_SCHEMA_URLCONF = "core.urls_public"

Предоставление арендаторам

Существует несколько способов предоставления услуг арендаторам:

  1. С помощью Команд управления django-tenants.
  2. Использование оболочки Django и запуск пользовательского кода.
  3. С помощью пользовательской команды populate_db.py .

Если вам нужно создать одного арендатора, то, как правило, лучше всего использовать первые два варианта. Однако, поскольку мы хотим создать несколько арендаторов, мы создадим команду "заполнить".

Давайте создадим трех арендаторов: public (общедоступный), demo1, и demo2.

Сначала создайте следующую структуру непосредственно в вашем приложении для арендаторов:

tenants/
├── data/
│   └── tenants.json
├── management/
│   ├── __init__.py
│   └── commands/
│       ├── __init__.py
│       └── populate_db.py
└── ...

Поместите следующее содержимое в файл tenants.json:

// tenants/data/tenants.json

[
  {
    "id": 1,
    "name": "Public Tenant",
    "schema_name": "public",
    "subdomain": "",
    "owner": {
      "username": "admin@localhost",
      "email": "admin@localhost",
      "password": "password"
    }
  },
  {
    "id": 2,
    "name": "Demo1 Tenant",
    "schema_name": "demo1",
    "subdomain": "demo1",
    "owner": {
      "username": "[email protected]",
      "email": "[email protected]",
      "password": "password"
    }
  },
  {
    "id": 3,
    "name": "Demo2 Tenant",
    "schema_name": "demo2",
    "subdomain": "demo2",
    "owner": {
      "username": "[email protected]",
      "email": "[email protected]",
      "password": "password"
    }
  }
]

Затем добавьте к следующееpopulate_db.py:

# tenants/management/commands/populate_db.py

import json

from psycopg2 import connect, sql
from django.core.management import BaseCommand, call_command

from core import settings
from tenants.models import Tenant, Domain, User


class Command(BaseCommand):
    help = "Creates a public tenant and two demo tenants"
    tenants_data_file = "tenants/data/tenants.json"

    def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False):
        super().__init__(stdout, stderr, no_color, force_color)

        # Load the tenant data from JSON
        self.tenants_data = []
        with open(self.tenants_data_file, "r") as file:
            self.tenants_data = json.load(file)

    def handle(self, *args, **kwargs):
        self.drop_and_recreate_db()

        call_command("migrate")
        self.create_tenants()

        self.stdout.write(
            self.style.SUCCESS("Yay, database has been populated successfully.")
        )

    def drop_and_recreate_db(self):
        db = settings.DATABASES["default"]
        db_name = db["NAME"]

        # Create a connection to the database
        conn = connect(
            dbname="postgres",
            user=db["USER"],
            password=db["PASSWORD"],
            host=db["HOST"],
            port=db["PORT"],
        )
        conn.autocommit = True
        cur = conn.cursor()

        # Terminate all connections to the database except the current one
        cur.execute(
            """
            SELECT pg_terminate_backend(pid)
            FROM pg_stat_activity
            WHERE datname = %s
              AND pid <> pg_backend_pid();
            """,
            [db_name],
        )

        # Drop the database if it exists and create a new one
        cur.execute(
            sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name))
        )
        cur.execute(
            sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_name))
        )

        cur.close()
        conn.close()

    def create_tenants(self):
        for tenant_data in self.tenants_data:
            # Create the tenant
            tenant = Tenant(
                id=tenant_data["id"],
                name=tenant_data["name"],
                schema_name=tenant_data["schema_name"],
            )
            tenant.save()

            # Build the full domain name
            domain_str = settings.BASE_DOMAIN
            if tenant_data["subdomain"]:
                domain_str = f"{tenant_data['subdomain']}.{settings.BASE_DOMAIN}"

            # Create the domain
            domain = Domain(
                domain=domain_str,
                is_primary=tenant_data["schema_name"] == settings.PUBLIC_SCHEMA_NAME,
                tenant=tenant,
            )
            domain.save()

            # Create the tenant owner
            tenant_owner = User.objects.create_superuser(
                username=tenant_data["owner"]["username"],
                email=tenant_data["owner"]["email"],
                password=tenant_data["owner"]["password"],
            )

Примечания:

  1. Эта команда сначала загружает данные tenants.json.
  2. Затем он удаляет, воссоздает и переносит базу данных.
  3. После этого он создает экземпляр Tenant и экземпляр Domain для каждого из арендаторов.
  4. Наконец, для каждого арендатора создается "владелец арендатора".

Наконец, выполните перенос и заполните базу данных:

(venv)$ python manage.py makemigrations
(venv)$ python manage.py populate_db

Ого, мы успешно установили и настроили django-арендаторов.

Использование

Мы создали трех арендаторов, каждый на своем поддомене:

  1. public  localhost:8000
  2. demo1 demo1.localhost:8000
  3. demo2 demo2.localhost:8000

Кроме того, у каждого клиента есть учетная запись суперпользователя.

Войдите в http://demo1.localhost:8000/admin , используя следующие учетные данные:

user: admin@demo1.localhost
pass: password

Чтобы войти в систему других пользователей, измените URL-адрес и имя пользователя соответствующим образом.

Вы заметите, что приложения Django теперь окрашены в разные цвета. Синий цвет используется для общедоступных приложений, а зеленый - для приложений, доступных только для клиентов.

Django Tenants Admin Panel

Создайте несколько статей, один-два проекта и добавьте несколько задач.

Если вы перейдете по следующим URL-адресам, то заметите, что статьи доступны всем пользователям:

Напротив, проекты и задачи изолированы на уровне клиента:

Поскольку мы определили PUBLIC_SCHEMA_URLCONF, следующий URL-адрес должен привести к 404:

django-tenant-users

Как упоминалось ранее, django-tenants не обеспечивает управление пользователями. Нет способа добавлять или удалять пользователей из tenants, ограничивать доступ клиентов или управлять разрешениями клиентов.

В этом разделе мы установим django-tenant-users, чтобы решить эти проблемы.

Установка

Начните с установки через pip:

(venv)$ pip install django-tenant-users==2.1.1

Затем измените ваши SHARED_APPS и TENANT_APPS следующим образом:

# core/settings.py

SHARED_APPS = [
    "django_tenants",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "django_filters",
    "tenants.apps.TenantsConfig",
    "blog.apps.BlogConfig",
    "tenant_users.permissions",  # new
    "tenant_users.tenants",  # new
]

TENANT_APPS = [
    "django.contrib.auth",  # new
    "django.contrib.contenttypes",  # new
    "tenant_users.permissions",  # new
    "tasks.apps.TasksConfig",
]

Обратите внимание, что tenant_users.permissions необходимо добавить как в общие, так и в клиентские приложения, поскольку разрешения обрабатываются на уровне схемы.

Затем добавьте TenantAccessMiddleware сразу после AuthenticationMiddleware:

# core/settings.py

MIDDLEWARE = [
    # ...
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "tenant_users.tenants.middleware.TenantAccessMiddleware",  # must be right here
    # ...
]

Это промежуточное программное обеспечение гарантирует, что прошедший проверку пользователь имеет доступ к клиенту, указанному в запросе. Если у пользователя нет доступа к клиенту, возникает ошибка 404.

Наконец, установите следующие два параметра в core/settings.py:

# core/settings.py

TENANT_USERS_DOMAIN = BASE_DOMAIN

AUTHENTICATION_BACKENDS = [
    "tenant_users.permissions.backend.UserBackend",
]

Примечания:

  1. TENANT_USERS_DOMAIN определяет, из какого домена следует выполнять подготовку пользователей. Это значение должно соответствовать домену общедоступного арендатора.
  2. AUTHENTICATION_BACKENDS устанавливает пользовательский сервер проверки подлинности, который хорошо работает с группами и разрешениями уровня клиента.

Пользователь, клиент и домен

Продвигаясь вперед, мы должны немного изменить модели User и Tenant:

# tenants/models.py

class User(UserProfile):
    pass


class Tenant(TenantBase, TimeStampedModel):
    name = models.CharField(max_length=100)

# ...

Примечания:

  1. User - вместо наследования от AbstractUser мы должны наследовать от UserProfile.
  2. Tenant - вместо использования TenantMixin, мы должны использовать TenantBase.

Не забудьте об импорте:

from tenant_users.tenants.models import TenantBase, UserProfile

Поскольку мы заменили пользовательскую модель Django по умолчанию, нам нужно заново выполнить миграцию. Удалите папку клиенты/миграции и затем запустите makemigrations:

(venv)$ rm -rf tenants/migrations/0001_initial.py
(venv)$ python manage.py makemigrations

Предоставление арендаторам

Наконец, мы должны немного изменить команду populate_db, чтобы привести ее в соответствие с новыми моделями:

# tenants/management/commands/populate_db.py

import json

from psycopg2 import connect, sql
from django.core.management import BaseCommand, call_command
from tenant_users.tenants.tasks import provision_tenant
from tenant_users.tenants.utils import create_public_tenant

from core import settings
from tenants.models import User


class Command(BaseCommand):
    help = "Creates a public tenant and two demo tenants"
    tenants_data_file = "tenants/data/tenants.json"

    root_user = None
    public_tenant = None

    def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False):
        super().__init__(stdout, stderr, no_color, force_color)

        # Load the tenant data from the JSON file
        self.tenants_data = []
        with open(self.tenants_data_file, "r") as file:
            self.tenants_data = json.load(file)

    def handle(self, *args, **kwargs):
        self.drop_and_recreate_db()
        call_command("migrate_schemas", "--shared", "--noinput")
        self.stdout.write(
            self.style.SUCCESS("Database recreated & migrated successfully.")
        )

        self.create_public_tenant()
        self.create_private_tenants()

        self.stdout.write(
            self.style.SUCCESS("Yay, database has been populated successfully.")
        )

    def drop_and_recreate_db(self):
        db = settings.DATABASES["default"]
        db_name = db["NAME"]

        # Create a connection to the database
        conn = connect(
            dbname="postgres",
            user=db["USER"],
            password=db["PASSWORD"],
            host=db["HOST"],
            port=db["PORT"],
        )
        conn.autocommit = True
        cur = conn.cursor()

        # Terminate all connections to the database except the current one
        cur.execute(
            """
            SELECT pg_terminate_backend(pid)
            FROM pg_stat_activity
            WHERE datname = %s
              AND pid <> pg_backend_pid();
            """,
            [db_name],
        )

        # Drop the database if it exists and create a new one
        cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name)))
        cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_name)))

        cur.close()
        conn.close()

    def create_public_tenant(self):
        self.stdout.write(f"Creating the public tenant...")
        public_tenant_data = self.tenants_data[0]

        # Create the public tenant and the root user
        public_tenant, public_tenant_domain, root_user = create_public_tenant(
            domain_url=settings.BASE_DOMAIN,
            tenant_extra_data={"slug": public_tenant_data["subdomain"]},
            owner_email=public_tenant_data["owner"]["email"],
            is_superuser=True,
            is_staff=True,
            **{
                "password": public_tenant_data["owner"]["password"],
                "is_verified": True,
            },
        )
        self.public_tenant = public_tenant
        self.root_user = root_user

        self.stdout.write(
            self.style.SUCCESS(
                f"Public tenant ('{public_tenant.schema_name}') has been successfully created."
            )
        )

    def create_private_tenants(self):
        private_tenant_data = self.tenants_data[1:]

        for tenant_data in private_tenant_data:
            self.stdout.write(f"Creating tenant {tenant_data['schema_name']}...")

            # Create the tenant owner
            tenant_owner = User.objects.create_user(
                email=tenant_data["owner"]["email"],
                password=tenant_data["owner"]["password"],
            )
            tenant_owner.is_verified = True
            tenant_owner.save()

            # Create the tenant
            tenant, domain = provision_tenant(
                tenant_name=tenant_data["name"],
                tenant_slug=tenant_data["subdomain"],
                schema_name=tenant_data["schema_name"],
                owner=tenant_owner,
                is_superuser=True,
                is_staff=True,
            )

            # Add the root user to the tenant
            tenant.add_user(
                self.root_user,
                is_superuser=True,
                is_staff=True,
            )

            self.stdout.write(
                self.style.SUCCESS(
                    f"Tenant '{tenant.schema_name}' has been successfully created."
                )
            )

Примечания:

  1. Команда воссоздает и переносит базу данных так же, как и раньше.
  2. Вместо непосредственного создания клиентов с помощью ObjectManager мы теперь используем специализированные команды, например create_public_tenant() и provision_tenant().
  3. При создании арендаторов мы должны предоставить арендатору owner.
  4. Чтобы добавить пользователей к арендаторам, мы используем метод add_user().

Наконец, выполните команду:

(venv)$ python manage.py populate_db

Исправления администратора Django

На момент написания этой статьи при использовании Django admin для управления арендаторами и пользователями возникало несколько проблем. В частности:

  1. При удалении экземпляров Tenant и User возникает исключение
  2. UserAdmin неправильно хэширует пароль (сохраняет его в виде обычного текста)
  3. UserTenantPermissions не отражает автоматически изменения при добавлении или удалении пользователя из клиента

Если вы не планируете управлять арендаторами и пользователями через Django admin, не стесняйтесь пропустить этот раздел. Однако я все же считаю, что вам следует, по крайней мере, исправить пункт второй.

Удаление клиента и пользователя

По умолчанию django-tenants не позволяет удалять клиентов и пользователей. Это помогает избежать ошибок. Однако это может вызвать разочарование, когда вам действительно нужно удалить клиента или пользователя.

Чтобы обойти это ограничение, добавьте следующие delete_model() методы в admin.py классы:

# tenants/admin.py

class TenantAdmin(TenantAdminMixin, admin.ModelAdmin):
    # ...

    def delete_model(self, request, obj):
        # Force delete the tenant
        obj.delete(force_drop=True)


class UserAdmin(admin.ModelAdmin):
    # ...

    def delete_model(self, request, obj):
        # Cancel the delete if the user owns any tenant
        if obj.id in Tenant.objects.values_list("owner_id", flat=True):
            raise ValidationError("You cannot delete a user that is a tenant owner.")

        # Cancel the delete if the user still belongs to any tenant
        if obj.tenants.count() > 0:
            raise ValidationError("Cannot delete a tenant owner.")

        # Otherwise, delete the user
        obj.delete(force_drop=True)

Не забудьте об импорте:

from django.core.exceptions import ValidationError

Теперь вы должны иметь возможность удалять арендаторов и пользователей с помощью администратора Django.

Хэширование пароля пользователя

Поскольку мы используем пользовательскую модель и пользовательского администратора, хеширование паролей больше не работает. Каждый раз, когда вы создаете или обновляете пользователя, пароль сохраняется в виде обычного текста.

Давайте снова включим хеширование и принудительно проверим надежность пароля.

Создайте forms.py в приложении tenants со следующим UserAdminForm:

# tenants/forms.py

from django import forms
from django.contrib.auth import password_validation
from django.contrib.auth.hashers import make_password
from tenants.models import User


class UserAdminForm(forms.ModelForm):
    class Meta:
        model = User
        fields = "__all__"

    def clean_password(self):
        password = self.cleaned_data.get("password")

        # Run the password validators
        try:
            password_validation.validate_password(password=password, user=self.instance)
        except forms.ValidationError as e:
            raise forms.ValidationError(e.messages)

        # Hash the password only if it isn't hashed yet
        if password and not password.startswith("pbkdf2_"):
            return make_password(password)

        return password

Далее настройте форму UserAdmin:

 

# tenants/signals.py

from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from django_tenants.utils import schema_context
from tenant_users.permissions.models import UserTenantPermissions

from tenants.models import User


@receiver(m2m_changed, sender=User.tenants.through)
def on_tenant_user_tenants_changed(
    sender, instance, action, reverse, model, pk_set, **kwargs
):
    # Automatically create 'UserTenantPermissions' when user is added to a tenant
    if action == "post_add":
        for tenant_id in pk_set:
            tenant = model.objects.get(pk=tenant_id)
            with schema_context(tenant.schema_name):
                UserTenantPermissions.objects.get_or_create(profile=instance)

    # Automatically delete 'UserTenantPermissions' when user is removed from a tenant
    if action == "post_remove":
        for tenant_id in pk_set:
            tenant = model.objects.get(pk=tenant_id)
            with schema_context(tenant.schema_name):
                UserTenantPermissions.objects.filter(profile=instance).delete()

Наконец, импортируем сигналы в apps.py вот так:

# tenants/forms.py 
    class UserAdmin(admin.ModelAdmin):
        form = UserAdminForm
        # ...

Не забудьте про импорт:

from tenants.forms import UserAdminForm

Пароль пользователя теперь должен автоматически хэшироваться при сохранении.

Автоматические разрешения для арендаторов

Наконец, давайте обеспечим автоматическое создание или удаление разрешений UserTenantPermissions каждый раз, когда пользователь назначается или удаляется из арендатора с помощью администратора Django.

Я имею в виду, в частности, сферу арендаторов:

Создайте signals.py в приложении клиента следующим образом:

# tenants/signals.py

from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from django_tenants.utils import schema_context
from tenant_users.permissions.models import UserTenantPermissions

from tenants.models import User


@receiver(m2m_changed, sender=User.tenants.through)
def on_tenant_user_tenants_changed(
    sender, instance, action, reverse, model, pk_set, **kwargs
):
    # Automatically create 'UserTenantPermissions' when user is added to a tenant
    if action == "post_add":
        for tenant_id in pk_set:
            tenant = model.objects.get(pk=tenant_id)
            with schema_context(tenant.schema_name):
                UserTenantPermissions.objects.get_or_create(profile=instance)

    # Automatically delete 'UserTenantPermissions' when user is removed from a tenant
    if action == "post_remove":
        for tenant_id in pk_set:
            tenant = model.objects.get(pk=tenant_id)
            with schema_context(tenant.schema_name):
                UserTenantPermissions.objects.filter(profile=instance).delete()

Наконец, импортируйте сигналы в apps.py следующим образом:

# tenants/apps.py

from django.apps import AppConfig

class TenantsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "tenants"

    def ready(self):
        from tenants import signals

Изменения в полях tenants теперь должны быть отражены в базе данных.

Заключение

В этом руководстве мы рассмотрели различные подходы к многоквартирному дому. Рекомендуемый подход для большинства проектов Django - это полуизолированный подход, который может быть реализован с помощью пакета django-tenants и django-tenant-users.

Исходный код доступен на GitHub.

Если у вас возникнут какие-либо вопросы или проблемы, не стесняйтесь обращаться ко мне.

Вернуться на верх