feat: Добавить систему мультитенантности с регистрацией магазинов
Реализована полноценная система мультитенантности на базе django-tenants. Каждый магазин получает изолированную схему БД и поддомен. Основные компоненты: Django-tenants интеграция: - Модели Client (тенант) и Domain в приложении tenants/ - Разделение на SHARED_APPS и TENANT_APPS - Public schema для общей админки - Tenant schemas для изолированных данных магазинов Система регистрации магазинов: - Публичная форма регистрации на /register/ - Модель TenantRegistration для заявок со статусами (pending/approved/rejected) - Валидация schema_name (латиница, 3-63 символа, уникальность) - Проверка на зарезервированные имена (admin, api, www и т.д.) - Админ-панель для модерации заявок с кнопками активации/отклонения Система подписок: - Модель Subscription с планами (триал 90 дней, месяц, квартал, год) - Автоматическое создание триальной подписки при активации - Методы is_expired() и days_left() для проверки статуса - Цветовая индикация в админке (зеленый/оранжевый/красный) Приложения: - tenants/ - управление тенантами, регистрация, подписки - shops/ - точки магазинов/самовывоза (tenant app) - Обновлены миграции для всех приложений Утилиты: - switch_to_tenant.py - переключение между схемами тенантов - Обновлены image_processor и image_service Конфигурация: - urls_public.py - роуты для public schema (админка + регистрация) - urls.py - роуты для tenant schemas (магазины) - requirements.txt - добавлены django-tenants, django-environ, phonenumber-field Документация: - DJANGO_TENANTS_SETUP.md - настройка мультитенантности - TENANT_REGISTRATION_GUIDE.md - руководство по регистрации - QUICK_START.md - быстрый старт - START_HERE.md - общая документация Использование: 1. Пользователь: http://localhost:8000/register/ → заполняет форму 2. Админ: http://localhost:8000/admin/ → активирует заявку 3. Результат: http://{schema_name}.localhost:8000/ - готовый магазин 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
0
myproject/tenants/__init__.py
Normal file
0
myproject/tenants/__init__.py
Normal file
8
myproject/tenants/apps.py
Normal file
8
myproject/tenants/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TenantsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'tenants'
|
||||
verbose_name = 'Управление тенантами'
|
||||
115
myproject/tenants/forms.py
Normal file
115
myproject/tenants/forms.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import TenantRegistration, Client, RESERVED_SCHEMA_NAMES
|
||||
|
||||
|
||||
class TenantRegistrationForm(forms.ModelForm):
|
||||
"""
|
||||
Форма регистрации нового тенанта
|
||||
"""
|
||||
class Meta:
|
||||
model = TenantRegistration
|
||||
fields = ['shop_name', 'schema_name', 'owner_name', 'owner_email', 'phone']
|
||||
widgets = {
|
||||
'shop_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Название вашего магазина',
|
||||
'required': True
|
||||
}),
|
||||
'schema_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'myshop',
|
||||
'required': True,
|
||||
'pattern': '[a-z0-9][a-z0-9\-]{1,61}[a-z0-9]',
|
||||
'title': 'Только латинские буквы в нижнем регистре, цифры и дефис (3-63 символа)'
|
||||
}),
|
||||
'owner_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Иван Иванов',
|
||||
'required': True
|
||||
}),
|
||||
'owner_email': forms.EmailInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'your@email.com',
|
||||
'required': True
|
||||
}),
|
||||
'phone': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '+375291234567',
|
||||
'required': True
|
||||
}),
|
||||
}
|
||||
labels = {
|
||||
'shop_name': 'Название магазина',
|
||||
'schema_name': 'Желаемый поддомен',
|
||||
'owner_name': 'Ваше имя',
|
||||
'owner_email': 'Email',
|
||||
'phone': 'Телефон',
|
||||
}
|
||||
help_texts = {
|
||||
'schema_name': 'Будет использоваться как поддомен: yourshop.inventory.by. '
|
||||
'Только латинские буквы, цифры и дефис (3-63 символа)',
|
||||
'owner_email': 'На этот email придет уведомление об активации',
|
||||
}
|
||||
|
||||
def clean_schema_name(self):
|
||||
"""
|
||||
Валидация поддомена:
|
||||
1. Приводим к нижнему регистру
|
||||
2. Проверяем длину
|
||||
3. Проверяем, что не зарезервировано
|
||||
4. Проверяем уникальность
|
||||
"""
|
||||
schema_name = self.cleaned_data.get('schema_name', '').lower().strip()
|
||||
|
||||
# Проверка длины
|
||||
if len(schema_name) < 3:
|
||||
raise ValidationError("Поддомен должен содержать минимум 3 символа.")
|
||||
|
||||
if len(schema_name) > 63:
|
||||
raise ValidationError("Поддомен не может быть длиннее 63 символов.")
|
||||
|
||||
# Проверка на зарезервированные имена
|
||||
if schema_name in RESERVED_SCHEMA_NAMES:
|
||||
raise ValidationError(f"Поддомен '{schema_name}' зарезервирован системой. Выберите другой.")
|
||||
|
||||
# Проверка уникальности в заявках
|
||||
if TenantRegistration.objects.filter(schema_name=schema_name).exists():
|
||||
raise ValidationError(f"Поддомен '{schema_name}' уже занят. Выберите другой.")
|
||||
|
||||
# Проверка уникальности в существующих тенантах
|
||||
if Client.objects.filter(schema_name=schema_name).exists():
|
||||
raise ValidationError(f"Поддомен '{schema_name}' уже занят. Выберите другой.")
|
||||
|
||||
return schema_name
|
||||
|
||||
def clean_owner_email(self):
|
||||
"""
|
||||
Валидация email: проверка на дубликаты для обычных пользователей
|
||||
(супер-админ может иметь несколько тенантов)
|
||||
"""
|
||||
email = self.cleaned_data.get('owner_email', '').lower().strip()
|
||||
|
||||
# Проверяем, есть ли активные заявки с таким email
|
||||
pending_registrations = TenantRegistration.objects.filter(
|
||||
owner_email=email,
|
||||
status=TenantRegistration.STATUS_PENDING
|
||||
).count()
|
||||
|
||||
if pending_registrations > 0:
|
||||
raise ValidationError(
|
||||
f"У вас уже есть заявка на регистрацию с email {email}, ожидающая проверки. "
|
||||
"Дождитесь активации или свяжитесь с поддержкой."
|
||||
)
|
||||
|
||||
# Проверяем количество существующих тенантов с таким email
|
||||
# Если больше 0 - это нормально для админов, но для обычных пользователей показываем предупреждение
|
||||
existing_tenants_count = Client.objects.filter(owner_email=email).count()
|
||||
|
||||
if existing_tenants_count > 0:
|
||||
# Добавляем предупреждение, но не блокируем (на случай если это супер-админ)
|
||||
# В реальности здесь можно добавить более сложную логику
|
||||
pass
|
||||
|
||||
return email
|
||||
0
myproject/tenants/management/__init__.py
Normal file
0
myproject/tenants/management/__init__.py
Normal file
0
myproject/tenants/management/commands/__init__.py
Normal file
0
myproject/tenants/management/commands/__init__.py
Normal file
145
myproject/tenants/management/commands/activate_registration.py
Normal file
145
myproject/tenants/management/commands/activate_registration.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Management команда для ручной активации заявки на регистрацию тенанта.
|
||||
|
||||
Использование:
|
||||
python manage.py activate_registration <schema_name>
|
||||
|
||||
Пример:
|
||||
python manage.py activate_registration mixflowers
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from tenants.models import TenantRegistration, Client, Domain, Subscription
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Активировать заявку на регистрацию тенанта вручную'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'schema_name',
|
||||
type=str,
|
||||
help='Schema name заявки для активации'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Принудительная активация (пересоздать тенант если уже существует)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
schema_name = options['schema_name']
|
||||
force = options.get('force', False)
|
||||
|
||||
try:
|
||||
# Ищем заявку
|
||||
registration = TenantRegistration.objects.get(schema_name=schema_name)
|
||||
except TenantRegistration.DoesNotExist:
|
||||
raise CommandError(f'Заявка с schema_name "{schema_name}" не найдена')
|
||||
|
||||
self.stdout.write(f'Найдена заявка: {registration.shop_name} ({registration.schema_name})')
|
||||
self.stdout.write(f'Статус: {registration.get_status_display()}')
|
||||
self.stdout.write(f'Email: {registration.owner_email}')
|
||||
self.stdout.write('')
|
||||
|
||||
# Проверяем статус
|
||||
if registration.status == TenantRegistration.STATUS_APPROVED and not force:
|
||||
self.stdout.write(self.style.WARNING('Эта заявка уже была одобрена!'))
|
||||
if registration.tenant:
|
||||
self.stdout.write(f'Связанный тенант: {registration.tenant.name} (ID: {registration.tenant.id})')
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING('Но тенант не был создан. Используйте --force для пересоздания'))
|
||||
if not self._confirm('Продолжить с --force?'):
|
||||
return
|
||||
force = True
|
||||
|
||||
# Проверяем существование тенанта
|
||||
existing_client = Client.objects.filter(schema_name=schema_name).first()
|
||||
if existing_client and not force:
|
||||
raise CommandError(
|
||||
f'Тенант с schema_name "{schema_name}" уже существует (ID: {existing_client.id}). '
|
||||
'Используйте --force для пересоздания (ОПАСНО: удалит все данные!)'
|
||||
)
|
||||
|
||||
# Активируем
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.WARNING('НАЧИНАЮ АКТИВАЦИЮ...'))
|
||||
self.stdout.write('')
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Если force - удаляем старый тенант
|
||||
if force and existing_client:
|
||||
self.stdout.write(self.style.WARNING(f'Удаление существующего тенанта {existing_client.id}...'))
|
||||
# Удаляем подписку
|
||||
try:
|
||||
existing_client.subscription.delete()
|
||||
except:
|
||||
pass
|
||||
# Удаляем домены
|
||||
Domain.objects.filter(tenant=existing_client).delete()
|
||||
# Удаляем тенант (это также удалит схему из PostgreSQL)
|
||||
existing_client.delete()
|
||||
self.stdout.write(self.style.SUCCESS('Старый тенант удален'))
|
||||
|
||||
# Создаем тенант
|
||||
self.stdout.write(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
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Тенант создан (ID: {client.id})'))
|
||||
|
||||
# Создаем домен
|
||||
domain_name = f"{registration.schema_name}.localhost"
|
||||
self.stdout.write(f'Создание домена: {domain_name}')
|
||||
domain = Domain.objects.create(
|
||||
domain=domain_name,
|
||||
tenant=client,
|
||||
is_primary=True
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Домен создан (ID: {domain.id})'))
|
||||
|
||||
# Создаем триальную подписку
|
||||
self.stdout.write('Создание триальной подписки на 90 дней')
|
||||
subscription = Subscription.create_trial(client)
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'✓ Подписка создана (ID: {subscription.id}), '
|
||||
f'истекает: {subscription.expires_at.strftime("%Y-%m-%d")}'
|
||||
))
|
||||
|
||||
# Обновляем заявку
|
||||
registration.status = TenantRegistration.STATUS_APPROVED
|
||||
registration.processed_at = timezone.now()
|
||||
registration.processed_by = None # Активировано через команду
|
||||
registration.tenant = client
|
||||
registration.save()
|
||||
self.stdout.write(self.style.SUCCESS('✓ Заявка обновлена'))
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write(self.style.SUCCESS('АКТИВАЦИЯ ЗАВЕРШЕНА УСПЕШНО!'))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write('')
|
||||
self.stdout.write(f'Магазин: {client.name}')
|
||||
self.stdout.write(f'Schema: {client.schema_name}')
|
||||
self.stdout.write(f'Домен: http://{domain_name}:8000/')
|
||||
self.stdout.write(f'Подписка до: {subscription.expires_at.strftime("%Y-%m-%d")} ({subscription.days_left()} дней)')
|
||||
self.stdout.write('')
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'ОШИБКА: {str(e)}'))
|
||||
import traceback
|
||||
self.stdout.write(traceback.format_exc())
|
||||
raise CommandError('Активация не удалась')
|
||||
|
||||
def _confirm(self, question):
|
||||
"""Запрос подтверждения у пользователя"""
|
||||
answer = input(f'{question} (yes/no): ')
|
||||
return answer.lower() in ['yes', 'y', 'да']
|
||||
158
myproject/tenants/management/commands/create_tenant.py
Normal file
158
myproject/tenants/management/commands/create_tenant.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Management команда для создания нового тенанта (магазина).
|
||||
|
||||
Использование:
|
||||
python manage.py create_tenant
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from tenants.models import Client, Domain
|
||||
import re
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Создать нового тенанта (магазин) с собственной схемой БД'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS('\n=== Создание нового магазина (тенанта) ===\n'))
|
||||
|
||||
# Получаем данные от пользователя
|
||||
name = self.get_shop_name()
|
||||
schema_name = self.get_schema_name()
|
||||
domain_name = self.get_domain_name(schema_name)
|
||||
owner_name = self.get_owner_name()
|
||||
owner_email = self.get_owner_email()
|
||||
phone = input('Телефон владельца (опционально): ').strip()
|
||||
|
||||
# Подтверждение
|
||||
self.stdout.write('\n' + '='*60)
|
||||
self.stdout.write(self.style.WARNING('Проверьте введенные данные:'))
|
||||
self.stdout.write(f'Название магазина: {name}')
|
||||
self.stdout.write(f'Схема БД: {schema_name}')
|
||||
self.stdout.write(f'Домен: {domain_name}')
|
||||
self.stdout.write(f'Владелец: {owner_name} ({owner_email})')
|
||||
if phone:
|
||||
self.stdout.write(f'Телефон: {phone}')
|
||||
self.stdout.write('='*60 + '\n')
|
||||
|
||||
confirm = input('Создать магазин? (yes/no): ').strip().lower()
|
||||
if confirm not in ['yes', 'y', 'да']:
|
||||
self.stdout.write(self.style.ERROR('Отменено'))
|
||||
return
|
||||
|
||||
# Создаем тенанта
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Создаем тенанта
|
||||
self.stdout.write('Создание тенанта...')
|
||||
tenant = Client.objects.create(
|
||||
schema_name=schema_name,
|
||||
name=name,
|
||||
owner_name=owner_name,
|
||||
owner_email=owner_email,
|
||||
phone=phone if phone else None,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Тенант создан: {tenant}'))
|
||||
|
||||
# Создаем домен
|
||||
self.stdout.write(f'Создание домена {domain_name}...')
|
||||
domain = Domain.objects.create(
|
||||
domain=domain_name,
|
||||
tenant=tenant,
|
||||
is_primary=True
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Домен создан: {domain}'))
|
||||
|
||||
self.stdout.write('\n' + '='*60)
|
||||
self.stdout.write(self.style.SUCCESS('✓ Магазин успешно создан!'))
|
||||
self.stdout.write('='*60 + '\n')
|
||||
|
||||
self.stdout.write(self.style.WARNING('Следующие шаги:'))
|
||||
self.stdout.write(f'1. Добавьте в hosts файл: 127.0.0.1 {domain_name}')
|
||||
self.stdout.write(f'2. Откройте в браузере: http://{domain_name}:8000/admin/')
|
||||
self.stdout.write(f'3. Схема БД "{schema_name}" создана автоматически')
|
||||
self.stdout.write(f'4. Все таблицы тенанта созданы в схеме "{schema_name}"')
|
||||
self.stdout.write('')
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'\n✗ Ошибка при создании тенанта: {e}'))
|
||||
raise
|
||||
|
||||
def get_shop_name(self):
|
||||
"""Получить название магазина"""
|
||||
while True:
|
||||
name = input('Название магазина: ').strip()
|
||||
if name:
|
||||
return name
|
||||
self.stdout.write(self.style.ERROR('Название не может быть пустым'))
|
||||
|
||||
def get_schema_name(self):
|
||||
"""Получить имя схемы БД"""
|
||||
while True:
|
||||
schema = input('Имя схемы БД (латиница, цифры, подчеркивания): ').strip().lower()
|
||||
|
||||
# Валидация
|
||||
if not schema:
|
||||
self.stdout.write(self.style.ERROR('Имя схемы не может быть пустым'))
|
||||
continue
|
||||
|
||||
if not re.match(r'^[a-z0-9_]+$', schema):
|
||||
self.stdout.write(self.style.ERROR('Только латинские буквы, цифры и подчеркивания'))
|
||||
continue
|
||||
|
||||
if schema in ['public', 'postgres', 'information_schema', 'pg_catalog']:
|
||||
self.stdout.write(self.style.ERROR('Это зарезервированное имя схемы'))
|
||||
continue
|
||||
|
||||
# Проверка существования
|
||||
if Client.objects.filter(schema_name=schema).exists():
|
||||
self.stdout.write(self.style.ERROR(f'Схема "{schema}" уже существует'))
|
||||
continue
|
||||
|
||||
return schema
|
||||
|
||||
def get_domain_name(self, default_subdomain):
|
||||
"""Получить доменное имя"""
|
||||
while True:
|
||||
default_domain = f'{default_subdomain}.localhost'
|
||||
domain = input(f'Доменное имя [{default_domain}]: ').strip().lower()
|
||||
|
||||
if not domain:
|
||||
domain = default_domain
|
||||
|
||||
# Валидация
|
||||
if not re.match(r'^[a-z0-9.-]+$', domain):
|
||||
self.stdout.write(self.style.ERROR('Неверный формат домена'))
|
||||
continue
|
||||
|
||||
# Проверка существования
|
||||
if Domain.objects.filter(domain=domain).exists():
|
||||
self.stdout.write(self.style.ERROR(f'Домен "{domain}" уже используется'))
|
||||
continue
|
||||
|
||||
return domain
|
||||
|
||||
def get_owner_name(self):
|
||||
"""Получить имя владельца"""
|
||||
while True:
|
||||
name = input('Имя владельца: ').strip()
|
||||
if name:
|
||||
return name
|
||||
self.stdout.write(self.style.ERROR('Имя не может быть пустым'))
|
||||
|
||||
def get_owner_email(self):
|
||||
"""Получить email владельца"""
|
||||
while True:
|
||||
email = input('Email владельца: ').strip().lower()
|
||||
|
||||
# Простая валидация email
|
||||
if not email:
|
||||
self.stdout.write(self.style.ERROR('Email не может быть пустым'))
|
||||
continue
|
||||
|
||||
if '@' not in email or '.' not in email:
|
||||
self.stdout.write(self.style.ERROR('Неверный формат email'))
|
||||
continue
|
||||
|
||||
return email
|
||||
48
myproject/tenants/migrations/0001_initial.py
Normal file
48
myproject/tenants/migrations/0001_initial.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_tenants.postgresql_backend.base
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Client',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название магазина')),
|
||||
('owner_email', models.EmailField(help_text='Контактный email владельца магазина', max_length=254, verbose_name='Email владельца')),
|
||||
('owner_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Имя владельца')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Активна ли учетная запись магазина', verbose_name='Активен')),
|
||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон')),
|
||||
('notes', models.TextField(blank=True, help_text='Внутренние заметки администратора', null=True, verbose_name='Заметки')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тенант (Магазин)',
|
||||
'verbose_name_plural': 'Тенанты (Магазины)',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Domain',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('domain', models.CharField(db_index=True, max_length=253, unique=True)),
|
||||
('is_primary', models.BooleanField(db_index=True, default=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='tenants.client')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Домен',
|
||||
'verbose_name_plural': 'Домены',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-27 09:45
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenants', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='client',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, help_text='Активна ли учетная запись магазина (ручная блокировка админом)', verbose_name='Активен'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='client',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, max_length=200, verbose_name='Название магазина'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='client',
|
||||
name='owner_email',
|
||||
field=models.EmailField(help_text='Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)', max_length=254, verbose_name='Email владельца'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='client',
|
||||
name='phone',
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='Телефон'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('plan', models.CharField(choices=[('trial', 'Триальный (90 дней)'), ('monthly', 'Месячный'), ('quarterly', 'Квартальный (3 месяца)'), ('yearly', 'Годовой')], default='trial', max_length=20, verbose_name='План подписки')),
|
||||
('started_at', models.DateTimeField(verbose_name='Дата начала')),
|
||||
('expires_at', models.DateTimeField(verbose_name='Дата окончания')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Активна ли подписка (может быть отключена вручную)', verbose_name='Активна')),
|
||||
('auto_renew', models.BooleanField(default=False, help_text='Автоматически продлевать подписку', verbose_name='Автопродление')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('client', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='tenants.client', verbose_name='Тенант')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Подписка',
|
||||
'verbose_name_plural': 'Подписки',
|
||||
'ordering': ['-expires_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TenantRegistration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('shop_name', models.CharField(max_length=200, verbose_name='Название магазина')),
|
||||
('schema_name', models.CharField(help_text='Например: myshop (будет доступен как myshop.inventory.by)', max_length=63, unique=True, validators=[django.core.validators.RegexValidator(message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.', regex='^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$')], verbose_name='Желаемый поддомен')),
|
||||
('owner_email', models.EmailField(max_length=254, verbose_name='Email владельца')),
|
||||
('owner_name', models.CharField(max_length=200, verbose_name='Имя владельца')),
|
||||
('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='Телефон')),
|
||||
('status', models.CharField(choices=[('pending', 'Ожидает проверки'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')),
|
||||
('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата обработки')),
|
||||
('rejection_reason', models.TextField(blank=True, verbose_name='Причина отклонения')),
|
||||
('processed_by', models.ForeignKey(blank=True, help_text='Администратор, который обработал заявку', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Обработал')),
|
||||
('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Заявка на регистрацию',
|
||||
'verbose_name_plural': 'Заявки на регистрацию',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
myproject/tenants/migrations/__init__.py
Normal file
0
myproject/tenants/migrations/__init__.py
Normal file
284
myproject/tenants/models.py
Normal file
284
myproject/tenants/models.py
Normal file
@@ -0,0 +1,284 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils import timezone
|
||||
from django_tenants.models import TenantMixin, DomainMixin
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
# Зарезервированные имена схем, которые нельзя использовать
|
||||
RESERVED_SCHEMA_NAMES = [
|
||||
'public', 'admin', 'api', 'www', 'mail', 'ftp', 'smtp',
|
||||
'static', 'media', 'assets', 'cdn', 'app', 'web',
|
||||
'billing', 'register', 'login', 'logout', 'dashboard',
|
||||
'test', 'dev', 'staging', 'production', 'demo'
|
||||
]
|
||||
|
||||
|
||||
class Client(TenantMixin):
|
||||
"""
|
||||
Модель тенанта (владельца магазина).
|
||||
Каждый тенант = отдельная схема в PostgreSQL с изолированными данными.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=200,
|
||||
db_index=True,
|
||||
verbose_name="Название магазина"
|
||||
)
|
||||
|
||||
owner_email = models.EmailField(
|
||||
verbose_name="Email владельца",
|
||||
help_text="Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)"
|
||||
)
|
||||
|
||||
owner_name = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Имя владельца"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания"
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Активен",
|
||||
help_text="Активна ли учетная запись магазина (ручная блокировка админом)"
|
||||
)
|
||||
|
||||
# Дополнительные поля для будущего расширения
|
||||
phone = PhoneNumberField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Телефон"
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Заметки",
|
||||
help_text="Внутренние заметки администратора"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Тенант (Магазин)"
|
||||
verbose_name_plural = "Тенанты (Магазины)"
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.schema_name})"
|
||||
|
||||
# auto_create_schema наследуется от TenantMixin
|
||||
# auto_drop_schema наследуется от TenantMixin
|
||||
auto_create_schema = True # Автоматически создавать схему при создании тенанта
|
||||
|
||||
|
||||
class Domain(DomainMixin):
|
||||
"""
|
||||
Модель домена для тенанта.
|
||||
Связывает поддомен (например shop1.inventory.by) с тенантом.
|
||||
"""
|
||||
class Meta:
|
||||
verbose_name = "Домен"
|
||||
verbose_name_plural = "Домены"
|
||||
|
||||
def __str__(self):
|
||||
return self.domain
|
||||
|
||||
|
||||
class TenantRegistration(models.Model):
|
||||
"""
|
||||
Модель заявки на регистрацию нового тенанта.
|
||||
Заявки сначала создаются со статусом 'pending', затем админ их активирует.
|
||||
"""
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_APPROVED = 'approved'
|
||||
STATUS_REJECTED = 'rejected'
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDING, 'Ожидает проверки'),
|
||||
(STATUS_APPROVED, 'Одобрено'),
|
||||
(STATUS_REJECTED, 'Отклонено'),
|
||||
]
|
||||
|
||||
# Данные от пользователя
|
||||
shop_name = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name="Название магазина"
|
||||
)
|
||||
|
||||
schema_name = models.CharField(
|
||||
max_length=63,
|
||||
unique=True,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r'^[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?$',
|
||||
message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. '
|
||||
'Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.'
|
||||
)
|
||||
],
|
||||
verbose_name="Желаемый поддомен",
|
||||
help_text="Например: myshop (будет доступен как myshop.inventory.by)"
|
||||
)
|
||||
|
||||
owner_email = models.EmailField(
|
||||
verbose_name="Email владельца"
|
||||
)
|
||||
|
||||
owner_name = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name="Имя владельца"
|
||||
)
|
||||
|
||||
phone = PhoneNumberField(
|
||||
verbose_name="Телефон"
|
||||
)
|
||||
|
||||
# Служебные поля
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default=STATUS_PENDING,
|
||||
db_index=True,
|
||||
verbose_name="Статус"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата подачи заявки"
|
||||
)
|
||||
|
||||
processed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата обработки"
|
||||
)
|
||||
|
||||
processed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name="Обработал",
|
||||
help_text="Администратор, который обработал заявку"
|
||||
)
|
||||
|
||||
rejection_reason = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Причина отклонения"
|
||||
)
|
||||
|
||||
# Ссылка на созданный тенант (заполняется после активации)
|
||||
tenant = models.OneToOneField(
|
||||
Client,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name="Созданный тенант"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Заявка на регистрацию"
|
||||
verbose_name_plural = "Заявки на регистрацию"
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.shop_name} ({self.schema_name}) - {self.get_status_display()}"
|
||||
|
||||
|
||||
class Subscription(models.Model):
|
||||
"""
|
||||
Модель подписки тенанта.
|
||||
Определяет план подписки и срок действия.
|
||||
"""
|
||||
PLAN_TRIAL = 'trial'
|
||||
PLAN_MONTHLY = 'monthly'
|
||||
PLAN_QUARTERLY = 'quarterly'
|
||||
PLAN_YEARLY = 'yearly'
|
||||
|
||||
PLAN_CHOICES = [
|
||||
(PLAN_TRIAL, 'Триальный (90 дней)'),
|
||||
(PLAN_MONTHLY, 'Месячный'),
|
||||
(PLAN_QUARTERLY, 'Квартальный (3 месяца)'),
|
||||
(PLAN_YEARLY, 'Годовой'),
|
||||
]
|
||||
|
||||
client = models.OneToOneField(
|
||||
Client,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='subscription',
|
||||
verbose_name="Тенант"
|
||||
)
|
||||
|
||||
plan = models.CharField(
|
||||
max_length=20,
|
||||
choices=PLAN_CHOICES,
|
||||
default=PLAN_TRIAL,
|
||||
verbose_name="План подписки"
|
||||
)
|
||||
|
||||
started_at = models.DateTimeField(
|
||||
verbose_name="Дата начала"
|
||||
)
|
||||
|
||||
expires_at = models.DateTimeField(
|
||||
verbose_name="Дата окончания"
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Активна",
|
||||
help_text="Активна ли подписка (может быть отключена вручную)"
|
||||
)
|
||||
|
||||
auto_renew = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Автопродление",
|
||||
help_text="Автоматически продлевать подписку"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания"
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name="Дата обновления"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Подписка"
|
||||
verbose_name_plural = "Подписки"
|
||||
ordering = ['-expires_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.client.name} - {self.get_plan_display()} (до {self.expires_at.date()})"
|
||||
|
||||
def is_expired(self):
|
||||
"""Проверка истечения подписки"""
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
def days_left(self):
|
||||
"""Количество дней до окончания подписки"""
|
||||
if self.is_expired():
|
||||
return 0
|
||||
delta = self.expires_at - timezone.now()
|
||||
return delta.days
|
||||
|
||||
@staticmethod
|
||||
def create_trial(client):
|
||||
"""Создать триальную подписку на 90 дней"""
|
||||
now = timezone.now()
|
||||
return Subscription.objects.create(
|
||||
client=client,
|
||||
plan=Subscription.PLAN_TRIAL,
|
||||
started_at=now,
|
||||
expires_at=now + timedelta(days=90),
|
||||
is_active=True
|
||||
)
|
||||
70
myproject/tenants/templates/tenants/base.html
Normal file
70
myproject/tenants/templates/tenants/base.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Регистрация магазина{% endblock %} - Inventory System</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.card {
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 15px 15px 0 0 !important;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
.alert {
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
133
myproject/tenants/templates/tenants/registration_form.html
Normal file
133
myproject/tenants/templates/tenants/registration_form.html
Normal file
@@ -0,0 +1,133 @@
|
||||
{% extends "tenants/base.html" %}
|
||||
|
||||
{% block title %}Регистрация нового магазина{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h3 class="mb-0">Регистрация нового магазина</h3>
|
||||
<p class="mb-0 mt-2">Заполните форму для создания вашего интернет-магазина</p>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Название магазина -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.shop_name.id_for_label }}" class="form-label">
|
||||
{{ form.shop_name.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.shop_name }}
|
||||
{% if form.shop_name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.shop_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.shop_name.help_text %}
|
||||
<div class="form-text">{{ form.shop_name.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Поддомен -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.schema_name.id_for_label }}" class="form-label">
|
||||
{{ form.schema_name.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
{{ form.schema_name }}
|
||||
<span class="input-group-text">.inventory.by</span>
|
||||
</div>
|
||||
{% if form.schema_name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.schema_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.schema_name.help_text %}
|
||||
<div class="form-text">{{ form.schema_name.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Имя владельца -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.owner_name.id_for_label }}" class="form-label">
|
||||
{{ form.owner_name.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.owner_name }}
|
||||
{% if form.owner_name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.owner_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.owner_email.id_for_label }}" class="form-label">
|
||||
{{ form.owner_email.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.owner_email }}
|
||||
{% if form.owner_email.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.owner_email.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.owner_email.help_text %}
|
||||
<div class="form-text">{{ form.owner_email.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Телефон -->
|
||||
<div class="mb-4">
|
||||
<label for="{{ form.phone.id_for_label }}" class="form-label">
|
||||
{{ form.phone.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.phone }}
|
||||
{% if form.phone.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.phone.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Кнопка отправки -->
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
Отправить заявку
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">
|
||||
После отправки заявки ваш магазин будет проверен администратором.<br>
|
||||
Уведомление придет на указанный email в течение 24 часов.
|
||||
</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="/admin/" class="text-white text-decoration-none">
|
||||
<small>Войти в панель администратора</small>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Автоматическое преобразование поддомена в lowercase
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const schemaNameInput = document.getElementById('{{ form.schema_name.id_for_label }}');
|
||||
if (schemaNameInput) {
|
||||
schemaNameInput.addEventListener('input', function() {
|
||||
this.value = this.value.toLowerCase();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "tenants/base.html" %}
|
||||
|
||||
{% block title %}Заявка отправлена{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h3 class="mb-0">Спасибо за регистрацию!</h3>
|
||||
</div>
|
||||
<div class="card-body p-5 text-center">
|
||||
<div class="mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" fill="currentColor" class="bi bi-check-circle text-success" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Ваша заявка успешно отправлена!</h4>
|
||||
|
||||
<p class="lead mb-4">
|
||||
Мы получили вашу заявку на создание магазина.<br>
|
||||
Наш администратор проверит данные и активирует ваш магазин.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h5 class="alert-heading">Что дальше?</h5>
|
||||
<hr>
|
||||
<ul class="list-unstyled text-start mb-0">
|
||||
<li class="mb-2">✓ В течение 24 часов администратор проверит вашу заявку</li>
|
||||
<li class="mb-2">✓ После активации вы получите письмо на указанный email</li>
|
||||
<li class="mb-2">✓ В письме будет ссылка на ваш магазин и инструкции</li>
|
||||
<li>✓ Вам будет предоставлен триальный период на 90 дней</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'tenants:register' %}" class="btn btn-outline-primary">
|
||||
Подать еще одну заявку
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<small class="text-muted">
|
||||
Если у вас возникли вопросы, свяжитесь с нами:<br>
|
||||
<a href="mailto:support@inventory.by">support@inventory.by</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
10
myproject/tenants/urls.py
Normal file
10
myproject/tenants/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.urls import path
|
||||
from .views import TenantRegistrationView, RegistrationSuccessView
|
||||
|
||||
app_name = 'tenants'
|
||||
|
||||
urlpatterns = [
|
||||
path('register/', TenantRegistrationView.as_view(), name='register'),
|
||||
path('register/success/', RegistrationSuccessView.as_view(), name='registration_success'),
|
||||
]
|
||||
59
myproject/tenants/views.py
Normal file
59
myproject/tenants/views.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views.generic import CreateView, TemplateView
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib import messages
|
||||
from .forms import TenantRegistrationForm
|
||||
from .models import TenantRegistration
|
||||
|
||||
|
||||
class TenantRegistrationView(CreateView):
|
||||
"""
|
||||
Публичная форма регистрации нового тенанта
|
||||
"""
|
||||
model = TenantRegistration
|
||||
form_class = TenantRegistrationForm
|
||||
template_name = 'tenants/registration_form.html'
|
||||
success_url = reverse_lazy('tenants:registration_success')
|
||||
|
||||
def form_valid(self, form):
|
||||
"""
|
||||
Обработка успешной отправки формы
|
||||
"""
|
||||
response = super().form_valid(form)
|
||||
|
||||
# Добавляем сообщение об успехе
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Спасибо за регистрацию! Ваша заявка на магазин '{self.object.shop_name}' "
|
||||
f"отправлена на проверку. Мы свяжемся с вами по email {self.object.owner_email} "
|
||||
f"в ближайшее время."
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def form_invalid(self, form):
|
||||
"""
|
||||
Обработка ошибок валидации
|
||||
"""
|
||||
messages.error(
|
||||
self.request,
|
||||
"Пожалуйста, исправьте ошибки в форме."
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class RegistrationSuccessView(TemplateView):
|
||||
"""
|
||||
Страница благодарности после успешной регистрации
|
||||
"""
|
||||
template_name = 'tenants/registration_success.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Получаем последнюю заявку текущего пользователя (если есть)
|
||||
# Здесь можно добавить фильтрацию по сессии или email
|
||||
context['show_details'] = False
|
||||
|
||||
return context
|
||||
Reference in New Issue
Block a user