feat: Добавить автоматическое создание суперпользователей для тенантов

Реализована система автоматического создания суперпользователей при активации
новых тенантов (магазинов). Credentials читаются из .env файла.

Изменения:
- Подключен django-environ для управления переменными окружения
- Обновлен settings.py: SECRET_KEY, DEBUG, DATABASE теперь из .env
- Добавлены настройки TENANT_ADMIN_EMAIL, TENANT_ADMIN_PASSWORD, TENANT_ADMIN_NAME
- Обновлен tenants/admin.py: автоматическое создание superuser при активации
- Создан activate_tenant.py: универсальный скрипт активации заявок
- Обновлен activate_mixflowers.py: добавлено создание superuser
- Создан .gitignore для защиты секретов
- Добавлена документация TENANT_ADMIN_GUIDE.md

Использование:
1. Через админку: Заявки → Активировать (автоматически создаст superuser)
2. Через скрипт: python activate_tenant.py <schema_name>

Доступ к админке тенанта:
- URL: http://{schema_name}.localhost:8000/admin/
- Email: admin@localhost (из .env)
- Password: 1234 (из .env)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-27 18:20:26 +03:00
parent a55d0405ed
commit 4b44624f86
6 changed files with 1164 additions and 70 deletions

64
myproject/.gitignore vendored Normal file
View File

@@ -0,0 +1,64 @@
# Django
*.log
*.pot
*.pyc
__pycache__/
local_settings.py
db.sqlite3
db.sqlite3-journal
# Environment variables (contains secrets!)
.env
# Virtual environment
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Static and media files
/staticfiles/
/media/
# Python
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Testing
.pytest_cache/
.coverage
htmlcov/
# Migrations (раскомментируйте если не хотите коммитить миграции)
# */migrations/*.py
# !*/migrations/__init__.py

View File

