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:
2025-10-27 19:13:10 +03:00
parent 4b44624f86
commit 097d4ea304
43 changed files with 3186 additions and 553 deletions

View File

67
myproject/shops/admin.py Normal file
View File

@@ -0,0 +1,67 @@
from django.contrib import admin
from .models import Shop
@admin.register(Shop)
class ShopAdmin(admin.ModelAdmin):
"""
Админ-панель для управления магазинами/пунктами самовывоза.
"""
list_display = [
'name',
'full_address',
'phone',
'working_hours',
'is_active',
'is_pickup_point',
]
list_filter = [
'is_active',
'is_pickup_point',
'district',
]
search_fields = [
'name',
'street',
'building_number',
'phone',
'email',
]
fieldsets = (
('Основная информация', {
'fields': ('name', 'description')
}),
('Адрес', {
'fields': ('street', 'building_number', 'district')
}),
('Контакты', {
'fields': ('phone', 'email')
}),
('Режим работы', {
'fields': ('opening_time', 'closing_time', 'working_days')
}),
('Настройки', {
'fields': ('is_active', 'is_pickup_point')
}),
('Дополнительно', {
'fields': ('delivery_instructions', 'latitude', 'longitude'),
'classes': ('collapse',)
}),
)
readonly_fields = ['created_at', 'updated_at']
def get_fieldsets(self, request, obj=None):
"""Добавляем временные метки для существующих объектов"""
fieldsets = super().get_fieldsets(request, obj)
if obj: # Если объект уже существует
fieldsets = fieldsets + (
('Системная информация', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
return fieldsets

6
myproject/shops/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ShopsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'shops'

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.1.4 on 2025-10-26 22:44
import phonenumber_field.modelfields
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Shop',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, verbose_name='Название магазина')),
('street', models.CharField(max_length=255, verbose_name='Улица')),
('building_number', models.CharField(max_length=20, verbose_name='Номер здания')),
('district', models.CharField(blank=True, help_text='Район в Минске', max_length=100, null=True, verbose_name='Район')),
('phone', phonenumber_field.modelfields.PhoneNumberField(help_text='Контактный телефон магазина', max_length=128, region=None, verbose_name='Телефон')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Email')),
('opening_time', models.TimeField(help_text='Время начала работы магазина', verbose_name='Время открытия')),
('closing_time', models.TimeField(help_text='Время окончания работы магазина', verbose_name='Время закрытия')),
('working_days', models.CharField(default='Пн-Вс', help_text='Например: Пн-Пт, Пн-Вс, Пн-Сб', max_length=100, verbose_name='Рабочие дни')),
('is_active', models.BooleanField(default=True, help_text='Работает ли магазин в данный момент', verbose_name='Активен')),
('is_pickup_point', models.BooleanField(default=True, help_text='Доступен ли магазин для самовывоза заказов', verbose_name='Пункт самовывоза')),
('description', models.TextField(blank=True, help_text='Дополнительная информация о магазине', null=True, verbose_name='Описание')),
('delivery_instructions', models.TextField(blank=True, help_text='Как найти магазин, где припарковаться и т.д.', null=True, verbose_name='Инструкции для клиентов')),
('latitude', models.DecimalField(blank=True, decimal_places=6, help_text='Координаты для отображения на карте', max_digits=9, null=True, verbose_name='Широта')),
('longitude', models.DecimalField(blank=True, decimal_places=6, help_text='Координаты для отображения на карте', max_digits=9, null=True, verbose_name='Долгота')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
],
options={
'verbose_name': 'Магазин',
'verbose_name_plural': 'Магазины',
'ordering': ['name'],
'indexes': [models.Index(fields=['is_active'], name='shops_shop_is_acti_bbb154_idx'), models.Index(fields=['is_pickup_point'], name='shops_shop_is_pick_a04981_idx'), models.Index(fields=['district'], name='shops_shop_distric_04626c_idx')],
},
),
]

View File

142
myproject/shops/models.py Normal file
View File

@@ -0,0 +1,142 @@
from django.db import models
from phonenumber_field.modelfields import PhoneNumberField
class Shop(models.Model):
"""
Модель магазина/пункта самовывоза для цветочного магазина в Минске.
"""
name = models.CharField(
max_length=200,
verbose_name="Название магазина"
)
# Адрес магазина
street = models.CharField(
max_length=255,
verbose_name="Улица"
)
building_number = models.CharField(
max_length=20,
verbose_name="Номер здания"
)
district = models.CharField(
max_length=100,
blank=True,
null=True,
verbose_name="Район",
help_text="Район в Минске"
)
# Контактная информация
phone = PhoneNumberField(
verbose_name="Телефон",
help_text="Контактный телефон магазина"
)
email = models.EmailField(
blank=True,
null=True,
verbose_name="Email"
)
# Режим работы
opening_time = models.TimeField(
verbose_name="Время открытия",
help_text="Время начала работы магазина"
)
closing_time = models.TimeField(
verbose_name="Время закрытия",
help_text="Время окончания работы магазина"
)
working_days = models.CharField(
max_length=100,
default="Пн-Вс",
verbose_name="Рабочие дни",
help_text="Например: Пн-Пт, Пн-Вс, Пн-Сб"
)
# Статусы и настройки
is_active = models.BooleanField(
default=True,
verbose_name="Активен",
help_text="Работает ли магазин в данный момент"
)
is_pickup_point = models.BooleanField(
default=True,
verbose_name="Пункт самовывоза",
help_text="Доступен ли магазин для самовывоза заказов"
)
# Дополнительная информация
description = models.TextField(
blank=True,
null=True,
verbose_name="Описание",
help_text="Дополнительная информация о магазине"
)
delivery_instructions = models.TextField(
blank=True,
null=True,
verbose_name="Инструкции для клиентов",
help_text="Как найти магазин, где припарковаться и т.д."
)
# Координаты для карты (опционально)
latitude = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True,
verbose_name="Широта",
help_text="Координаты для отображения на карте"
)
longitude = models.DecimalField(
max_digits=9,
decimal_places=6,
null=True,
blank=True,
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 = "Магазины"
indexes = [
models.Index(fields=['is_active']),
models.Index(fields=['is_pickup_point']),
models.Index(fields=['district']),
]
ordering = ['name']
def __str__(self):
return f"{self.name} ({self.full_address})"
@property
def full_address(self):
"""Полный адрес магазина"""
return f"{self.street}, {self.building_number}"
@property
def working_hours(self):
"""Форматированный режим работы"""
return f"{self.working_days}: {self.opening_time.strftime('%H:%M')} - {self.closing_time.strftime('%H:%M')}"

3
myproject/shops/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
myproject/shops/views.py Normal file
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.