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:
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-25 13:44
|
||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
||||
|
||||
import django.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
|
||||
@@ -104,51 +104,51 @@ class Customer(models.Model):
|
||||
return discounts.get(self.loyalty_tier, 0)
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""Override to handle unique phone validation properly during updates"""
|
||||
# Run the phone number normalization again before unique validation
|
||||
"""Переопределение для корректной проверки уникальности телефона при обновлениях"""
|
||||
# Снова нормализуем номер телефона перед проверкой уникальности
|
||||
if self.phone:
|
||||
# Check for existing customers with the same phone (excluding current instance if updating)
|
||||
# Проверяем существующих клиентов с таким же телефоном (исключая текущий экземпляр при обновлении)
|
||||
existing = Customer.objects.filter(phone=self.phone)
|
||||
if self.pk:
|
||||
existing = existing.exclude(pk=self.pk)
|
||||
if existing.exists():
|
||||
raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'})
|
||||
|
||||
# Call parent validate_unique to handle other validation
|
||||
# Вызываем родительский validate_unique для обработки других проверок
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
def clean_phone(self):
|
||||
"""Custom cleaning for phone field to normalize before validation."""
|
||||
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
|
||||
if self.phone:
|
||||
try:
|
||||
# Parse the phone number to check if it's valid and normalize it
|
||||
# Парсим номер телефона, чтобы проверить его валидность и нормализовать
|
||||
raw_phone = str(self.phone)
|
||||
|
||||
# If it starts with '8' and has 11 digits, it might be Russian domestic format
|
||||
# Если начинается с '8' и имеет 11 цифр, это может быть российский формат
|
||||
if raw_phone.startswith('8') and len(raw_phone) == 11:
|
||||
# Try BY first for Belarusian numbers
|
||||
# Сначала пробуем BY для белорусских номеров
|
||||
parsed = phonenumbers.parse(raw_phone, "BY")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
# If BY doesn't work, try RU as fallback
|
||||
# Если BY не работает, пробуем RU как запасной вариант
|
||||
parsed = phonenumbers.parse(raw_phone, "RU")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
# Try to parse without country code (might already be in international format)
|
||||
# Пробуем парсить без кода страны (может быть уже в международном формате)
|
||||
parsed = phonenumbers.parse(raw_phone, None)
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
except phonenumbers.NumberParseException:
|
||||
# If parsing fails, return as is and let field validation handle it
|
||||
# Если парсинг не удался, возвращаем как есть и позволяем валидации поля обработать это
|
||||
pass
|
||||
|
||||
return self.phone
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
# Normalize phone before field validation runs
|
||||
# Нормализуем телефон перед запуском проверки полей
|
||||
if not exclude:
|
||||
exclude = []
|
||||
if 'phone' not in exclude and self.phone:
|
||||
@@ -158,33 +158,33 @@ class Customer(models.Model):
|
||||
except Exception:
|
||||
normalized = None
|
||||
if normalized:
|
||||
# assign normalized value (E.164) so PhoneNumberField sees корректный формат
|
||||
# присваиваем нормализованное значение (E.164), чтобы PhoneNumberField видел корректный формат
|
||||
self.phone = normalized
|
||||
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
def clean(self):
|
||||
"""Additional validation if needed."""
|
||||
"""Дополнительная валидация при необходимости."""
|
||||
super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Ensure phone is normalized even if save is called directly (not through form)
|
||||
# At this point, if it came through form validation, phone should already be normalized
|
||||
# But if save is called directly on the model, we still need to normalize
|
||||
# Обеспечиваем нормализацию телефона, даже если save вызывается напрямую (не через форму)
|
||||
# На данный момент, если вызов прошел через валидацию формы, телефон уже должен быть нормализован
|
||||
# Но если save вызывается непосредственно в модели, нам все равно нужно нормализовать
|
||||
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11:
|
||||
# This is likely a domestic format number that needs normalization
|
||||
# Это, вероятно, номер в формате страны, который требует нормализации
|
||||
try:
|
||||
# Try BY first for Belarusian numbers
|
||||
# Сначала пробуем BY для белорусских номеров
|
||||
parsed = phonenumbers.parse(str(self.phone), "BY")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
else:
|
||||
# If BY doesn't work, try RU as fallback
|
||||
# Если BY не работает, пробуем RU как запасной вариант
|
||||
parsed = phonenumbers.parse(str(self.phone), "RU")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
except phonenumbers.NumberParseException:
|
||||
# If parsing fails, don't change it and let the field validation handle it
|
||||
# Если парсинг не удался, не меняем и позволяем валидации поля обработать это
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
@@ -269,7 +269,7 @@ class Address(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_default:
|
||||
# If this address is being set as default, unset the default flag on other addresses for this customer
|
||||
# Если этот адрес устанавливается как адрес по умолчанию, снимаем флаг по умолчанию с других адресов этого клиента
|
||||
Address.objects.filter(customer=self.customer, is_default=True).update(is_default=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user