@@ -0,0 +1,332 @@
# Руководство по автоматическому созданию суперпользователей для тенантов
## Обзор
При создании нового тенанта (магазина) система **автоматически** создает суперпользователя с credentials из файла `.env`. Это позволяет сразу после активации войти в админ-панель тенанта и начать работу.
---
## Настройка
### 1. Файл `.env`
В корне проекта находится файл [.env](myproject/.env) с настройками:
```env
# Настройки автоматического создания суперпользователя для новых тенантов
TENANT_ADMIN_EMAIL=admin@localhost
TENANT_ADMIN_PASSWORD=1234
TENANT_ADMIN_NAME=Admin
```
**Важно для продакшена:**
- Измените пароль на более безопасный
- Используйте надежный email
- Не коммитьте `.env` в git (уже добавлен в `.gitignore`)
### 2. Шаблон `.env.example`
Для других разработчиков создан файл [.env.example](myproject/.env.example) - скопируйте его в `.env` и настройте:
```bash
cp .env.example .env
# Отредактируйте .env своими значениями
```
---
## Как это работает
### При активации через админку
1. Заходите в админ-панель: `http://localhost:8000/admin/`
2. Раздел "Заявки на регистрацию"
3. Нажимаете кнопку "Активировать" напротив заявки
**Автоматически выполняется:**
- Создается тенант (Client)
- Создается домен ({schema_name}.localhost)
- Создается триальная подписка (90 дней)
- **Создается суперпользователь** с credentials из `.env`
- Обновляется статус заявки на "Одобрено"
### При активации через скрипт
#### Универсальный скрипт [activate_tenant.py](myproject/activate_tenant.py):
```bash
cd c:\Users\team_\Desktop\test_qwen\myproject
"c:\Users\team_\Desktop\test_qwen\venv\Scripts\python.exe" activate_tenant.py grach
```
Где `grach` - это schema_name заявки.
**Вывод скрипта:**
```
Найдена заявка: Цветы грач (grach)
Статус: Ожидает проверки
Email: owner@example.com
Начинаю активацию...
1. Создание тенанта: grach
[OK] Тенант создан (ID: 5)
2. Создание домена: grach.localhost
[OK] Домен создан (ID: 4)
3. Создание триальной подписки на 90 дней
[OK] Подписка создана (ID: 2)
Истекает: 2026-01-25 (89 дней)
4. Создание суперпользователя для тенанта
[OK] Суперпользователь создан (ID: 1)
5. Обновление статуса заявки
[OK] Заявка помечена как "Одобрено"
======================================================================
АКТИВАЦИЯ ЗАВЕРШЕНА УСПЕШНО!
======================================================================
Магазин: Цветы грач
Schema: grach
Домен: http://grach.localhost:8000/
Подписка до: 2026-01-25 (89 дней)
Доступ к админке тенанта:
URL: http://grach.localhost:8000/admin/
Email: admin@localhost
Password: 1234
======================================================================
```
---
## Доступ к админке тенанта
После создания тенанта доступ к его админ-панели:
**URL:** `http://{schema_name}.localhost:8000/admin/`
**Credentials:**
- Email: значение из `TENANT_ADMIN_EMAIL` (.env)
- Password: значение из `TENANT_ADMIN_PASSWORD` (.env)
### Пример для тенанта "grach":
```
URL: http://grach.localhost:8000/admin/
Email: admin@localhost
Password: 1234
```
### Пример для тенанта "mixflowers":
```
URL: http://mixflowers.localhost:8000/admin/
Email: admin@localhost
Password: 1234
```
---
## Создание дополнительных суперпользователей
Если нужно создать еще одного суперпользователя для конкретного тенанта, используйте скрипт [switch_to_tenant.py](myproject/switch_to_tenant.py):
```bash
cd c:\Users\team_\Desktop\test_qwen\myproject
"c:\Users\team_\Desktop\test_qwen\venv\Scripts\python.exe" switch_to_tenant.py grach
```
Откроется интерактивная оболочка Python в контексте тенанта "grach":
```python
# Вы уже находитесь в схеме тенанта
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.create_superuser(
email='another_admin@localhost',
name='Another Admin',
password='password123'
)
print(f'Создан пользователь: {user.email}')
```
---
## Технические детали
### Модель пользователя
Проект использует кастомную модель пользователя [CustomUser](myproject/accounts/models.py):
- **USERNAME_FIELD** = `email` (вход по email, а не username)
- **REQUIRED_FIELDS** = `['name']` (обязательно имя)
- Username автоматически = email для совместимости
### Метод создания суперпользователя
```python
User.objects.create_superuser(
email='admin@localhost', # из TENANT_ADMIN_EMAIL
name='Admin', # из TENANT_ADMIN_NAME
password='1234' # из TENANT_ADMIN_PASSWORD
)
```
### Переключение между схемами
```python
from django.db import connection
from tenants.models import Client
# Переключиться на тенанта
client = Client.objects.get(schema_name='grach')
connection.set_tenant(client)
# Теперь все запросы к БД идут в схему "grach"
User.objects.all() # Пользователи тенанта "grach"
# Вернуться в public схему
public_tenant = Client.objects.get(schema_name='public')
connection.set_tenant(public_tenant)
```
---
## Безопасность
### Для локальной разработки
Текущие настройки подходят:
```env
TENANT_ADMIN_EMAIL=admin@localhost
TENANT_ADMIN_PASSWORD=1234
TENANT_ADMIN_NAME=Admin
```
### Для продакшена
**ОБЯЗАТЕЛЬНО измените:**
1. **Пароль:**
```env
TENANT_ADMIN_PASSWORD=сложный-случайный-пароль-min-16-символов
```
2. **Email:**
```env
TENANT_ADMIN_EMAIL=admin@yourdomain.com
```
3. **Дополнительно:**
- Включите двухфакторную аутентификацию (2FA)
- Настройте IP whitelist для админки
- Используйте HTTPS
- Регулярно меняйте пароль
---
## Частые вопросы
### Q: Как изменить пароль для существующих тенантов?
A: Используйте скрипт `switch_to_tenant.py`:
```bash
python switch_to_tenant.py grach
```
Затем в интерактивной оболочке:
```python
from django.contrib.auth import get_user_model
User = get_user_model()
user = User.objects.get(email='admin@localhost')
user.set_password('новый-пароль')
user.save()
print(f'Пароль обновлен для {user.email}')
```
### Q: Что если я забыл пароль от админки тенанта?
A: Используйте тот же метод что выше для сброса пароля.
### Q: Можно ли использовать разные пароли для разных тенантов?
A: Сейчас все тенанты получают одинаковые credentials из `.env`. Если нужны уникальные пароли для каждого тенанта:
1. Вариант A: Генерируйте случайный пароль при создании и сохраняйте в notes тенанта
2. Вариант B: Отправляйте credentials на email владельца
3. Вариант C: Требуйте смены пароля при первом входе
### Q: Как дать доступ владельцу магазина?
A: Есть несколько вариантов:
**Вариант 1:** Использовать тот же email `admin@localhost` (быстро для разработки)
**Вариант 2:** Создать отдельного пользователя для владельца:
```python
python switch_to_tenant.py grach
# В оболочке:
from django.contrib.auth import get_user_model
User = get_user_model()
owner = User.objects.create_superuser(
email='owner@grach.com', # Email владельца из заявки
name='Владелец магазина',
password='временный-пароль'
)
```
Затем отправьте владельцу:
- URL: `http://grach.localhost:8000/admin/`
- Email: `owner@grach.com`
- Password: `временный-пароль`
- Попросите сменить пароль при первом входе
---
## Обновленные файлы
1. [.env](myproject/.env) - переменные окружения (НЕ коммитить!)
2. [.env.example](myproject/.env.example) - шаблон для разработчиков
3. [settings.py](myproject/myproject/settings.py) - подключен django-environ
4. [tenants/admin.py](myproject/tenants/admin.py) - автосоздание суперпользователя
5. [activate_tenant.py](myproject/activate_tenant.py) - универсальный скрипт активации
6. [.gitignore](myproject/.gitignore) - защита секретов
---
## Примеры использования
### Сценарий 1: Активация новой заявки через админку
```
1. http://localhost:8000/admin/ → вход как супер-админ
2. Заявки на регистрацию → найти pending заявку
3. Нажать "Активировать"
4. Готово! Доступ: http://{schema_name}.localhost:8000/admin/
```
### Сценарий 2: Активация через скрипт
```bash
cd c:\Users\team_\Desktop\test_qwen\myproject
"c:\Users\team_\Desktop\test_qwen\venv\Scripts\python.exe" activate_tenant.py myshop
```
### Сценарий 3: Вход в админку тенанта
```
1. Открыть: http://myshop.localhost:8000/admin/
2. Email: admin@localhost
3. Password: 1234
4. Готово!
```
---
**Вопросы?** Проверьте логи Django или обратитесь к документации по django-tenants.

