bernardopires / django-tenant-schemas

Tenant support for Django using PostgreSQL schemas.
https://django-tenant-schemas.readthedocs.org/en/latest/
MIT License
1.46k stars 424 forks source link

Django-tenants - please help #700

Open Slukas122 opened 6 months ago

Slukas122 commented 6 months ago

Hello,

I have a problem with django-tenants. I am still learning to program, so it is possible that I have made a beginner's mistake somewhere.

I will explain what I am trying to achieve with a model example. Some procedures are quite awkward and serve mainly to identify the problem.

The problem is that the middleware likely does not switch the tenant. Specifically, I would expect that if /prefix/domain_idorsubfolder_id is in the URL, the middleware would automatically detect the prefix, subfolder ID, and set the corresponding schema as active. However, this is not happening, and the login to the tenant does not occur. Instead, the login happens in the "public" schema in the database.

Model example: A user goes to http://127.0.0.1:8000/login/ and enters their email, which filters the appropriate tenant and redirects the user to /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.

Notice that the authorization_app is not listed among the installed apps at all, which is strange because I reference it in urls.py, tenant_urls.py, and in the authorization_app's own urls.py.

urls.py - public URLs in 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

from django.shortcuts import render, redirect
from django.contrib.auth import login
from django.contrib.auth.views import LoginView
from .forms import UserRegisterForm, RedirectTenantForm
from tenants_app.models import TenantModel, DomainModel
from django.db import transaction
from django.contrib.auth.decorators import login_required
from .models import UserModel
import uuid
import urllib.parse
import re
from seo_app import settings
import logging
from django_tenants.utils import schema_context
from django.contrib.auth.models import Group, Permission
from tenant_users.permissions.models import UserTenantPermissions
from django.db import connection

@transaction.atomic
def user_register(request):
    if request.method == "POST":
        form = UserRegisterForm(request.POST)
        if form.is_valid():
            new_user = form.save(commit=False)  # Save new user to the database. Then add them as a foreign key to owner_id
            new_user.is_active = True
            new_user.save()

            subdomain_username = form.cleaned_data.get("username").lower()
            subdomain_username = re.sub(r'[^a-zA-Z0-9]', '', subdomain_username)  # This regex cleans the user input for use in subdomain
            unique_id = str(uuid.uuid4()).replace('-', '').lower()[:7]
            subdomain_username = f"{subdomain_username}{unique_id}"

            # Create tenant instance
            new_user_tenant = TenantModel()
            new_user_tenant.owner_id = new_user.id
            new_user_tenant.name = subdomain_username
            new_user_tenant.schema_name = subdomain_username
            new_user_tenant.domain_subfolder = subdomain_username
            new_user_tenant.save()

            # Set domain instance for User
            new_user_subdomain = DomainModel()
            new_user_subdomain.domain = f"{subdomain_username}"
            new_user_subdomain.tenant = new_user_tenant
            new_user_subdomain.is_primary = True
            new_user_subdomain.save()

            return redirect("/")
    else:
        form = UserRegisterForm()
    return render(request, "register_template.html", {"form": form})

def redirect_tenant_login(request):
    if request.method == "POST":
        form = RedirectTenantForm(request.POST)
        if form.is_valid():
            email = form.cleaned_data["tenant_email"]
            user_instance = UserModel.objects.get(email=email)
            tenant_instance = TenantModel.objects.get(owner_id=user_instance.id)
            subdomain = tenant_instance.schema_name
            print(f"User ID: {subdomain}")
            return redirect(f"/client/{subdomain}/tenant/login/")
    else:
        form = RedirectTenantForm()
    return render(request, "registration/redirect_tenant.html", {"form": form})

class CustomLoginView(LoginView):  # Encode using urllib.parse
    def get_success_url(self):
        user = self.request.user
        print(user)
        try:
            usermodel = UserModel.objects.get(email=user.email)
            print(usermodel)
            tenant_model = TenantModel.objects.get(owner_id=usermodel.id)
            print(tenant_model)
            tenant_name = tenant_model.schema_name
            print(tenant_name)
            tenant_name_normalized = urllib.parse.quote(tenant_name.lower())  # Safe encoding for URL
            print(tenant_name_normalized)
            return f'/client/{tenant_name_normalized}/dashboard/'
        except UserModel.DoesNotExist:
            print("Error 1")
            return "/"
        except AttributeError:
            print("Error 2")
            # Added to catch errors when 'tenant' does not exist for the given user
            return "/"

    def form_valid(self, form):
        super().form_valid(form)  # Standard login and get user
        url = self.get_success_url()
        return redirect(url)

At this URL http://127.0.0.1:8000/client/test2f0d3775/tenant/login/, I activated the Python manage.py shell and tested the connection, which returned the following:

>>> from django.db import connection
>>> print(connection.schema_name)
public

django - settings.py

from django.core.management.utils import get_random_secret_key
from pathlib import Path
import os

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent

