Django Tenants - я потратил на него много времени.
У меня возникла проблема с django-tenants. Я все еще учусь программировать, поэтому вполне возможно, что я где-то допустил ошибку новичка.
Я объясню, чего я пытаюсь добиться, на модельном примере. Некоторые процедуры довольно неудобны и служат в основном для выявления проблемы.
Проблема в том, что промежуточное ПО, скорее всего, не переключает арендатора. В частности, я ожидал, что если в URL-адресе будет указано /prefix/domain_idorsubfolder_id, то промежуточное ПО автоматически определит префикс, идентификатор подпапки и установит соответствующую схему как активную. Однако этого не происходит, и вход в систему арендатора не происходит. Вместо этого вход происходит в схему "public" в базе данных.
Пример модели: Пользователь заходит на http://127.0.0.1:8000/login/ и вводит свой email, который фильтрует соответствующего арендатора и перенаправляет пользователя на страницу /client/tenant_id/tenant/login.
Page not found (404)
Request Method: GET
Request URL: http://127.0.0.1:8000/client/test2f0d3775/tenant/login/
Using the URLconf defined in seo_app.tenant_urls_dynamically_tenant_prefixed, Django tried these URL patterns, in this order:
client/test2f0d3775/ basic-keyword-cleaning/ [name='basic_cleaned_keyword']
client/test2f0d3775/ ai-keyword-cleaning/ [name='auto_cleaned_keyword']
client/test2f0d3775/ keyword-group-creation/ [name='content_group']
client/test2f0d3775/ looking-for-link-oppurtunities/ [name='search_linkbuilding']
client/test2f0d3775/ url-pairing/ [name='url_pairing']
client/test2f0d3775/ creating-an-outline/ [name='article_outline']
client/test2f0d3775/ analyses/ [name='all_analyses']
client/test2f0d3775/ download/<str:model_type>/<int:file_id>/ [name='download_file']
client/test2f0d3775/ dashboard/ [name='dashboard']
client/test2f0d3775/ client/
The current path, client/test2f0d3775/tenant/login/, didn’t match any of these.
Заметили, что приложение authorization_app вообще не значится среди установленных приложений, что странно, поскольку я ссылаюсь на него в urls.py, tenant_urls.py и в собственном urls.py приложения authorization_app.
urls.py - публичные URL в root_django_project
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path("", include("authorization_app.urls")),
path('', include('seo_app.tenant_urls')),
# path("client/", include('seo_app.tenant_urls_dynamically_tenant_prefixed')),
# path(r"", include("keyword_analysis_app.urls")),
# path(r"", include("linkbuilding_app.urls")),
# path(r"", include("content_app.urls")),
# path(r"", include("downloading_reports_app.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
tenant_urls.py - root_django_project
from django.urls import path, include
from django.conf import settings
app_name = "tenant"
urlpatterns = [
path(f"", include("keyword_analysis_app.urls")),
path(f"", include("linkbuilding_app.urls")),
path(f"", include("content_app.urls")),
path(f'', include("downloading_reports_app.urls")),
path(f'{settings.TENANT_SUBFOLDER_PREFIX}/', include("authorization_app.urls")),
# path("", include("keyword_analysis_app.urls")),
# path("", include("linkbuilding_app.urls")),
# path("", include("content_app.urls")),
# path('', include("downloading_reports_app.urls")),
# path('', include("authorization_app.urls")),
]
urls.py - authorization_app
from django.urls import path
from . import views
from django.contrib.auth.views import LogoutView
from .views import CustomLoginView, redirect_tenant_login
app_name = "authorization_app"
urlpatterns = [
path("register/", views.user_register, name="register"),
path("login/", views.redirect_tenant_login, name="redirect_tenant"),
path("logout/", LogoutView.as_view(next_page="/"), name="logout"),
path("<str:subfolder>/tenant/login/", CustomLoginView.as_view(), name="login_tenant"),
]
views.py - authorization_app
По этому URL http://127.0.0.1:8000/client/test2f0d3775/tenant/login/ я активировал оболочку Python manage.py и протестировал соединение, которое вернуло следующее:
>>> from django.db import connection
>>> print(connection.schema_name)
public
django - settings.py
Огромное спасибо за любой совет, который вы можете предложить. Я работаю над этим уже несколько дней безрезультатно. К сожалению, я не смог найти в документации никаких указаний о том, как реализовать систему входа, в которой пользователь может легко войти в систему и затем быть перенаправленным на свою собственную схему, где он будет использовать только свою собственную базу данных.
Я пробовал писать новое промежуточное ПО. Я пробовал менять пути URL. Я пробовал использовать django-тенант на основе субдоменов. К сожалению, также возникали проблемы с потерей cookies. На MacOS я не смог настроить это в /etc/hosts. Аутентификация всегда происходит в публичной схеме, и при перенаправлении на поддомен все куки теряются. Несколько методов регистрации и переписывания представлений. Сначала происходила аутентификация, а затем перенаправление. В другой попытке я установил контекст с помощью schema_context. Последний подход, который вы можете увидеть в общем коде, должен был идентифицировать арендатора на основе предоставленного им email, затем найти соответствующую подпапку в базе данных и создать путь с /prefix/subfolder/tenant/login. Вход в систему арендатора должен был происходить на этой странице, но этого не происходит. Я также попробовал различные конфигурации в settings.py, так как кажется, что в документации не хватает какой-то важной информации. Спасибо за любой совет.
Пользовательская амнезия с Django_tenants.
<
Вы уже нашли решение?
Проблема в том, что в Django_tenants просто не хватает много кода. Прежде всего, Django_tenants предрасполагает к использованию поддоменов для своих арендаторов. Есть, правда, и возможность перейти на использование подпапок, но это только бета-версия и порождает множество ошибок.
Django_tenants прекрасно работает у меня, но не после того, как я потратил около двух месяцев на исследования и работу, чтобы добиться этого.
Нужно решить три проблемы:
- правильная работа с вложенными папками,
- аутентификация с вложенными папками,
- сеансы Django с вложенными папками.
Сначала у меня работали вложенные папки, но при этом у Django_tenants продолжала возникать "амнезия пользователя", т.е. пользователь мог войти в систему и был принят, но на следующей странице он снова становился Anonymous. На страницах, где требовалась авторизация, Django_tenants продолжал, таким образом, бегать по кругу.
Здесь нужно охватить огромное количество тем, поэтому давайте начнем прямо сейчас.
Сначала начнем с
правильное управление вложенными папками.
Необходимо написать новое промежуточное ПО. Бета-версии промежуточного ПО для подпапок Django_tenants недостаточно. Обычно предполагается, что вы просто поменяете middleware в settings.py на middleware из подпапки. Это не работает, и я написал свой собственный middleware: subfolderhans.py, который я включил сюда и который вы можете свободно использовать. Я поместил его в общий раздел моего проекта Django и ссылаюсь на него в файле settings.py следующим образом:
from subfolderhans import TenantSubfolderMiddlewareHans
MIDDLEWARE = [
# 'django_tenants.middleware.main.TenantMainMiddleware',
'subfolderhans.TenantSubfolderMiddlewareHans',
Далее, есть urlresolver.py, который также добавлен в этот ответ, и который нужно написать поверх оригинального в исходных файлах Django_tenant:
COPY urlresolvers.py /usr/local/lib/python3.10/dist-packages/django_tenants/urlresolvers.py
(в Linux), в Windows это c:\users\<your name>\AppData\Roaming\...
.
Обратите внимание, AppData - это скрытая директория, в настройках вида нужно установить "Показывать скрытые файлы" в True.
Далее, есть context_processors.py, который также включен сюда и который вы должны поместить в общий раздел вашего проекта Django_tenants. Вы можете использовать его, если хотите иметь в статической карте каталог для каждого приложения, а затем ссылаться в файлах шаблона на изображения и т.д. с помощью context_processors.py. Лично у меня не так много графики, поэтому я собираю их все вместе и просто слежу за тем, чтобы имена изображений были уникальными.
Во-вторых, context_processors также используется в settings.py:
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'csb_tenants', 'templates', 'csb_tenants')],
'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',
'context_processors.get_program_settings', # for templates
'context_processors.admin_header_processor', # for admin titles in registration section: password change/reset, ...
],
},
},
]
Csb_tenants в моем случае - это мое приложение tenant_app.
Для полноты картины, вот копия настроек моего общего и арендаторского приложений в файле settings.py:
TENANT_SUBFOLDER_PREFIX = "huurder"
# Application definition
SHARED_APPS = [
'django_tenants',
'csb_tenants',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
TENANT_APPS = [
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'csb_tenant',
]
INSTALLED_APPS = list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS]
Я специально не использовал "tenant" в качестве префикса арендатора, по очевидным причинам.
Более того, я написал небольшую процедуру в файле global.py для получения первичного ключа арендатора из url, которая более или менее похожа на функцию get_subfolder(request) в context_processors.py:
def fetch_customer_stripeid_from_url(request):
request_string = request.get_full_path()
start_marker = "cus_"
end_marker = "/"
# Find the start and end of the substring to extract
start = request_string.find(start_marker)
end = request_string.find(end_marker, start + len(start_marker))
# If end_marker is not found, include the rest of the string
if end == -1:
end = None
customer_stripeid = request_string[start:end]
return customer_stripeid
Так вот, это для того, чтобы лечить вложенные папки.
Теперь следующий шаг:
Аутентификация в Django_tenants с подпапками
Аутентификация проходит неправильно в django_tenants с вложенными папками. Вам нужен дополнительный уровень аутентификации в settings.py:
AUTHENTICATION_BACKENDS = [
'csb_tenant.views.TenantAuthenticationBackend',
'django.contrib.auth.backends.ModelBackend',
]
Будут выполнены обе аутентификации, сначала вышеуказанная, затем последняя, и если одна из них даст положительный результат, аутентификация пройдет. Мой TenantAuthenticationBackend будет выполнять аутентификацию для арендаторов, и если это не сработает, классический Modelbackend может попытаться использовать публичную схему, и если это также не сработает, то пользователь явно не авторизован.
Вот код для TenantAuthenticationBackend, который я поместил в представления моей модели приложения, но это личное.
from csb_tenants.csglobals import fetch_customer_stripeid_from_url
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import AnonymousUser
from django_tenants.utils import schema_context
from django.contrib.auth.forms import AuthenticationForm
from django.db import connection
class CustomAuthenticationForm(AuthenticationForm):
def clean(self):
username = self.cleaned_data.get('username')
password = self.cleaned_data.get('password')
return super().clean()
class TenantAuthenticationBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
UserModel = get_user_model()
try:
user = UserModel.objects.get(username=username)
if user:
if user.check_password(password):
return user
else:
tenant = csbtenants.objects.get(customer_stripeid=username)
if tenant.password == password:
return AnonymousUser()
except UserModel.DoesNotExist:
cust_id = fetch_customer_stripeid_from_url(request)
tenant = csbtenants.objects.get(customer_stripeid=cust_id)
with schema_context(tenant.schema_name):
try:
users = UserModel.objects.all()
for user in users:
print(user.username)
user = UserModel.objects.get(username=username)
if user:
if user.check_password(password):
return user
except Exception as e:
print(f'TenantAuthenticationBackend: with schema_context(tenant.schema_name): An unexpected error occurred: {e}')
pass
pass
except csbtenants.DoesNotExist:
print(f'TenantAuthenticationBackend: csbtenants.DoesNotExist')
pass
except Exception as e:
print(f'TenantAuthenticationBackend: An unexpected error occurred: {e}')
return None
Как видите, вы должны наследоваться от классического Modelbackend, а затем переопределить функцию authenticate. (Я должен сказать "метод", я знаю, но я из старого поколения IT-инженеров, когда теорема объектной ориентации еще не была общепринятой, а только зарождалась).
Этот бэкэнд аутентификации является ключевым. Теперь мы можем использовать его повсюду, прежде всего при входе в систему, а также для поиска следующей страницы после входа:
class TenantLoginView(auth_views.LoginView):
customer_id = ''
form_class = CustomAuthenticationForm
def get_context_data(self, **kwargs):
print_user_data(self.request)
context = super().get_context_data(**kwargs)
self.customer_id = fetch_customer_stripeid_from_url(self.request)
context['cust_id'] = self.customer_id
try:
tenant = csbtenants.objects.get(customer_stripeid=self.customer_id)
context['tenant_name'] = tenant.companyname
except ObjectDoesNotExist:
context['tenant_name'] = ''
return context
def form_valid(self, form):
self.success_url = '/' + TENANT_SUBFOLDER_PREFIX + '/' + self.customer_id + '/tenantconfig/'
tenant = csbtenants.objects.get(customer_stripeid=self.customer_id)
with schema_context(tenant.schema_name):
response = super().form_valid(form)
print(f'Last query (schema_name: {connection.schema_name}) after response = super().form_valid: ', connection.queries[-1]['sql']) # Print the last SQL query
return response
def form_invalid(self, form):
response = super().form_invalid(form)
messages.error(self.request, 'Invalid username or password.')
return response
def post(self, request, *args, **kwargs):
self.customer_id = fetch_customer_stripeid_from_url(request)
return super().post(request, *args, **kwargs)
class Index(TemplateView):
template_name = 'csb_tenant/index.html'
def dispatch(self, request, *args, **kwargs):
print(f'request.user.username: {request.user.username}')
no_redirect = request.user.is_authenticated or request.user.is_superuser or request.user.is_staff
print(f'no_redirect: {no_redirect}')
tenant_customer_stripeid = fetch_customer_stripeid_from_url(request)
if not no_redirect:
return HttpResponseRedirect(f'/accounts/login?next=/{TENANT_SUBFOLDER_PREFIX}/{tenant_customer_stripeid}/tenantconfig/')
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
tenant_customer_stripeid = fetch_customer_stripeid_from_url(self.request)
context['csbtenant'] = get_object_or_404(csbtenants, customer_stripeid=tenant_customer_stripeid)
context['form'] = LoadFilesForm()
return context
Здесь вы видите метод index, который, конечно же, является первой страницей model_app, и чтобы получить доступ, пользователь должен войти в систему. Этот вход происходит в TenantLoginView (вы назвали его CustomLoginView), унаследованном и переопределенном для классического представления входа. Секрет действительно, как вы уже предполагали, заключается в использовании schema_context.
Рассмотрев аутентификацию, мы еще не достигли цели, поскольку теперь нам предстоит рассмотреть третий элемент - сессии.
Управление сессиями Django_tenant с помощью вложенных папок.
Давайте проясним, мы говорим не о сетевых сессиях, а о сессиях из Django. Эти сессии записываются в базу данных, в таблицу под названием django_session, и для каждого арендатора и, соответственно, схемы должна быть своя таблица с названием Django_session. Именно здесь все идет не так, как в стандартном Django_tenants, где эти таблицы создаются, но затем записи извлекаются из публичной схемы, где этих записей нет.
Вы не обязаны использовать базу данных для хранения сессий, вы можете не хранить их вообще или сохранять их по-другому, но стандартом является база данных.
В сессиях Django хранится запись из трех полей, первое из которых - уникальный идентификатор, второе - полезная нагрузка, а третье - временная метка. Уникальный идентификатор и полезная нагрузка (обычно это словарь, который может содержать много данных) хранятся в базе данных в полностью зашифрованном виде. Это делается для обеспечения четвертого уровня конфиденциальности, когда постоянные данные также должны быть зашифрованы.
Я написал промежуточное программное обеспечение для управления этими сессиями, которое я снова прилагаю к этому ответу, которое я поместил в общий раздел моего проекта django и сделал вот так в моем settings.py:
'SessionMiddlewareHans.TenantSessionMiddleware', #'django.contrib.sessions.middleware.SessionMiddleware',
Поскольку порядок этих промежуточных программ важен, потому что если арендатор еще не известен, то вы не можете аутентифицироваться, а если вы не аутентифицировались, то не должны идти и получать сессию и т.д. Вот мой порядок middlewares в settings.py:
MIDDLEWARE = [
# 'django_tenants.middleware.main.TenantMainMiddleware',
'subfolderhans.TenantSubfolderMiddlewareHans',
'django.middleware.security.SecurityMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware",
'SessionMiddlewareHans.TenantSessionMiddleware',
#'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware'
Это, конечно, зависит от ваших потребностей. Я даю его, чтобы вы могли сравнить. Тем не менее, этот порядок работает для меня.
Это практически все.
Вам придется немного адаптировать мой код к вашей конкретной ситуации. Csb_tenants - основное приложение для арендаторов, csb_tenant - приложение-модель, csbtenants - основная модель основного приложения для арендаторов, csbtenant - модель приложения-модели.
Если я что-то забыл, или что-то непонятно, то, пожалуйста, не стесняйтесь спрашивать.
Django_tenants - чрезвычайно ценное программное обеспечение, и я рад, что могу внести свой вклад таким образом.
Ганс
Обновление: поскольку оказалось, что я не могу загрузить кодовые файлы, я выкладываю их здесь:
urlresolvers.py:
import re
import sys
from types import ModuleType
from django.db import connection
from django.conf import settings
from django.urls import URLResolver, reverse as reverse_default, path, include
from django.utils.functional import lazy
from django_tenants.utils import (
get_tenant_domain_model,
get_subfolder_prefix,
clean_tenant_url, has_multi_type_tenants, get_tenant_types,
)
def reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None):
url = reverse_default(viewname, urlconf, args, kwargs, current_app=current_app)
return clean_tenant_url(url)
reverse_lazy = lazy(reverse, str)
def get_subfolder_urlconf(tenant):
if has_multi_type_tenants():
urlconf = get_tenant_types()[tenant.get_tenant_type()]['URLCONF']
else:
urlconf = settings.ROOT_URLCONF
subfolder_prefix = get_subfolder_prefix()
class TenantUrlConf(ModuleType):
urlpatterns = [
path(f"{subfolder_prefix}/{tenant.domain_subfolder}/", include(urlconf))
]
return TenantUrlConf(tenant.domain_subfolder)
subfolderhans.py:
SessionMiddlewhareHans.py:
# Hans Wendel van Hespen 01/06/2024: custom version of the standard session middleware, to make them schema_based.
from django.contrib.sessions.middleware import SessionMiddleware
from django_tenants.utils import schema_context
from django.http import HttpResponseServerError
from django.db import connection
from django.conf import settings
class TenantSessionMiddleware(SessionMiddleware):
def process_request(self, request):
# Get the session key from the cookies
session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
# Import csbtenants here, not at the module level
from csb_tenants.models import csbtenants
from csb_tenants.csglobals import fetch_customer_stripeid_from_url
from stripeproject.settings import TENANT_SUBFOLDER_PREFIX
# Determine the correct schema to use
request_string = request.get_full_path()
if TENANT_SUBFOLDER_PREFIX in request_string:
with schema_context('public'):
customer_id = fetch_customer_stripeid_from_url(request)
tenant = csbtenants.objects.get(customer_stripeid=customer_id)
with schema_context(tenant.schema_name):
request.session = self.SessionStore(session_key)
else:
with schema_context('public'):
request.session = self.SessionStore(session_key)
def process_response(self, request, response):
# Import csbtenants here, not at the module level
from csb_tenants.models import csbtenants
from csb_tenants.csglobals import fetch_customer_stripeid_from_url
from stripeproject.settings import TENANT_SUBFOLDER_PREFIX
try:
request_string = request.get_full_path()
if TENANT_SUBFOLDER_PREFIX in request_string:
# Try to get the tenant for the current user
with schema_context('public'):
# Get the customer_id from the URL
customer_id = fetch_customer_stripeid_from_url(request)
tenant = csbtenants.objects.get(customer_stripeid=customer_id)
# If a tenant was found, use the tenant's schema
with schema_context(tenant.schema_name):
antwoord = super().process_response(request, response)
return antwoord
else:
# No specific tenant in url: public schema
with schema_context('public'):
return super().process_response(request, response)
except Exception as e:
print(f'TenantSessionMiddleware: An unexpected error occurred: {e}')
return HttpResponseServerError('An unexpected error occurred.')
и, наконец, context_processors.py:
from stripeproject.settings import TENANT_SUBFOLDER_PREFIX
from django.contrib import admin
def get_subfolder_code(request):
subfolder_code = ""
if request is None:
return subfolder_code
try:
subfolder_code = "/" + TENANT_SUBFOLDER_PREFIX + "/" + request.tenant.name
except:
pass
return subfolder_code
#for template
def get_program_settings(request):
subfolder_code = get_subfolder_code(request)
context = {
'subfolder_code': subfolder_code
}
return {"program_settings": context}
def admin_header_processor(request):
site_header = getattr(admin.site, 'site_header')
site_title = getattr(admin.site, 'site_title')
index_title = getattr(admin.site, 'index_title')
return {"site_header": site_header, "site_title": site_title, "index_title": index_title}