View File

@@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
"""
Скрипт для активации заявки mixflowers
"""
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()
from django.db import transaction
from django.utils import timezone
from tenants.models import TenantRegistration, Client, Domain, Subscription
# Ищем заявку
registration = TenantRegistration.objects.get(schema_name='mixflowers')
print(f'Найдена заявка: {registration.shop_name} ({registration.schema_name})')
print(f'Статус: {registration.get_status_display()}')
print(f'Email: {registration.owner_email}')
print('')
with transaction.atomic():
# Создаем тенант
print(f'Создание тенанта: {registration.schema_name}')
client = Client.objects.create(
schema_name=registration.schema_name,
name=registration.shop_name,
owner_email=registration.owner_email,
owner_name=registration.owner_name,
phone=registration.phone,
is_active=True
)
print(f'[OK] Тенант создан (ID: {client.id})')
# Создаем домен
domain_name = f"{registration.schema_name}.localhost"
print(f'Создание домена: {domain_name}')
domain = Domain.objects.create(
domain=domain_name,
tenant=client,
is_primary=True
)
print(f'[OK] Домен создан (ID: {domain.id})')
# Создаем триальную подписку
print('Создание триальной подписки на 90 дней')
subscription = Subscription.create_trial(client)
print(f'[OK] Подписка создана (ID: {subscription.id})')
print(f' Истекает: {subscription.expires_at.strftime("%Y-%m-%d")} ({subscription.days_left()} дней)')
# Создаем суперпользователя для тенанта
print('Создание суперпользователя для тенанта')
from django.db import connection
from django.contrib.auth import get_user_model
from django.conf import settings
# Переключаемся на схему тенанта
connection.set_tenant(client)
User = get_user_model()
if not User.objects.filter(email=settings.TENANT_ADMIN_EMAIL).exists():
superuser = User.objects.create_superuser(
email=settings.TENANT_ADMIN_EMAIL,
name=settings.TENANT_ADMIN_NAME,
password=settings.TENANT_ADMIN_PASSWORD
)
print(f'[OK] Суперпользователь создан (ID: {superuser.id})')
print(f' Email: {superuser.email}')
print(f' Password: {settings.TENANT_ADMIN_PASSWORD}')
else:
print(f'[SKIP] Пользователь с email {settings.TENANT_ADMIN_EMAIL} уже существует')
# Возвращаемся в public схему
public_tenant = Client.objects.get(schema_name='public')
connection.set_tenant(public_tenant)
# Обновляем заявку
registration.status = TenantRegistration.STATUS_APPROVED
registration.processed_at = timezone.now()
registration.processed_by = None
registration.tenant = client
registration.save()
print('[OK] Заявка обновлена')
print('')
print('=' * 60)
print('АКТИВАЦИЯ ЗАВЕРШЕНА УСПЕШНО!')
print('=' * 60)
print(f'Магазин: {client.name}')
print(f'Schema: {client.schema_name}')
print(f'Домен: http://{domain_name}:8000/')
print(f'Подписка до: {subscription.expires_at.strftime("%Y-%m-%d")} ({subscription.days_left()} дней)')
print('')
print('Доступ к админке:')
print(f' URL: http://{domain_name}:8000/admin/')
print(f' Email: {settings.TENANT_ADMIN_EMAIL}')
print(f' Password: {settings.TENANT_ADMIN_PASSWORD}')

View File