DEBUG = True

ALLOWED_HOSTS = ['localhost', '.localhost', '127.0.0.1', '.127.0.0.1', '*']

# Tenant Application definition
SHARED_APPS = (
    'django_tenants',
    'django.contrib.contenttypes',
    "tenant_users.permissions",
    "tenant_users.tenants",
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.admin',
    'django.contrib.staticfiles',
    'django.contrib.auth',
    'authorization_app',
    'tenants_app',
    # everything below here is optional
)

TENANT_APPS = (
    'django.contrib.auth',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django.contrib.contenttypes',
    "tenant_users.permissions",
    'keyword_analysis_app',
    'linkbuilding_app',
    'content_app',
    'downloading_reports_app',
    'authorization_app',
)

TENANT_MODEL = "tenants_app.TenantModel"  # app.Model
TENANT_DOMAIN_MODEL = "tenants_app.DomainModel"  # app.Model

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

PUBLIC_SCHEMA_URLCONF = 'seo_app.urls'  # points to public tenants in seo_app urls.py
# TENANT_URLCONF = 'seo_app.tenant_urls'  # points to private tenants in seo_app urls.py
ROOT_URLCONF = 'seo_app.tenant_urls'

AUTH_USER_MODEL = "authorization_app.UserModel"
# Switch to authentication backend django-tenants-user
AUTHENTICATION_BACKENDS = (
    "tenant_users.permissions.backend.UserBackend",
)

TENANT_USERS_DOMAIN = "127.0.0.1"

MIDDLEWARE = [
    # 'seo_app.middleware.TenantSubfolderMiddleware',
    'django_tenants.middleware.TenantSubfolderMiddleware',  # Set tenants - subfolder
    # 'django_tenants.middleware.main.TenantMainMiddleware',  # Set tenants - subdomains
    'django.middleware.security.SecurityMiddleware',
    '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',
]

INTERNAL_IPS = [
    '127.0.0.1',
]

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [BASE_DIR / "templates"],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.request',  # DJANGO TENANTS
                'django.template.context_processors.debug',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

WSGI_APPLICATION = 'seo_app.wsgi.application'

DATABASES = {
    'default': {
        'ENGINE': 'django_tenants.postgresql_backend',  # TENANTS CONFIGURATION
        'NAME': 'xxxxxx',  # Name of your database
        'USER': 'lukas_db',  # Database user
        'PASSWORD': 'xxxxxxxxx',  # User password
        'HOST': '127.0.0.1',  # Server address (or IP address)
        'PORT': '5432',  # Port on which PostgreSQL is running (5432)
    }
}

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]

LANGUAGE_CODE = 'cs'

LOGOUT_REDIRECT_URL = '/'
LOGIN_REDIRECT_URL = '/dashboard/'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True

# settings.py
MEDIA_ROOT = BASE_DIR / 'media'
MEDIA_URL = '/media/'

# asynchronous processing
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0'  # Uses Redis on localhost
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_TIMEZONE = 'Europe/Prague'
CELERY_WORKER_POOL = 'prefork'
CELERY_WORKER_POOL = 'solo'

STATIC_URL = "/static/"
STATICFILES_DIRS = [
    BASE_DIR / 'static',
    # Add more paths if needed
]
# STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

SESSION_COOKIE_DOMAIN = '127.0.0.1'
SESSION_COOKIE_NAME = 'sessionid_tenant'
CSRF_COOKIE_DOMAIN = "127.0.0.1"

SHOW_PUBLIC_IF_NO_TENANT_FOUND = True
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_HTTPONLY = True
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
SESSION_SAVE_EVERY_REQUEST = True

# TENANTS CONFIGURATION
DATABASE_ROUTERS = (
    'django_tenants.routers.TenantSyncRouter',
)

TENANT_SUBFOLDER_PREFIX = "client"

Thank you very much for any advice you can offer. I've been working on this for several days without any success. Unfortunately, I couldn't find any guidance in the documentation on how to implement a login system where the user can log in easily and then be redirected to their own schema, where they would use only their own database.

I tried writing new middleware. I tried changing URL paths. I tried using subdomain-based django-tenant. Unfortunately, there were also issues with cookies being lost. On MacOS, I couldn't configure this in /etc/hosts. Authentication always occurs in the public schema, and when redirecting to the subdomain, all cookies are lost. Several methods of logging and rewriting views. First, authentication and then redirection took place. In another attempt, I set the context using with schema_context. The latest approach, which you can see in the shared code, was supposed to identify the tenant based on the provided email, then find the corresponding subfolder in the database and create a path with /prefix/subfolder/tenant/login. The login to the tenant was supposed to happen on this page, but it doesn't. I also tried various configurations in settings.py, as it seems that some crucial information is missing from the documentation. Thank you for any advice.

jeroenbrouwer commented 1 month ago

This is not the django-tenants repo. You should ask this at https://github.com/django-tenants/django-tenants