@@ -0,0 +1,176 @@
# -*- coding: utf-8 -*-
"""
Универсальный скрипт для активации заявки на создание тенанта.
Использование:
python activate_tenant.py <schema_name>
Примеры:
python activate_tenant.py grach
python activate_tenant.py myshop
Скрипт выполняет:
1. Находит заявку по schema_name
2. Создает тенант (Client)
3. Создает домен ({schema_name}.localhost)
4. Создает триальную подписку (90 дней)
5. Создает суперпользователя (credentials из .env)
6. Обновляет статус заявки на "Одобрено"
"""
import os
import sys
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()
from django.db import transaction, connection
from django.utils import timezone
from django.contrib.auth import get_user_model
from django.conf import settings
from tenants.models import TenantRegistration, Client, Domain, Subscription
def print_usage():
"""Вывод справки по использованию"""
print("Использование: python activate_tenant.py <schema_name>")
print("")
print("Примеры:")
print(" python activate_tenant.py grach")
print(" python activate_tenant.py myshop")
print("")
print("Доступные заявки (со статусом 'pending'):")
pending_regs = TenantRegistration.objects.filter(status=TenantRegistration.STATUS_PENDING)
if pending_regs.exists():
for reg in pending_regs:
print(f" - {reg.schema_name}: {reg.shop_name} ({reg.owner_email})")
else:
print(" Нет заявок, ожидающих активации")
def activate_tenant(schema_name):
"""Активация тенанта по schema_name"""
# Ищем заявку
try:
registration = TenantRegistration.objects.get(schema_name=schema_name)
except TenantRegistration.DoesNotExist:
print(f"Ошибка: Заявка с schema_name '{schema_name}' не найдена")
print("")
print_usage()
return False
print(f'Найдена заявка: {registration.shop_name} ({registration.schema_name})')
print(f'Статус: {registration.get_status_display()}')
print(f'Email: {registration.owner_email}')
print('')
# Проверяем статус
if registration.status == TenantRegistration.STATUS_APPROVED:
print(f'Внимание: Эта заявка уже была активирована!')
if registration.tenant:
print(f'Тенант: {registration.tenant.name} (ID: {registration.tenant.id})')
print(f'Домен: http://{registration.schema_name}.localhost:8000/')
return False
# Проверяем, не существует ли уже тенант
if Client.objects.filter(schema_name=schema_name).exists():
print(f'Ошибка: Тенант с schema_name "{schema_name}" уже существует!')
return False
print('Начинаю активацию...')
print('')
try:
with transaction.atomic():
# Создаем тенант
print(f'1. Создание тенанта: {registration.schema_name}')
client = Client.objects.create(
schema_name=registration.schema_name,
name=registration.shop_name,
owner_email=registration.owner_email,
owner_name=registration.owner_name,
phone=registration.phone,
is_active=True
)
print(f' [OK] Тенант создан (ID: {client.id})')
# Создаем домен
domain_name = f"{registration.schema_name}.localhost"
print(f'2. Создание домена: {domain_name}')
domain = Domain.objects.create(
domain=domain_name,
tenant=client,
is_primary=True
)
print(f' [OK] Домен создан (ID: {domain.id})')
# Создаем триальную подписку
print('3. Создание триальной подписки на 90 дней')
subscription = Subscription.create_trial(client)
print(f' [OK] Подписка создана (ID: {subscription.id})')
print(f' Истекает: {subscription.expires_at.strftime("%Y-%m-%d")} ({subscription.days_left()} дней)')
# Создаем суперпользователя для тенанта
print('4. Создание суперпользователя для тенанта')
# Переключаемся на схему тенанта
connection.set_tenant(client)
User = get_user_model()
if not User.objects.filter(email=settings.TENANT_ADMIN_EMAIL).exists():
superuser = User.objects.create_superuser(
email=settings.TENANT_ADMIN_EMAIL,
name=settings.TENANT_ADMIN_NAME,
password=settings.TENANT_ADMIN_PASSWORD
)
print(f' [OK] Суперпользователь создан (ID: {superuser.id})')
else:
print(f' [SKIP] Пользователь с email {settings.TENANT_ADMIN_EMAIL} уже существует')
# Возвращаемся в public схему
public_tenant = Client.objects.get(schema_name='public')
connection.set_tenant(public_tenant)
# Обновляем заявку
print('5. Обновление статуса заявки')
registration.status = TenantRegistration.STATUS_APPROVED
registration.processed_at = timezone.now()
registration.processed_by = None # Активировано через скрипт
registration.tenant = client
registration.save()
print(' [OK] Заявка помечена как "Одобрено"')
print('')
print('=' * 70)
print('АКТИВАЦИЯ ЗАВЕРШЕНА УСПЕШНО!')
print('=' * 70)
print(f'Магазин: {client.name}')
print(f'Schema: {client.schema_name}')
print(f'Домен: http://{domain_name}:8000/')
print(f'Подписка до: {subscription.expires_at.strftime("%Y-%m-%d")} ({subscription.days_left()} дней)')
print('')
print('Доступ к админке тенанта:')
print(f' URL: http://{domain_name}:8000/admin/')
print(f' Email: {settings.TENANT_ADMIN_EMAIL}')
print(f' Password: {settings.TENANT_ADMIN_PASSWORD}')
print('=' * 70)
return True
except Exception as e:
print('')
print(f'Ошибка при активации: {str(e)}')
import traceback
traceback.print_exc()
return False
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Ошибка: Не указан schema_name")
print("")
print_usage()
sys.exit(1)
schema_name = sys.argv[1]
success = activate_tenant(schema_name)
sys.exit(0 if success else 1)

View File

@@ -1,51 +1,94 @@
# -*- coding: utf-8 -*-
"""
Django settings for myproject project.
Django settings for myproject project with django-tenants support.
Generated by 'django-admin startproject' using Django 5.2.7.
This is a multi-tenant SaaS application where each shop owner gets their own subdomain
and isolated database schema.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
Example: shop1.inventory.by, shop2.inventory.by
"""
from pathlib import Path
import environ
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Initialize environment variables
env = environ.Env(
# Set casting and default values
DEBUG=(bool, True),
SECRET_KEY=(str, 'django-insecure-default-key-change-in-production'),
)
# Read .env file
environ.Env.read_env(BASE_DIR / '.env')
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-bs^tx8!&v2qx9!)i0!%*p#=kwn&@x0%r6i3&l-3z14bw%k5yj-'
SECRET_KEY = env('SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = env('DEBUG')
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*'] # Для разработки. В продакшене указать конкретные домены
# Application definition
# ============================================
# DJANGO-TENANTS CONFIGURATION
# ============================================
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
# Shared apps: доступны в public схеме (общие для всей системы)
SHARED_APPS = [
'django_tenants', # ОБЯЗАТЕЛЬНО ПЕРВЫМ!
'tenants', # Приложение с моделями тенантов
# Django встроенные приложения
'django.contrib.contenttypes',
'django.contrib.auth',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.admin',
'django.contrib.staticfiles',
'nested_admin', # Для вложенных inline в админке
# Accounts должен быть в shared для CustomUser (используется в админке)
'accounts',
'products',
'inventory',
'orders',
'customers',
]
# Tenant apps: создаются в отдельной схеме для каждого тенанта (изолированные данные)
TENANT_APPS = [
'django.contrib.contenttypes', # Дублируем для tenant схем
'django.contrib.auth', # Дублируем для tenant схем
# Приложения с бизнес-логикой (изолированные для каждого магазина)
'nested_admin',
'customers', # Клиенты магазина
'shops', # Точки магазина/самовывоза
'products', # Товары и категории
'orders', # Заказы
'inventory', # Складской учет
]
# Объединяем для INSTALLED_APPS
INSTALLED_APPS = list(SHARED_APPS) + [app for app in TENANT_APPS if app not in SHARED_APPS]
# Модели тенанта и домена
TENANT_MODEL = "tenants.Client"
TENANT_DOMAIN_MODEL = "tenants.Domain"
# Показывать tenant_id в логах (полезно для отладки)
SHOW_PUBLIC_IF_NO_TENANT_FOUND = True
# ============================================
# MIDDLEWARE
# ============================================
MIDDLEWARE = [
'django_tenants.middleware.main.TenantMainMiddleware', # ОБЯЗАТЕЛЬНО ПЕРВЫМ!
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
@@ -55,12 +98,25 @@ MIDDLEWARE = [
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
# ============================================
# URL CONFIGURATION
# ============================================
ROOT_URLCONF = 'myproject.urls'
# URL-конфигурация для public схемы (главный домен inventory.by)
PUBLIC_SCHEMA_URLCONF = 'myproject.urls_public'
# ============================================
# TEMPLATES
# ============================================
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'], # Добавили путь к шаблонам
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -75,19 +131,35 @@ TEMPLATES = [
WSGI_APPLICATION = 'myproject.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
# ============================================
# DATABASE CONFIGURATION
# ============================================
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'ENGINE': 'django_tenants.postgresql_backend', # ВАЖНО: используем backend от django-tenants
'NAME': env('DB_NAME'),
'USER': env('DB_USER'),
'PASSWORD': env('DB_PASSWORD'),
'HOST': env('DB_HOST'),
'PORT': env('DB_PORT'),
'OPTIONS': {
'client_encoding': 'UTF8',
'connect_timeout': 10,
},
'CONN_MAX_AGE': 0,
}
}
# Database router для django-tenants
DATABASE_ROUTERS = [
'django_tenants.routers.TenantSyncRouter',
]
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
# ============================================
# PASSWORD VALIDATION
# ============================================
AUTH_PASSWORD_VALIDATORS = [
{
@@ -105,34 +177,36 @@ AUTH_PASSWORD_VALIDATORS = [
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'ru-ru' # Изменили на русский
TIME_ZONE = 'Europe/Moscow' # Установили таймзону
# ============================================
# INTERNATIONALIZATION
# ============================================
LANGUAGE_CODE = 'ru-ru'
TIME_ZONE = 'Europe/Moscow'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
# ============================================
# STATIC FILES (CSS, JavaScript, Images)
# ============================================
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static'] # Добавили директорию для статических файлов
STATIC_ROOT = BASE_DIR / 'staticfiles' # Для collectstatic
STATIC_URL = '/static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
STATIC_ROOT = BASE_DIR / 'staticfiles'
# ============================================
# MEDIA FILES (User uploads)
# ============================================
# Media files (User uploads)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# ============================================
# IMAGE PROCESSING SETTINGS
# ============================================
# Конфигурация для обработки изображений товаров, комплектов и категорий
# Определяет размеры, форматы и качество для каждого типа изображения
IMAGE_PROCESSING_CONFIG = {
'formats': {
@@ -164,42 +238,49 @@ IMAGE_PROCESSING_CONFIG = {
'height': 200,
'description': 'Thumbnail (200x200, WebP format)'
},
},
'folders': {
'original': 'originals',
'large': 'large',
'medium': 'medium',
'thumbnail': 'thumbnails',
}
}
# Настройки категорий товаров
# Максимальная глубина вложенности категорий (защита от слишком глубокой иерархии)
# ============================================
# BUSINESS LOGIC SETTINGS
# ============================================
# Максимальная глубина вложенности категорий товаров
MAX_CATEGORY_DEPTH = 10
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Настройки для отправки email в консоль
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEFAULT_FROM_EMAIL = 'noreply@example.com'
# Настройки телефонных номеров
PHONENUMBER_DEFAULT_REGION = 'BY' # Регион по умолчанию для номеров без кода страны
PHONENUMBER_DEFAULT_REGION = 'BY'
# ============================================
# EMAIL SETTINGS
# ============================================
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
DEFAULT_FROM_EMAIL = 'noreply@inventory.by'
# ============================================
# AUTHENTICATION
# ============================================
# Указываем нашу кастомную модель пользователя
AUTH_USER_MODEL = 'accounts.CustomUser'
# ВРЕМЕННЫЙ ФИХ для SQLite: удалить когда база данных будет PostgreSQL
# Регистрируем кастомную функцию LOWER для поддержки кириллицы в SQLite
if 'sqlite' in DATABASES['default']['ENGINE']:
from django.db.backends.signals import connection_created
from django.dispatch import receiver
@receiver(connection_created)
def setup_sqlite_unicode_support(sender, connection, **kwargs):
"""Добавляет поддержку Unicode для LOWER() в SQLite"""
if connection.vendor == 'sqlite':
connection.connection.create_function('LOWER', 1, lambda s: s.lower() if s else s)
# ============================================
# TENANT ADMIN AUTO-CREATION
# ============================================
# При создании нового тенанта автоматически создается суперпользователь
# с указанными credentials для доступа к админке тенанта
TENANT_ADMIN_EMAIL = env('TENANT_ADMIN_EMAIL')
TENANT_ADMIN_PASSWORD = env('TENANT_ADMIN_PASSWORD')
TENANT_ADMIN_NAME = env('TENANT_ADMIN_NAME')
# ============================================
# DEFAULT SETTINGS
# ============================================
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

344
myproject/tenants/admin.py Normal file
View File

@@ -0,0 +1,344 @@
# -*- coding: utf-8 -*-
from django.contrib import admin
from django.contrib import messages
from django.utils import timezone
from django.utils.html import format_html
from django.urls import reverse
from django.db import transaction
from .models import Client, Domain, TenantRegistration, Subscription
class DomainInline(admin.TabularInline):
"""
Inline для управления доменами тенанта.
"""
model = Domain
extra = 1
fields = ['domain', 'is_primary']
@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
"""
Админ-панель для управления тенантами (магазинами).
ВАЖНО: Эта админка доступна только в Public схеме!
"""
list_display = [
'name',
'schema_name',
'owner_email',
'is_active',
'created_at',
'subscription_status',
]
list_filter = [
'is_active',
'created_at',
]
search_fields = [
'name',
'schema_name',
'owner_email',
'owner_name',
]
# Убираем schema_name из readonly при создании нового тенанта
def get_readonly_fields(self, request, obj=None):
if obj: # Редактирование существующего тенанта
return ['schema_name', 'created_at']
else: # Создание нового тенанта
return ['created_at']
fieldsets = (
('Информация о магазине', {
'fields': ('name', 'schema_name', 'is_active')
}),
('Владелец', {
'fields': ('owner_name', 'owner_email', 'phone')
}),
('Дополнительно', {
'fields': ('created_at', 'notes'),
'classes': ('collapse',)
}),
)
inlines = [DomainInline]
def subscription_status(self, obj):
"""Отображение статуса подписки"""
try:
sub = obj.subscription
if sub.is_expired():
return format_html('<span style="color: red;">Истекла {}</span>', sub.expires_at.date())
elif sub.days_left() <= 7:
return format_html('<span style="color: orange;">Осталось {} дн.</span>', sub.days_left())
else:
return format_html('<span style="color: green;">{} до {}</span>',
sub.get_plan_display(), sub.expires_at.date())
except Subscription.DoesNotExist:
return format_html('<span style="color: gray;">Нет подписки</span>')
subscription_status.short_description = "Подписка"
def has_delete_permission(self, request, obj=None):
"""
Запрещаем удаление тенантов через админку (для безопасности).
Удаление должно происходить через специальную команду.
"""
return False
@admin.register(Domain)
class DomainAdmin(admin.ModelAdmin):
"""
Админ-панель для управления доменами.
"""
list_display = [
'domain',
'tenant',
'is_primary',
]
list_filter = [
'is_primary',
]
search_fields = [
'domain',
'tenant__name',
'tenant__schema_name',
]
@admin.register(TenantRegistration)
class TenantRegistrationAdmin(admin.ModelAdmin):
"""
Админка для управления заявками на регистрацию
"""
list_display = ("shop_name", "schema_name", "owner_email", "status", "created_at", "actions_column")
list_filter = ("status", "created_at")
search_fields = ("shop_name", "schema_name", "owner_email", "owner_name")
readonly_fields = ("created_at", "processed_at", "processed_by", "tenant")
fieldsets = (
("Информация о магазине", {
"fields": ("shop_name", "schema_name")
}),
("Владелец", {
"fields": ("owner_name", "owner_email", "phone")
}),
("Статус", {
"fields": ("status", "created_at", "processed_at", "processed_by", "rejection_reason")
}),
("Результат", {
"fields": ("tenant",)
}),
)
actions = ['approve_registrations', 'reject_registrations']
def actions_column(self, obj):
"""Кнопки действий для каждой заявки"""
if obj.status == TenantRegistration.STATUS_PENDING:
approve_url = reverse('admin:tenants_tenantregistration_change', args=[obj.pk])
return format_html(
'<a class="button" href="{}?approve=1" style="background: #417690; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px; margin-right: 5px;">Активировать</a> '
'<a class="button" href="{}?reject=1" style="background: #ba2121; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">Отклонить</a>',
approve_url, approve_url
)
elif obj.status == TenantRegistration.STATUS_APPROVED:
if obj.tenant:
tenant_url = reverse('admin:tenants_client_change', args=[obj.tenant.pk])
return format_html('<a href="{}">Перейти к тенанту →</a>', tenant_url)
return "Одобрено"
else:
return "Отклонено"
actions_column.short_description = "Действия"
def approve_registrations(self, request, queryset):
"""Массовая активация заявок"""
approved_count = 0
for registration in queryset.filter(status=TenantRegistration.STATUS_PENDING):
try:
self._approve_registration(registration, request.user)
approved_count += 1
except Exception as e:
messages.error(request, f"Ошибка при активации '{registration.shop_name}': {str(e)}")
if approved_count > 0:
messages.success(request, f"Успешно активировано заявок: {approved_count}")
approve_registrations.short_description = "✓ Активировать выбранные заявки"
def reject_registrations(self, request, queryset):
"""Массовое отклонение заявок"""
rejected_count = queryset.filter(status=TenantRegistration.STATUS_PENDING).update(
status=TenantRegistration.STATUS_REJECTED,
processed_at=timezone.now(),
processed_by=request.user,
rejection_reason="Отклонено массовым действием"
)
if rejected_count > 0:
messages.success(request, f"Отклонено заявок: {rejected_count}")
reject_registrations.short_description = "✗ Отклонить выбранные заявки"
def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
"""
Обработка действий активации/отклонения через GET параметры
"""
if object_id and request.method == 'GET':
registration = TenantRegistration.objects.get(pk=object_id)
if 'approve' in request.GET and registration.status == TenantRegistration.STATUS_PENDING:
try:
self._approve_registration(registration, request.user)
messages.success(request, f"Заявка '{registration.shop_name}' успешно активирована!")
return self._redirect_to_changelist()
except Exception as e:
messages.error(request, f"Ошибка при активации: {str(e)}")
elif 'reject' in request.GET and registration.status == TenantRegistration.STATUS_PENDING:
# Обрабатываем отклонение
registration.status = TenantRegistration.STATUS_REJECTED
registration.processed_at = timezone.now()
registration.processed_by = request.user
registration.rejection_reason = "Отклонено администратором"
registration.save()
messages.warning(request, f"Заявка '{registration.shop_name}' отклонена.")
return self._redirect_to_changelist()
return super().changeform_view(request, object_id, form_url, extra_context)
@transaction.atomic
def _approve_registration(self, registration, admin_user):
"""
Активация заявки: создание тенанта, домена и триальной подписки
"""
import logging
logger = logging.getLogger(__name__)
try:
# Проверяем, не создан ли уже тенант
if Client.objects.filter(schema_name=registration.schema_name).exists():
raise ValueError(f"Тенант с schema_name '{registration.schema_name}' уже существует!")
# Создаем тенант
logger.info(f"Создание тенанта: {registration.schema_name}")
client = Client.objects.create(
schema_name=registration.schema_name,
name=registration.shop_name,
owner_email=registration.owner_email,
owner_name=registration.owner_name,
phone=registration.phone,
is_active=True
)
logger.info(f"Тенант создан: {client.id}")
# Создаем домен (для локальной разработки используем localhost)
domain_name = f"{registration.schema_name}.localhost"
logger.info(f"Создание домена: {domain_name}")
domain = Domain.objects.create(
domain=domain_name,
tenant=client,
is_primary=True
)
logger.info(f"Домен создан: {domain.id}")
# Создаем триальную подписку на 90 дней
logger.info(f"Создание триальной подписки для тенанта: {client.id}")
subscription = Subscription.create_trial(client)
logger.info(f"Подписка создана: {subscription.id}, истекает: {subscription.expires_at}")
# Автоматически создаем суперпользователя для тенанта
logger.info(f"Создание суперпользователя для тенанта: {client.id}")
from django.db import connection
from django.contrib.auth import get_user_model
from django.conf import settings
# Переключаемся на схему тенанта
connection.set_tenant(client)
User = get_user_model()
# Проверяем, не существует ли уже пользователь с таким email
if not User.objects.filter(email=settings.TENANT_ADMIN_EMAIL).exists():
superuser = User.objects.create_superuser(
email=settings.TENANT_ADMIN_EMAIL,
name=settings.TENANT_ADMIN_NAME,
password=settings.TENANT_ADMIN_PASSWORD
)
logger.info(f"Суперпользователь создан: {superuser.id} ({superuser.email})")
else:
logger.warning(f"Пользователь с email {settings.TENANT_ADMIN_EMAIL} уже существует в тенанте")
# Возвращаемся в public схему
from tenants.models import Client as TenantClient
public_tenant = TenantClient.objects.get(schema_name='public')
connection.set_tenant(public_tenant)
# Обновляем статус заявки
registration.status = TenantRegistration.STATUS_APPROVED
registration.processed_at = timezone.now()
registration.processed_by = admin_user
registration.tenant = client
registration.save()
logger.info(f"Заявка {registration.id} успешно активирована")
return client
except Exception as e:
logger.error(f"Ошибка при активации заявки {registration.id}: {str(e)}", exc_info=True)
raise # Перебрасываем исключение для отображения в админке
def _redirect_to_changelist(self):
"""Редирект на список заявок"""
from django.shortcuts import redirect
return redirect('admin:tenants_tenantregistration_changelist')
@admin.register(Subscription)
class SubscriptionAdmin(admin.ModelAdmin):
"""
Админка для управления подписками
"""
list_display = ("client", "plan", "started_at", "expires_at", "is_active", "days_left_display", "is_expired_display")
list_filter = ("plan", "is_active", "auto_renew")
search_fields = ("client__name", "client__schema_name")
readonly_fields = ("created_at", "updated_at", "days_left_display", "is_expired_display")
fieldsets = (
("Основная информация", {
"fields": ("client", "plan", "is_active", "auto_renew")
}),
("Период действия", {
"fields": ("started_at", "expires_at", "days_left_display", "is_expired_display")
}),
("Служебная информация", {
"fields": ("created_at", "updated_at")
}),
)
def days_left_display(self, obj):
"""Отображение оставшихся дней"""
days = obj.days_left()
if days == 0:
return format_html('<span style="color: red;">Истекла</span>')
elif days <= 7:
return format_html('<span style="color: orange;">{} дн.</span>', days)
else:
return format_html('{} дн.', days)
days_left_display.short_description = "Осталось"
def is_expired_display(self, obj):
"""Отображение статуса истечения"""
if obj.is_expired():
return format_html('<span style="color: red;">Да</span>')
else:
return format_html('<span style="color: green;">Нет</span>')
is_expired_display.short_description = "Истекла"