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

@@ -0,0 +1,302 @@
# Настройка Django Tenants для multi-tenancy
Этот проект настроен как SaaS-платформа с поддержкой multi-tenancy через django-tenants.
Каждый владелец магазина получает свой поддомен и изолированную схему БД в PostgreSQL.
## Шаг 1: Установка PostgreSQL
### Вариант A: Установка локально (Windows)
1. Скачайте PostgreSQL с https://www.postgresql.org/download/windows/
2. Установите PostgreSQL (запомните пароль для пользователя `postgres`)
3. Откройте pgAdmin или psql и создайте базу данных:
```sql
CREATE DATABASE inventory_db;
```
### Вариант B: Использование Docker (рекомендуется)
```bash
docker run --name inventory-postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=inventory_db \
-p 5432:5432 \
-d postgres:15
```
## Шаг 2: Установка зависимостей
```bash
pip install -r requirements.txt
```
Это установит:
- `django-tenants==3.7.0`
- `psycopg2-binary==2.9.10`
- и другие зависимости
## Шаг 3: Настройка подключения к БД
Откройте `myproject/settings.py` и при необходимости измените параметры подключения:
```python
DATABASES = {
'default': {
'ENGINE': 'django_tenants.postgresql_backend',
'NAME': 'inventory_db',
'USER': 'postgres',
'PASSWORD': 'postgres', # ВАШ ПАРОЛЬ
'HOST': 'localhost',
'PORT': '5432',
}
}
```
## Шаг 4: Создание миграций
```bash
# Создать миграции для всех приложений
python manage.py makemigrations
# Применить миграции для public схемы
python manage.py migrate_schemas --shared
```
## Шаг 5: Создание публичного тенанта
Django-tenants требует создания public тенанта для работы главного домена (inventory.by):
```bash
python manage.py shell
```
```python
from tenants.models import Client, Domain
# Создать public тенанта
public_tenant = Client.objects.create(
schema_name='public',
name='Главный домен',
owner_email='admin@inventory.by',
owner_name='Администратор'
)
# Создать домен для public
public_domain = Domain.objects.create(
domain='localhost', # Для локальной разработки
tenant=public_tenant,
is_primary=True
)
print(f'Public тенант создан: {public_tenant}')
print(f'Public домен создан: {public_domain}')
exit()
```
## Шаг 6: Создание суперпользователя
```bash
# Создать суперпользователя в public схеме
python manage.py createsuperuser --schema=public
```
Введите:
- Email: ваш email
- Name: ваше имя
- Password: ваш пароль
## Шаг 7: Создание тестового магазина (тенанта)
```bash
python manage.py create_tenant
```
Введите данные:
- Название магазина: Тестовый Магазин
- Схема БД: shop1
- Домен: shop1.localhost (или оставьте по умолчанию)
- Имя владельца: Иван Иванов
- Email: shop1@example.com
- Телефон: (опционально)
Команда автоматически:
1. Создаст тенанта в таблице `Client`
2. Создаст домен в таблице `Domain`
3. Создаст схему БД `shop1` в PostgreSQL
4. Применит все миграции к схеме `shop1`
## Шаг 8: Настройка hosts файла
Для локального тестирования добавьте в файл hosts:
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
**Linux/Mac**: `/etc/hosts`
```
127.0.0.1 localhost
127.0.0.1 shop1.localhost
127.0.0.1 shop2.localhost
```
## Шаг 9: Запуск сервера
```bash
python manage.py runserver 0.0.0.0:8000
```
## Шаг 10: Тестирование
### Доступ к админке супер-администратора (Public схема):
- URL: http://localhost:8000/admin/
- Логин: email и пароль суперпользователя
- Здесь вы можете управлять тенантами (магазинами)
### Доступ к админке магазина (Tenant схема):
- URL: http://shop1.localhost:8000/admin/
- Создайте суперпользователя для магазина:
```bash
python manage.py tenant_command createsuperuser --schema=shop1
```
- Здесь владелец магазина управляет своими товарами, заказами, клиентами
---
## Архитектура проекта
### Public Schema (схема `public`):
Доступна по адресу: `localhost` или `inventory.by`
**Модели:**
- `Client` - информация о тенантах (магазинах)
- `Domain` - домены тенантов
**Кто имеет доступ:**
- Супер-администратор (вы)
**Для чего:**
- Управление тенантами
- Просмотр статистики
- Биллинг (в будущем)
### Tenant Schema (схемы `shop1`, `shop2`, и т.д.):
Доступна по поддоменам: `shop1.localhost`, `shop2.localhost`
**Модели:**
- `Customer` - клиенты магазина
- `Address` - адреса клиентов
- `Shop` - точки магазина
- `Product`, `ProductKit`, `Category` - товары
- `Order`, `OrderItem` - заказы
- `Inventory` - складской учет
- `CustomUser` - сотрудники (для будущего)
**Кто имеет доступ:**
- Владелец магазина
- Сотрудники магазина (в будущем)
**Для чего:**
- Управление товарами
- Обработка заказов
- Работа с клиентами
- Складской учет
---
## Полезные команды
### Создать тенанта:
```bash
python manage.py create_tenant
```
### Применить миграции ко всем тенантам:
```bash
python manage.py migrate_schemas
```
### Применить миграции только к public:
```bash
python manage.py migrate_schemas --shared
```
### Применить миграции к конкретному тенанту:
```bash
python manage.py migrate_schemas --schema=shop1
```
### Выполнить команду для конкретного тенанта:
```bash
python manage.py tenant_command <command> --schema=shop1
```
Например:
```bash
python manage.py tenant_command createsuperuser --schema=shop1
python manage.py tenant_command loaddata data.json --schema=shop1
```
### Список всех тенантов:
```bash
python manage.py shell
```
```python
from tenants.models import Client
for tenant in Client.objects.all():
print(f'{tenant.name}: {tenant.schema_name}')
```
---
## Устранение проблем
### Ошибка: "No tenant found for hostname"
- Проверьте, что домен добавлен в hosts файл
- Проверьте, что домен существует в таблице `Domain`
- Проверьте, что вы обращаетесь к правильному поддомену
### Ошибка: "relation does not exist"
- Запустите миграции: `python manage.py migrate_schemas`
- Проверьте, что схема создана в PostgreSQL
### Ошибка подключения к PostgreSQL:
- Проверьте, что PostgreSQL запущен
- Проверьте параметры подключения в `settings.py`
- Проверьте, что база данных `inventory_db` существует
---
## Продакшн
Для продакшна (на сервере):
1. Измените `settings.py`:
```python
DEBUG = False
ALLOWED_HOSTS = ['.inventory.by']
```
2. Настройте DNS для поддоменов (wildcard):
```
*.inventory.by → ваш сервер
```
3. Используйте реальные домены вместо localhost
4. Настройте PostgreSQL с безопасным паролем
5. Используйте environment variables для секретов
---
## Следующие шаги
После успешной настройки:
1. ✅ Создайте несколько тестовых магазинов
2. ✅ Добавьте товары в каждый магазин
3. ✅ Создайте тестовые заказы
4. ✅ Проверьте изоляцию данных между магазинами
5. 🔜 Разработайте веб-интерфейс для владельцев магазинов
6. 🔜 Добавьте регистрацию новых магазинов через веб-форму
7. 🔜 Реализуйте биллинг и тарифные планы

81
myproject/QUICK_START.md Normal file
View File

@@ -0,0 +1,81 @@
# Быстрый старт - Django Tenants
## 1. Установка PostgreSQL
```bash
# Docker (рекомендуется):
docker run --name inventory-postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=inventory_db -p 5432:5432 -d postgres:15
```
## 2. Установка пакетов
```bash
pip install -r requirements.txt
```
## 3. Миграции
```bash
python manage.py makemigrations
python manage.py migrate_schemas --shared
```
## 4. Создание public тенанта
```bash
python manage.py shell
```
```python
from tenants.models import Client, Domain
public_tenant = Client.objects.create(
schema_name='public',
name='Главный домен',
owner_email='admin@inventory.by',
owner_name='Администратор'
)
Domain.objects.create(
domain='localhost',
tenant=public_tenant,
is_primary=True
)
exit()
```
## 5. Создание суперпользователя
```bash
python manage.py createsuperuser --schema=public
```
## 6. Создание тестового магазина
```bash
python manage.py create_tenant
```
## 7. Добавить в hosts
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
```
127.0.0.1 localhost
127.0.0.1 shop1.localhost
```
## 8. Запуск
```bash
python manage.py runserver 0.0.0.0:8000
```
## 9. Проверка
- Админка системы: http://localhost:8000/admin/
- Админка магазина: http://shop1.localhost:8000/admin/
---
**Подробная инструкция**: см. [DJANGO_TENANTS_SETUP.md](DJANGO_TENANTS_SETUP.md)

297
myproject/START_HERE.md Normal file
View File

@@ -0,0 +1,297 @@
# 🚀 Чистый старт проекта с Django Tenants
Все миграции удалены. База данных пуста. Готов к чистому старту!
## ✅ Что уже сделано:
1. ✅ PostgreSQL установлен и запущен в Docker
2.Все старые миграции удалены
3. ✅ SQLite база удалена
4. ✅ Проект настроен для django-tenants
---
## 📋 Пошаговая инструкция:
### Шаг 1: Установить зависимости
```bash
pip install -r requirements.txt
```
Это установит:
- django-tenants
- psycopg2-binary
- и все остальные зависимости
---
### Шаг 2: Создать миграции для всех приложений
```bash
python manage.py makemigrations
```
Django создаст миграции для:
- **tenants** (public схема - Client и Domain)
- **accounts, customers, shops, products, orders, inventory** (tenant схемы)
---
### Шаг 3: Применить миграции к public схеме
```bash
python manage.py migrate_schemas --shared
```
Это создаст:
- Схему `public` в PostgreSQL
- Таблицы для управления тенантами (Client, Domain)
- Таблицы Django (auth, contenttypes, sessions, admin)
---
### Шаг 4: Создать public тенанта
Public тенант нужен для главного домена (localhost в разработке).
```bash
python manage.py shell
```
В shell выполните:
```python
from tenants.models import Client, Domain
# Создать public тенанта
public_tenant = Client.objects.create(
schema_name='public',
name='Главный домен',
owner_email='admin@inventory.by',
owner_name='Администратор'
)
# Создать домен для public
public_domain = Domain.objects.create(
domain='localhost',
tenant=public_tenant,
is_primary=True
)
print(f'✓ Public тенант создан: {public_tenant}')
print(f'✓ Public домен создан: {public_domain}')
exit()
```
---
### Шаг 5: Создать суперпользователя (ваш аккаунт)
```bash
python manage.py createsuperuser --schema=public
```
Введите:
- **Email**: ваш email
- **Name**: ваше имя
- **Password**: ваш пароль
Этот суперпользователь будет иметь доступ к админке на `localhost:8000/admin/` для управления тенантами.
---
### Шаг 6: Создать первый магазин (тенант)
```bash
python manage.py create_tenant
```
Пример данных:
- **Название магазина**: Цветочный рай
- **Схема БД**: shop1
- **Домен**: shop1.localhost (или оставьте по умолчанию)
- **Имя владельца**: Иван Иванов
- **Email**: ivan@example.com
- **Телефон**: (можете оставить пустым)
Команда автоматически:
1. Создаст тенанта в таблице `Client`
2. Создаст домен в таблице `Domain`
3. Создаст схему `shop1` в PostgreSQL
4. Применит все миграции к схеме `shop1`
5. Создаст все таблицы (customers, orders, products, etc.) в схеме `shop1`
---
### Шаг 7: Настроить hosts файл
Откройте файл hosts с правами администратора:
**Windows**: `C:\Windows\System32\drivers\etc\hosts`
Добавьте строки:
```
127.0.0.1 localhost
127.0.0.1 shop1.localhost
127.0.0.1 shop2.localhost
```
Сохраните файл.
---
### Шаг 8: Запустить сервер
```bash
python manage.py runserver 0.0.0.0:8000
```
---
### Шаг 9: Проверить работу
#### 1. Админка супер-администратора (Public схема):
URL: **http://localhost:8000/admin/**
Логин: email и пароль суперпользователя (из Шага 5)
Здесь вы увидите:
- Управление тенантами (магазинами)
- Управление доменами
- Стандартные разделы Django
#### 2. Админка магазина (Tenant схема):
URL: **http://shop1.localhost:8000/admin/**
Сначала нужно создать пользователя для магазина:
```bash
python manage.py tenant_command createsuperuser --schema=shop1
```
Затем зайдите в админку магазина и увидите:
- Клиенты (Customers)
- Адреса (Addresses)
- Магазины/точки (Shops)
- Товары (Products, Categories, Kits)
- Заказы (Orders, OrderItems)
- Складской учет (Inventory)
---
## 🎯 Проверка изоляции данных
Создайте второй магазин:
```bash
python manage.py create_tenant
```
Данные (название: "Второй магазин", схема: "shop2", домен: "shop2.localhost")
Затем:
1. Добавьте товары в shop1
2. Добавьте товары в shop2
3. Убедитесь, что товары из shop1 НЕ видны в shop2 и наоборот
**Это и есть полная изоляация данных!**
---
## 🛠 Полезные команды
### Посмотреть список всех тенантов:
```bash
python manage.py shell
```
```python
from tenants.models import Client
for tenant in Client.objects.all():
print(f'{tenant.name}: {tenant.schema_name} - {tenant.get_primary_domain()}')
```
### Применить миграции ко всем тенантам:
```bash
python manage.py migrate_schemas
```
### Применить миграции к конкретному тенанту:
```bash
python manage.py migrate_schemas --schema=shop1
```
### Выполнить команду для тенанта:
```bash
python manage.py tenant_command <command> --schema=shop1
```
Примеры:
```bash
python manage.py tenant_command createsuperuser --schema=shop1
python manage.py tenant_command shell --schema=shop1
python manage.py tenant_command dumpdata --schema=shop1 > shop1_data.json
```
---
## 📊 Структура базы данных
После выполнения всех шагов в PostgreSQL будет:
### Схема `public`:
- Таблицы тенантов: `tenants_client`, `tenants_domain`
- Таблицы Django: `auth_user`, `auth_group`, `django_session`, etc.
### Схема `shop1`:
- `customers_customer`, `customers_address`
- `shops_shop`
- `products_product`, `products_category`, `products_productkit`
- `orders_order`, `orders_orderitem`
- `inventory_*`
- И все остальные таблицы приложений
### Схема `shop2`:
- Те же таблицы что и в `shop1`, но с ДРУГИМИ данными!
---
## ❗ Возможные проблемы
### Ошибка: "connection to server at localhost (127.0.0.1), port 5432 failed"
PostgreSQL не запущен. Запустите:
```bash
docker start inventory-postgres
```
### Ошибка: "database 'inventory_db' does not exist"
Создайте базу:
```bash
docker exec -it inventory-postgres psql -U postgres -c "CREATE DATABASE inventory_db;"
```
### Ошибка: "No tenant found for hostname 'shop1.localhost'"
- Проверьте hosts файл
- Проверьте, что домен создан: `Domain.objects.filter(domain='shop1.localhost').exists()`
### Ошибка: "relation does not exist"
Миграции не применены. Запустите:
```bash
python manage.py migrate_schemas
```
---
## 🎉 Готово!
После выполнения всех шагов у вас будет работающая SaaS-платформа с полной изоляцией данных между магазинами!
**Подробная документация**: [DJANGO_TENANTS_SETUP.md](DJANGO_TENANTS_SETUP.md)

View File

@@ -0,0 +1,324 @@
# Руководство по системе регистрации тенантов
## Что реализовано
Создана полноценная система регистрации новых магазинов (тенантов) с ручной модерацией администратором.
### 1. Модели данных ([tenants/models.py](myproject/tenants/models.py))
#### Client (обновлена)
- Добавлен `db_index` для поля `name` (ускорение поиска)
- Изменено поле `phone` на `PhoneNumberField` (поддержка РБ/РФ форматов)
- Обновлен `help_text` для `owner_email` (один email может быть у нескольких магазинов для супер-админа)
#### TenantRegistration (новая)
Модель заявки на регистрацию:
- `shop_name` - название магазина
- `schema_name` - желаемый поддомен (с валидацией regex)
- `owner_email`, `owner_name`, `phone` - контактные данные
- `status` - статус заявки: pending/approved/rejected
- `processed_at`, `processed_by` - данные обработки
- `tenant` - ссылка на созданный тенант после активации
#### Subscription (новая)
Модель подписки:
- `plan` - тип плана (триал 90 дней, месяц, квартал, год)
- `started_at`, `expires_at` - период действия
- `is_active`, `auto_renew` - статус и автопродление
- Методы: `is_expired()`, `days_left()`, `create_trial(client)`
#### RESERVED_SCHEMA_NAMES
Список зарезервированных поддоменов (admin, api, www, и т.д.)
---
### 2. Админ-панель ([tenants/admin.py](myproject/tenants/admin.py))
#### ClientAdmin (обновлена)
- Добавлена колонка `subscription_status` с цветовой индикацией
- Разрешено редактирование `schema_name` при создании нового тенанта
- Запрещено удаление тенантов через админку (для безопасности)
#### TenantRegistrationAdmin (новая)
Функции:
- Список заявок с фильтрами по статусу и дате
- Кнопки "Активировать" / "Отклонить" для каждой заявки
- Массовые действия для обработки нескольких заявок
- При активации:
- Создается тенант (Client)
- Создается домен (например: myshop.localhost)
- Создается триальная подписка на 90 дней
- Заявка помечается как "Одобрено"
#### SubscriptionAdmin (новая)
- Просмотр и управление подписками
- Цветовая индикация истекающих подписок (красный < 0 дней, оранжевый < 7 дней)
---
### 3. Публичная форма регистрации
#### [tenants/forms.py](myproject/tenants/forms.py) - TenantRegistrationForm
Валидация:
- `schema_name`: приведение к lowercase, проверка длины (3-63 символа), проверка на зарезервированные имена, проверка уникальности
- `owner_email`: проверка на дубликаты pending заявок
#### [tenants/views.py](myproject/tenants/views.py)
- `TenantRegistrationView` - форма регистрации
- `RegistrationSuccessView` - страница благодарности
#### HTML шаблоны
- [base.html](myproject/tenants/templates/tenants/base.html) - базовый шаблон с Bootstrap 5
- [registration_form.html](myproject/tenants/templates/tenants/registration_form.html) - красивая форма с валидацией
- [registration_success.html](myproject/tenants/templates/tenants/registration_success.html) - страница с инструкциями
---
## Как использовать
### Для пользователей (владельцев будущих магазинов)
1. Откройте публичную форму регистрации:
```
http://localhost:8000/register/
```
2. Заполните форму:
- Название магазина
- Желаемый поддомен (только латиница, цифры, дефис)
- Ваше имя
- Email
- Телефон
3. После отправки увидите страницу благодарности
4. Ожидайте активации администратором (в течение 24 часов)
---
### Для администратора
1. Войдите в админ-панель:
```
http://localhost:8000/admin/
```
2. Перейдите в раздел "Заявки на регистрацию"
3. Увидите список заявок со статусом "Ожидает проверки"
4. Для активации заявки:
- Кликните на кнопку "Активировать" справа от заявки
- ИЛИ выберите несколько заявок и используйте массовое действие
5. Что происходит при активации:
- Создается новый тенант (Client) с указанным schema_name
- Создается домен `{schema_name}.localhost`
- Создается триальная подписка на 90 дней
- Заявка помечается как "Одобрено"
- В поле "Созданный тенант" появляется ссылка на тенант
6. Для отклонения:
- Кликните "Отклонить"
- Заявка помечается как "Отклонено"
---
## Доступ к магазинам
После активации магазин доступен по адресу:
```
http://{schema_name}.localhost:8000/
```
Например, для магазина с `schema_name=myshop`:
```
http://myshop.localhost:8000/
```
---
## Управление подписками
### Просмотр подписок
1. Админ-панель → "Подписки"
2. Видны все подписки с информацией:
- Тип плана
- Дата начала/окончания
- Осталось дней
- Истекла или нет
### Продление подписки
1. Откройте подписку тенанта
2. Измените:
- `expires_at` - новую дату окончания
- `plan` - новый тип плана (если меняется)
3. Сохраните
### Типы планов
- **Триальный (90 дней)** - автоматически при создании
- **Месячный** - 30 дней
- **Квартальный** - 90 дней
- **Годовой** - 365 дней
---
## Технические детали
### Валидация schema_name
Regex: `^[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?$`
Правила:
- Только латинские буквы в нижнем регистре
- Цифры и дефис разрешены
- Длина 3-63 символа
- Не может начинаться или заканчиваться дефисом
- Не совпадает с зарезервированными именами
### Зарезервированные имена
```python
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'
]
```
### Email для супер-админа
Один email может использоваться для нескольких магазинов (полезно для вас как супер-админа для входа в разные тенанты).
Для обычных пользователей форма проверяет наличие pending заявок с таким же email.
---
## Что дальше (рекомендации)
### 1. Email-уведомления
Добавить отправку писем:
- Пользователю при активации заявки
- Пользователю при истечении подписки (за 7 дней, за 1 день)
- Админу при новой заявке
### 2. Биллинг
Создать страницу `/billing/` где владелец магазина может:
- Посмотреть текущую подписку
- Продлить подписку
- Оплатить через платежную систему
### 3. Middleware для is_active
Если нужна жесткая блокировка доступа к деактивированным магазинам, создать middleware:
```python
# tenants/middleware.py
class TenantStatusMiddleware:
def __call__(self, request):
if hasattr(request, 'tenant'):
if not request.tenant.is_active:
# Показать страницу "Магазин заблокирован"
pass
sub = request.tenant.subscription
if sub.is_expired():
# Редирект на /billing/renew/
pass
return self.get_response(request)
```
### 4. Автоматическая очистка
Создать команду для удаления старых отклоненных заявок:
```bash
python manage.py cleanup_old_registrations --days=30
```
---
## Структура файлов
```
myproject/tenants/
├── models.py # Модели Client, TenantRegistration, Subscription
├── admin.py # Админ-панель с функционалом активации
├── forms.py # Форма регистрации с валидацией
├── views.py # Views для публичной регистрации
├── urls.py # Роуты /register/ и /register/success/
└── templates/tenants/
├── base.html # Базовый шаблон
├── registration_form.html # Форма регистрации
└── registration_success.html # Страница благодарности
```
---
## Тестирование
### 1. Регистрация магазина
```bash
# Запустите сервер
python manage.py runserver
# Откройте браузер
http://localhost:8000/register/
# Заполните форму:
Название: Тестовый магазин
Поддомен: testshop
Имя: Иван Иванов
Email: test@example.com
Телефон: +375291234567
# Отправьте заявку
```
### 2. Активация через админку
```bash
# Войдите в админку
http://localhost:8000/admin/
# Логин/пароль супер-админа
# Перейдите в "Заявки на регистрацию"
# Нажмите "Активировать" напротив заявки
```
### 3. Проверка созданного магазина
```bash
# Откройте браузер
http://testshop.localhost:8000/
# Должна открыться страница магазина
```
---
## Поддержка
При возникновении проблем проверьте:
1. Миграции применены: `python manage.py migrate_schemas --shared`
2. В `settings.py` приложение `tenants` в `SHARED_APPS`
3. В `urls_public.py` подключены роуты tenants
4. Виртуальное окружение активировано
5. `phonenumber_field` установлен
---
**Система готова к использованию!**
Теперь вы можете:
- Принимать заявки на регистрацию
- Модерировать их через админку
- Управлять подписками
- Контролировать доступ к магазинам

View File

@@ -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.contrib.auth.validators import django.contrib.auth.validators
import django.utils.timezone import django.utils.timezone

View File

@@ -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 django.db.models.deletion
import phonenumber_field.modelfields import phonenumber_field.modelfields

View File

@@ -104,51 +104,51 @@ class Customer(models.Model):
return discounts.get(self.loyalty_tier, 0) return discounts.get(self.loyalty_tier, 0)
def validate_unique(self, exclude=None): 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: if self.phone:
# Check for existing customers with the same phone (excluding current instance if updating) # Проверяем существующих клиентов с таким же телефоном (исключая текущий экземпляр при обновлении)
existing = Customer.objects.filter(phone=self.phone) existing = Customer.objects.filter(phone=self.phone)
if self.pk: if self.pk:
existing = existing.exclude(pk=self.pk) existing = existing.exclude(pk=self.pk)
if existing.exists(): if existing.exists():
raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'}) raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'})
# Call parent validate_unique to handle other validation # Вызываем родительский validate_unique для обработки других проверок
super().validate_unique(exclude=exclude) super().validate_unique(exclude=exclude)
def clean_phone(self): def clean_phone(self):
"""Custom cleaning for phone field to normalize before validation.""" """Пользовательская очистка поля телефона для нормализации перед валидацией."""
if self.phone: if self.phone:
try: try:
# Parse the phone number to check if it's valid and normalize it # Парсим номер телефона, чтобы проверить его валидность и нормализовать
raw_phone = str(self.phone) 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: if raw_phone.startswith('8') and len(raw_phone) == 11:
# Try BY first for Belarusian numbers # Сначала пробуем BY для белорусских номеров
parsed = phonenumbers.parse(raw_phone, "BY") parsed = phonenumbers.parse(raw_phone, "BY")
if phonenumbers.is_valid_number(parsed): if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) 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") parsed = phonenumbers.parse(raw_phone, "RU")
if phonenumbers.is_valid_number(parsed): if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) 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) parsed = phonenumbers.parse(raw_phone, None)
if phonenumbers.is_valid_number(parsed): if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except phonenumbers.NumberParseException: except phonenumbers.NumberParseException:
# If parsing fails, return as is and let field validation handle it # Если парсинг не удался, возвращаем как есть и позволяем валидации поля обработать это
pass pass
return self.phone return self.phone
def clean_fields(self, exclude=None): def clean_fields(self, exclude=None):
# Normalize phone before field validation runs # Нормализуем телефон перед запуском проверки полей
if not exclude: if not exclude:
exclude = [] exclude = []
if 'phone' not in exclude and self.phone: if 'phone' not in exclude and self.phone:
@@ -158,33 +158,33 @@ class Customer(models.Model):
except Exception: except Exception:
normalized = None normalized = None
if normalized: if normalized:
# assign normalized value (E.164) so PhoneNumberField sees корректный формат # присваиваем нормализованное значение (E.164), чтобы PhoneNumberField видел корректный формат
self.phone = normalized self.phone = normalized
super().clean_fields(exclude=exclude) super().clean_fields(exclude=exclude)
def clean(self): def clean(self):
"""Additional validation if needed.""" """Дополнительная валидация при необходимости."""
super().clean() super().clean()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Ensure phone is normalized even if save is called directly (not through form) # Обеспечиваем нормализацию телефона, даже если save вызывается напрямую (не через форму)
# 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 вызывается непосредственно в модели, нам все равно нужно нормализовать
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11: 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:
# Try BY first for Belarusian numbers # Сначала пробуем BY для белорусских номеров
parsed = phonenumbers.parse(str(self.phone), "BY") parsed = phonenumbers.parse(str(self.phone), "BY")
if phonenumbers.is_valid_number(parsed): if phonenumbers.is_valid_number(parsed):
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
else: else:
# If BY doesn't work, try RU as fallback # Если BY не работает, пробуем RU как запасной вариант
parsed = phonenumbers.parse(str(self.phone), "RU") parsed = phonenumbers.parse(str(self.phone), "RU")
if phonenumbers.is_valid_number(parsed): if phonenumbers.is_valid_number(parsed):
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except phonenumbers.NumberParseException: except phonenumbers.NumberParseException:
# If parsing fails, don't change it and let the field validation handle it # Если парсинг не удался, не меняем и позволяем валидации поля обработать это
pass pass
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -269,7 +269,7 @@ class Address(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.is_default: 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) Address.objects.filter(customer=self.customer, is_default=True).update(is_default=False)
super().save(*args, **kwargs) super().save(*args, **kwargs)

View File

@@ -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 django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models

View File

@@ -1,34 +1,25 @@
# -*- coding: utf-8 -*-
""" """
URL configuration for myproject project. URL configuration for TENANT schemas (shop1.inventory.by, shop2.inventory.by, etc.).
The `urlpatterns` list routes URLs to views. For more information please see: This is used for individual shop subdomains where shop owners manage their business.
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
from . import views
urlpatterns = [ urlpatterns = [
path('_nested_admin/', include('nested_admin.urls')), # Для nested admin path('_nested_admin/', include('nested_admin.urls')), # Для nested admin
path('admin/', admin.site.urls), path('admin/', admin.site.urls), # Админка для владельца магазина (доступна на поддомене)
path('', views.index, name='index'), # Main page # TODO: Add web interface for shop owners
path('accounts/', include('accounts.urls')), # path('', views.dashboard, name='dashboard'),
path('products/', include('products.urls')), # path('products/', include('products.urls')),
path('customers/', include('customers.urls')), # path('orders/', include('orders.urls')),
# path('customers/', include('customers.urls')),
] ]
# Serve media files during development # Serve media files during development
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""
URL configuration for the PUBLIC schema (inventory.by domain).
This is the main domain where:
- Super admin can access admin panel
- Tenant registration is available
- Future: Landing page, etc.
"""
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('tenants.urls')), # Подключаем роуты для регистрации тенантов
]
# Serve media files in development
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@@ -1,28 +1,174 @@
# -*- coding: utf-8 -*-
from django.contrib import admin from django.contrib import admin
from .models import Customer, Order, OrderItem from .models import Order, OrderItem
class CustomerAdmin(admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'email', 'phone', 'created_at')
list_filter = ('created_at', 'updated_at')
search_fields = ('first_name', 'last_name', 'email')
date_hierarchy = 'created_at'
class OrderItemInline(admin.TabularInline): class OrderItemInline(admin.TabularInline):
"""
Inline для управления позициями заказа прямо в форме заказа.
"""
model = OrderItem model = OrderItem
extra = 1 extra = 1
readonly_fields = ('snapshot_name', 'snapshot_sku', 'sale_price', 'cost_price') fields = ['product', 'product_kit', 'quantity', 'price']
readonly_fields = []
def get_readonly_fields(self, request, obj=None):
"""Делаем цену readonly для существующих позиций"""
if obj and obj.pk:
return ['price']
return []
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin): class OrderAdmin(admin.ModelAdmin):
list_display = ('id', 'customer', 'status', 'total_price', 'created_at', 'updated_at') """
list_filter = ('status', 'created_at', 'updated_at') Админ-панель для управления заказами.
search_fields = ('customer__first_name', 'customer__last_name', 'customer__email', 'id') """
date_hierarchy = 'created_at' list_display = [
'order_number',
'customer',
'delivery_type',
'delivery_date',
'status',
'total_amount',
'is_paid',
'created_at',
]
list_filter = [
'status',
'delivery_type',
'is_paid',
'delivery_date',
'created_at',
]
search_fields = [
'order_number',
'customer__name',
'customer__phone',
'customer__email',
'delivery_address__recipient_name',
'delivery_address__street',
]
readonly_fields = [
'order_number',
'created_at',
'updated_at',
'delivery_info',
'delivery_time_window',
]
fieldsets = (
('Основная информация', {
'fields': ('order_number', 'customer', 'status')
}),
('Доставка', {
'fields': (
'delivery_type',
'delivery_address',
'pickup_shop',
'delivery_date',
'delivery_time_start',
'delivery_time_end',
'delivery_cost',
'delivery_info',
'delivery_time_window',
)
}),
('Оплата', {
'fields': ('payment_method', 'is_paid', 'total_amount')
}),
('Дополнительно', {
'fields': ('is_anonymous', 'special_instructions'),
'classes': ('collapse',)
}),
('Системная информация', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
inlines = [OrderItemInline] inlines = [OrderItemInline]
actions = [
'mark_as_confirmed',
'mark_as_in_assembly',
'mark_as_in_delivery',
'mark_as_delivered',
'mark_as_paid',
]
admin.site.register(Customer, CustomerAdmin) def mark_as_confirmed(self, request, queryset):
admin.site.register(Order, OrderAdmin) """Отметить заказы как подтвержденные"""
admin.site.register(OrderItem) updated = queryset.update(status='confirmed')
self.message_user(request, f'{updated} заказ(ов) отмечено как подтвержденные')
mark_as_confirmed.short_description = 'Отметить как подтвержденные'
def mark_as_in_assembly(self, request, queryset):
"""Отметить заказы как в сборке"""
updated = queryset.update(status='in_assembly')
self.message_user(request, f'{updated} заказ(ов) отмечено как в сборке')
mark_as_in_assembly.short_description = 'Отметить как в сборке'
def mark_as_in_delivery(self, request, queryset):
"""Отметить заказы как в доставке"""
updated = queryset.update(status='in_delivery')
self.message_user(request, f'{updated} заказ(ов) отмечено как в доставке')
mark_as_in_delivery.short_description = 'Отметить как в доставке'
def mark_as_delivered(self, request, queryset):
"""Отметить заказы как доставленные"""
updated = queryset.update(status='delivered')
self.message_user(request, f'{updated} заказ(ов) отмечено как доставленные')
mark_as_delivered.short_description = 'Отметить как доставленные'
def mark_as_paid(self, request, queryset):
"""Отметить заказы как оплаченные"""
updated = queryset.update(is_paid=True)
self.message_user(request, f'{updated} заказ(ов) отмечено как оплаченные')
mark_as_paid.short_description = 'Отметить как оплаченные'
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
"""
Админ-панель для управления позициями заказов.
"""
list_display = [
'order',
'item_name',
'quantity',
'price',
'get_total_price',
]
list_filter = [
'order__status',
'order__created_at',
]
search_fields = [
'order__order_number',
'product__name',
'product_kit__name',
]
readonly_fields = ['created_at', 'get_total_price']
fieldsets = (
('Заказ', {
'fields': ('order',)
}),
('Товар/Комплект', {
'fields': ('product', 'product_kit')
}),
('Информация', {
'fields': ('quantity', 'price', 'get_total_price')
}),
('Системная информация', {
'fields': ('created_at',),
'classes': ('collapse',)
}),
)

View File

@@ -1,7 +1,6 @@
# 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 django.db.models.deletion
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -10,80 +9,90 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('customers', '0001_initial'),
('products', '0001_initial'), ('products', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('shops', '0001_initial'),
] ]
operations = [ operations = [
migrations.CreateModel(
name='Customer',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(max_length=100, verbose_name='Имя')),
('last_name', models.CharField(max_length=100, verbose_name='Фамилия')),
('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата регистрации')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Покупатель',
'verbose_name_plural': 'Покупатели',
},
),
migrations.CreateModel( migrations.CreateModel(
name='Order', name='Order',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('created', 'Создан'), ('confirmed', 'Подтвержден'), ('assembled', 'Собран'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='created', max_length=20, verbose_name='Статус')), ('order_number', models.CharField(editable=False, help_text='Уникальный номер заказа для отображения клиенту', max_length=50, unique=True, verbose_name='Номер заказа')),
('total_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Общая сумма')), ('delivery_type', models.CharField(choices=[('courier', 'Курьерская доставка'), ('pickup', 'Самовывоз')], default='courier', max_length=20, verbose_name='Тип доставки')),
('delivery_date', models.DateField(verbose_name='Дата доставки/самовывоза')),
('delivery_time_start', models.TimeField(help_text='Начало временного интервала', verbose_name='Время от')),
('delivery_time_end', models.TimeField(help_text='Конец временного интервала', verbose_name='Время до')),
('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
('status', models.CharField(choices=[('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, verbose_name='Статус заказа')),
('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')),
('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')),
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')),
('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')),
('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='orders.customer', verbose_name='Клиент')), ('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент')),
('delivery_address', models.ForeignKey(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.address', verbose_name='Адрес доставки')),
('pickup_shop', models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='shops.shop', verbose_name='Точка самовывоза')),
], ],
options={ options={
'verbose_name': 'Заказ', 'verbose_name': 'Заказ',
'verbose_name_plural': 'Заказы', 'verbose_name_plural': 'Заказы',
'ordering': ['-created_at'],
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='OrderItem', name='OrderItem',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=3, default=1, max_digits=10, verbose_name='Количество')), ('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
('snapshot_name', models.CharField(max_length=200, verbose_name='Название (на момент заказа)')), ('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
('snapshot_sku', models.CharField(blank=True, max_length=100, null=True, verbose_name='Артикул (на момент заказа)')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
('sale_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Цена продажи')),
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Себестоимость')),
('composition_snapshot', models.JSONField(blank=True, null=True, verbose_name='Состав комплекта (снапшот)')),
('kit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order_items', to='products.productkit', verbose_name='Комплект')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ')), ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order_items', to='products.product', verbose_name='Товар')), ('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.product', verbose_name='Товар')),
('product_kit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.productkit', verbose_name='Комплект товаров')),
], ],
options={ options={
'verbose_name': 'Позиция заказа', 'verbose_name': 'Позиция заказа',
'verbose_name_plural': 'Позиции заказов', 'verbose_name_plural': 'Позиции заказа',
}, },
), ),
migrations.AddIndex(
model_name='customer',
index=models.Index(fields=['email'], name='orders_cust_email_e97b09_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['status'], name='orders_orde_status_c6dd84_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='order', model_name='order',
index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'), index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'),
), ),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['status'], name='orders_orde_status_c6dd84_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['delivery_date'], name='orders_orde_deliver_e4274f_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['delivery_type'], name='orders_orde_deliver_f68568_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='order', model_name='order',
index=models.Index(fields=['created_at'], name='orders_orde_created_0e92de_idx'), index=models.Index(fields=['created_at'], name='orders_orde_created_0e92de_idx'),
), ),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['order_number'], name='orders_orde_order_n_f3ada5_idx'),
),
migrations.AddIndex( migrations.AddIndex(
model_name='orderitem', model_name='orderitem',
index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'), index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'),
), ),
migrations.AddIndex(
model_name='orderitem',
index=models.Index(fields=['product'], name='orders_orde_product_32ff41_idx'),
),
migrations.AddIndex(
model_name='orderitem',
index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'),
),
] ]

View File

@@ -1,135 +1,334 @@
from django.db import models from django.db import models
from django.core.exceptions import ValidationError
from accounts.models import CustomUser from accounts.models import CustomUser
from customers.models import Customer, Address
from products.models import Product, ProductKit from products.models import Product, ProductKit
from shops.models import Shop
import uuid
class Customer(models.Model):
"""
Модель покупателя.
"""
user = models.OneToOneField(CustomUser, on_delete=models.CASCADE, null=True, blank=True,
related_name='customer', verbose_name="Пользователь")
first_name = models.CharField(max_length=100, verbose_name="Имя")
last_name = models.CharField(max_length=100, verbose_name="Фамилия")
email = models.EmailField(unique=True, verbose_name="Email")
phone = models.CharField(max_length=20, blank=True, null=True, verbose_name="Телефон")
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=['email']),
]
def __str__(self):
return f"{self.first_name} {self.last_name} ({self.email})"
class Order(models.Model): class Order(models.Model):
""" """
Заказ клиента. Заказ клиента для доставки цветов.
""" """
# Основная информация
customer = models.ForeignKey(
Customer,
on_delete=models.PROTECT,
related_name='orders',
verbose_name="Клиент"
)
order_number = models.CharField(
max_length=50,
unique=True,
editable=False,
verbose_name="Номер заказа",
help_text="Уникальный номер заказа для отображения клиенту"
)
# Тип доставки
DELIVERY_TYPE_CHOICES = [
('courier', 'Курьерская доставка'),
('pickup', 'Самовывоз'),
]
delivery_type = models.CharField(
max_length=20,
choices=DELIVERY_TYPE_CHOICES,
default='courier',
verbose_name="Тип доставки"
)
# Адрес доставки (для курьерской доставки)
delivery_address = models.ForeignKey(
Address,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='orders',
verbose_name="Адрес доставки",
help_text="Обязательно для курьерской доставки"
)
# Пункт самовывоза (для самовывоза)
pickup_shop = models.ForeignKey(
Shop,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='pickup_orders',
verbose_name="Точка самовывоза",
help_text="Обязательно для самовывоза"
)
# Дата и время доставки/самовывоза
delivery_date = models.DateField(
verbose_name="Дата доставки/самовывоза"
)
delivery_time_start = models.TimeField(
verbose_name="Время от",
help_text="Начало временного интервала"
)
delivery_time_end = models.TimeField(
verbose_name="Время до",
help_text="Конец временного интервала"
)
delivery_cost = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Стоимость доставки",
help_text="0 для самовывоза"
)
# Статус заказа
STATUS_CHOICES = [ STATUS_CHOICES = [
('created', 'Создан'), ('new', 'Новый'),
('confirmed', 'Подтвержден'), ('confirmed', 'Подтвержден'),
('assembled', 'Собран'), ('in_assembly', 'В сборке'),
('in_delivery', 'В доставке'),
('delivered', 'Доставлен'), ('delivered', 'Доставлен'),
('cancelled', 'Отменен'), ('cancelled', 'Отменен'),
] ]
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, status = models.CharField(
related_name='orders', verbose_name="Клиент") max_length=20,
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='created', choices=STATUS_CHOICES,
verbose_name="Статус") default='new',
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Общая сумма") verbose_name="Статус заказа"
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания") )
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
# Оплата
PAYMENT_METHOD_CHOICES = [
('cash_to_courier', 'Наличные курьеру'),
('card_to_courier', 'Карта курьеру'),
('online', 'Онлайн оплата'),
('bank_transfer', 'Банковский перевод'),
]
payment_method = models.CharField(
max_length=20,
choices=PAYMENT_METHOD_CHOICES,
default='cash_to_courier',
verbose_name="Способ оплаты"
)
is_paid = models.BooleanField(
default=False,
verbose_name="Оплачен"
)
total_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Итоговая сумма заказа",
help_text="Общая сумма заказа включая доставку"
)
# Дополнительная информация
is_anonymous = models.BooleanField(
default=False,
verbose_name="Анонимная доставка",
help_text="Не сообщать получателю имя отправителя"
)
special_instructions = models.TextField(
blank=True,
null=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: class Meta:
verbose_name = "Заказ" verbose_name = "Заказ"
verbose_name_plural = "Заказы" verbose_name_plural = "Заказы"
indexes = [ indexes = [
models.Index(fields=['status']),
models.Index(fields=['customer']), models.Index(fields=['customer']),
models.Index(fields=['status']),
models.Index(fields=['delivery_date']),
models.Index(fields=['delivery_type']),
models.Index(fields=['created_at']), models.Index(fields=['created_at']),
models.Index(fields=['order_number']),
] ]
ordering = ['-created_at']
def __str__(self): def __str__(self):
return f"Заказ #{self.id} - {self.customer}" return f"Заказ #{self.order_number} - {self.customer}"
def save(self, *args, **kwargs):
# Генерируем уникальный номер заказа при создании
if not self.order_number:
self.order_number = self.generate_order_number()
super().save(*args, **kwargs)
def generate_order_number(self):
"""Генерирует уникальный номер заказа"""
# Формат: ORD-YYYYMMDD-XXXX (например: ORD-20250126-A3F2)
from datetime import datetime
date_str = datetime.now().strftime('%Y%m%d')
unique_id = uuid.uuid4().hex[:4].upper()
return f"ORD-{date_str}-{unique_id}"
def clean(self):
"""Валидация модели"""
super().clean()
# Проверка: для курьерской доставки обязателен адрес
if self.delivery_type == 'courier' and not self.delivery_address:
raise ValidationError({
'delivery_address': 'Для курьерской доставки необходимо указать адрес доставки'
})
# Проверка: для самовывоза обязателен пункт самовывоза
if self.delivery_type == 'pickup' and not self.pickup_shop:
raise ValidationError({
'pickup_shop': 'Для самовывоза необходимо выбрать точку самовывоза'
})
# Проверка: время окончания должно быть позже времени начала
if self.delivery_time_start and self.delivery_time_end:
if self.delivery_time_end <= self.delivery_time_start:
raise ValidationError({
'delivery_time_end': 'Время окончания должно быть позже времени начала'
})
def calculate_total(self):
"""Рассчитывает итоговую сумму заказа"""
items_total = sum(item.get_total_price() for item in self.items.all())
self.total_amount = items_total + self.delivery_cost
return self.total_amount
@property
def delivery_info(self):
"""Информация о доставке для отображения"""
if self.delivery_type == 'courier':
return f"Доставка по адресу: {self.delivery_address.full_address}"
elif self.delivery_type == 'pickup':
return f"Самовывоз из: {self.pickup_shop.name}"
return "Не указано"
@property
def delivery_time_window(self):
"""Временное окно доставки"""
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
class OrderItem(models.Model): class OrderItem(models.Model):
""" """
Строка заказа — может быть простым товаром или комплектом. Позиция (товар) в заказе.
Хранит информацию о товаре или комплекте, количестве и цене на момент заказа.
""" """
order = models.ForeignKey(Order, on_delete=models.CASCADE, order = models.ForeignKey(
related_name='items', verbose_name="Заказ") Order,
product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True, blank=True, on_delete=models.CASCADE,
related_name='order_items', verbose_name="Товар") related_name='items',
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, null=True, blank=True, verbose_name="Заказ"
related_name='order_items', verbose_name="Комплект") )
quantity = models.DecimalField(max_digits=10, decimal_places=3, default=1,
verbose_name="Количество")
# Снапшот-поля (для истории и отчётов) # Товар или комплект (один из двух должен быть заполнен)
snapshot_name = models.CharField(max_length=200, verbose_name="Название (на момент заказа)") product = models.ForeignKey(
snapshot_sku = models.CharField(max_length=100, blank=True, null=True, Product,
verbose_name="Артикул (на момент заказа)") on_delete=models.PROTECT,
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена продажи") null=True,
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Себестоимость") blank=True,
composition_snapshot = models.JSONField(null=True, blank=True, related_name='order_items',
verbose_name="Состав комплекта (снапшот)") verbose_name="Товар"
)
product_kit = models.ForeignKey(
ProductKit,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='order_items',
verbose_name="Комплект товаров"
)
quantity = models.PositiveIntegerField(
default=1,
verbose_name="Количество"
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Цена за единицу",
help_text="Цена на момент создания заказа (фиксируется)"
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата добавления"
)
class Meta: class Meta:
verbose_name = "Позиция заказа" verbose_name = "Позиция заказа"
verbose_name_plural = "Позиции заказов" verbose_name_plural = "Позиции заказа"
indexes = [ indexes = [
models.Index(fields=['order']), models.Index(fields=['order']),
models.Index(fields=['product']),
models.Index(fields=['product_kit']),
] ]
def save(self, *args, **kwargs): def __str__(self):
# Валидация: либо product, либо kit, но не оба item_name = ""
if self.product and self.kit:
raise ValueError("Нельзя одновременно указать товар и комплект")
if not self.product and not self.kit:
raise ValueError("Необходимо указать либо товар, либо комплект")
# Заполнение снапшот-полей
if self.product: if self.product:
if not self.snapshot_name: item_name = self.product.name
self.snapshot_name = self.product.name elif self.product_kit:
if not self.snapshot_sku: item_name = self.product_kit.name
self.snapshot_sku = self.product.sku return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
if not self.sale_price:
self.sale_price = self.product.sale_price
if not self.cost_price:
self.cost_price = self.product.cost_price
elif self.kit:
if not self.snapshot_name:
self.snapshot_name = self.kit.name
if not self.sale_price or not self.cost_price:
# Здесь можно реализовать логику подсчета цены комплекта
# в зависимости от метода ценообразования
if self.kit.pricing_method == 'fixed' and self.kit.fixed_price:
self.sale_price = self.kit.fixed_price
# В реальном приложении нужно реализовать все методы ценообразования
if self.kit.pricing_method != 'fixed' and not self.composition_snapshot:
# Формирование снапшота состава комплекта
composition = []
for item in self.kit.kit_items.all():
composition.append({
"product_id": item.product.id,
"name": item.product.name,
"sku": item.product.sku,
"quantity": float(item.quantity),
"cost_price": float(item.product.cost_price),
"sale_price": float(item.product.sale_price)
})
self.composition_snapshot = composition
def clean(self):
"""Валидация модели"""
super().clean()
# Проверка: должен быть заполнен либо product, либо product_kit
if not self.product and not self.product_kit:
raise ValidationError(
'Необходимо указать либо товар, либо комплект товаров'
)
# Проверка: не должны быть заполнены оба поля одновременно
if self.product and self.product_kit:
raise ValidationError(
'Нельзя указать одновременно и товар, и комплект'
)
def save(self, *args, **kwargs):
# Автоматически фиксируем цену при создании, если она не указана
if not self.price:
if self.product:
self.price = self.product.price
elif self.product_kit:
self.price = self.product_kit.price
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self): def get_total_price(self):
return f"{self.snapshot_name} x{self.quantity} в заказе #{self.order.id}" """Возвращает общую стоимость позиции"""
return self.price * self.quantity
@property
def item_name(self):
"""Название товара/комплекта"""
if self.product:
return self.product.name
elif self.product_kit:
return self.product_kit.name
return "Не указано"

View File

@@ -1,87 +0,0 @@
"""
Management команда для обработки существующих изображений товаров, комплектов и категорий.
Создает все необходимые размеры (thumbnail, medium, large) для уже загруженных изображений.
Использование:
python manage.py process_images
python manage.py process_images --model ProductPhoto
python manage.py process_images --model ProductKitPhoto
python manage.py process_images --model ProductCategoryPhoto
"""
from django.core.management.base import BaseCommand
from products.models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
from products.utils.image_processor import ImageProcessor
class Command(BaseCommand):
help = "Обработка существующих изображений и создание всех необходимых размеров"
def add_arguments(self, parser):
parser.add_argument(
'--model',
type=str,
default=None,
help='Какую модель обрабатывать (ProductPhoto, ProductKitPhoto, ProductCategoryPhoto)',
)
def handle(self, *args, **options):
model_name = options.get('model')
models_to_process = []
if not model_name:
# Обрабатываем все модели
models_to_process = [
('ProductPhoto', ProductPhoto, 'products'),
('ProductKitPhoto', ProductKitPhoto, 'kits'),
('ProductCategoryPhoto', ProductCategoryPhoto, 'categories'),
]
else:
# Обрабатываем конкретную модель
if model_name == 'ProductPhoto':
models_to_process = [('ProductPhoto', ProductPhoto, 'products')]
elif model_name == 'ProductKitPhoto':
models_to_process = [('ProductKitPhoto', ProductKitPhoto, 'kits')]
elif model_name == 'ProductCategoryPhoto':
models_to_process = [('ProductCategoryPhoto', ProductCategoryPhoto, 'categories')]
else:
self.stdout.write(
self.style.ERROR(f'Неизвестная модель: {model_name}')
)
return
total_processed = 0
total_errors = 0
for model_display_name, model_class, base_path in models_to_process:
self.stdout.write(f'\nОбработка {model_display_name}...')
self.stdout.write('-' * 50)
photos = model_class.objects.filter(image__isnull=False).exclude(image='')
if not photos.exists():
self.stdout.write(self.style.WARNING(f'Нет изображений для обработки в {model_display_name}'))
continue
count = photos.count()
self.stdout.write(f'Найдено изображений: {count}')
for i, photo in enumerate(photos, 1):
try:
# Сохраняем фото - это вызовет обработку в методе save()
photo.save()
total_processed += 1
self.stdout.write(
self.style.SUCCESS(f'✓ [{i}/{count}] {photo} - OK')
)
except Exception as e:
total_errors += 1
self.stdout.write(
self.style.ERROR(f'✗ [{i}/{count}] {photo} - ОШИБКА: {str(e)}')
)
self.stdout.write('\n' + '=' * 50)
self.stdout.write(self.style.SUCCESS(f'Обработано: {total_processed}'))
if total_errors:
self.stdout.write(self.style.ERROR(f'Ошибок: {total_errors}'))
self.stdout.write('=' * 50)

View File

@@ -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 django.db.models.deletion
from django.conf import settings from django.conf import settings
@@ -91,7 +91,7 @@ class Migration(migrations.Migration):
name='ProductCategoryPhoto', name='ProductCategoryPhoto',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='categories/originals/', verbose_name='Оригинальное фото')), ('image', models.ImageField(upload_to='categories/temp/', verbose_name='Оригинальное фото')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')), ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')),
@@ -131,7 +131,7 @@ class Migration(migrations.Migration):
name='ProductKitPhoto', name='ProductKitPhoto',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='kits/originals/', verbose_name='Оригинальное фото')), ('image', models.ImageField(upload_to='kits/temp/', verbose_name='Оригинальное фото')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')), ('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')),
@@ -146,7 +146,7 @@ class Migration(migrations.Migration):
name='ProductPhoto', name='ProductPhoto',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='products/originals/', verbose_name='Оригинальное фото')), ('image', models.ImageField(upload_to='products/temp/', verbose_name='Оригинальное фото')),
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')), ('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')), ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')),

View File

@@ -21,6 +21,16 @@ class ImageProcessor:
Использует конфигурацию из settings.IMAGE_PROCESSING_CONFIG Использует конфигурацию из settings.IMAGE_PROCESSING_CONFIG
""" """
# Константы для маппинга форматов и расширений файлов
FORMAT_EXTENSIONS = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
# Размеры для обработки
SIZE_KEYS = ['original', 'large', 'medium', 'thumbnail']
@staticmethod @staticmethod
def _get_config(): def _get_config():
"""Получить конфигурацию из settings""" """Получить конфигурацию из settings"""
@@ -47,11 +57,15 @@ class ImageProcessor:
return formats.get(size_key, {'format': 'JPEG', 'quality': 90}) return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
@staticmethod @staticmethod
def _get_folder(size_key): def _get_file_extension(image_format):
"""Получить папку для сохранения заданного типа изображения""" """Получить расширение файла для заданного формата"""
config = ImageProcessor._get_config() return ImageProcessor.FORMAT_EXTENSIONS.get(image_format, 'jpg')
folders = config.get('folders', {})
return folders.get(size_key, size_key) @staticmethod
def _normalize_size_name(size_key):
"""Преобразовать 'thumbnail' в 'thumb' для имени файла"""
return 'thumb' if size_key == 'thumbnail' else size_key
@staticmethod @staticmethod
def process_image(image_file, base_path, entity_id=None, photo_id=None): def process_image(image_file, base_path, entity_id=None, photo_id=None):
@@ -74,8 +88,11 @@ class ImageProcessor:
} }
Raises: Raises:
ValueError: Если файл не является изображением ValueError: Если файл не является изображением или отсутствуют обязательные параметры
""" """
if entity_id is None or photo_id is None:
raise ValueError("entity_id and photo_id are required parameters")
try: try:
# Открываем изображение # Открываем изображение
img = Image.open(image_file) img = Image.open(image_file)
@@ -104,11 +121,8 @@ class ImageProcessor:
size_dims = ImageProcessor._get_size_dimensions(size_key) size_dims = ImageProcessor._get_size_dimensions(size_key)
resized_img = ImageProcessor._resize_image(img, size_dims) resized_img = ImageProcessor._resize_image(img, size_dims)
# Переименовываем thumbnail в thumb для конечного пользователя
final_size_key = 'thumb' if size_key == 'thumbnail' else size_key
size_path = ImageProcessor._save_image_version( size_path = ImageProcessor._save_image_version(
resized_img, base_path, entity_id, photo_id, size_key, final_size_key resized_img, base_path, entity_id, photo_id, size_key
) )
saved_paths[size_key] = size_path saved_paths[size_key] = size_path
@@ -163,9 +177,39 @@ class ImageProcessor:
return new_img return new_img
@staticmethod @staticmethod
def _save_image_version(img, base_path, entity_id, photo_id, size_key, final_size_name=None): def _make_square_image(img, max_size):
""" """
Сохраняет версию изображения в новой структуре с фиксированными именами. Делает изображение квадратным с белым фоном.
Args:
img: PIL Image object
max_size: Максимальный размер стороны квадрата
Returns:
PIL Image object - квадратное изображение
"""
# Если изображение больше max_size, масштабируем
if img.width > max_size or img.height > max_size:
scale_factor = min(max_size / img.width, max_size / img.height)
new_width = int(img.width * scale_factor)
new_height = int(img.height * scale_factor)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Создаем квадратное изображение
size_obj = min(max(img.width, img.height), max_size)
square_img = Image.new('RGB', (size_obj, size_obj), (255, 255, 255))
# Центрируем изображение
offset_x = (size_obj - img.width) // 2
offset_y = (size_obj - img.height) // 2
square_img.paste(img, (offset_x, offset_y))
return square_img
@staticmethod
def _save_image_version(img, base_path, entity_id, photo_id, size_key):
"""
Сохраняет версию изображения с фиксированными именами.
Использует формат и качество из конфигурации для каждого размера. Использует формат и качество из конфигурации для каждого размера.
Args: Args:
@@ -174,7 +218,6 @@ class ImageProcessor:
entity_id: ID сущности (product_id, category_id, kit_id) entity_id: ID сущности (product_id, category_id, kit_id)
photo_id: ID фотографии photo_id: ID фотографии
size_key: Ключ размера ('original', 'large', 'medium', 'thumbnail') size_key: Ключ размера ('original', 'large', 'medium', 'thumbnail')
final_size_name: Имя размера в файле ('original', 'large', 'medium', 'thumb')
Returns: Returns:
str: Путь сохраненного файла относительно MEDIA_ROOT str: Путь сохраненного файла относительно MEDIA_ROOT
@@ -184,55 +227,23 @@ class ImageProcessor:
image_format = format_config.get('format', 'JPEG') image_format = format_config.get('format', 'JPEG')
quality = format_config.get('quality', 90) quality = format_config.get('quality', 90)
# Определяем расширение файла в зависимости от формата # Определяем расширение и имя файла
ext_map = { extension = ImageProcessor._get_file_extension(image_format)
'JPEG': 'jpg', final_size_name = ImageProcessor._normalize_size_name(size_key)
'WEBP': 'webp',
'PNG': 'png',
}
extension = ext_map.get(image_format, 'jpg')
# Определяем имя размера для файла ('thumb' вместо 'thumbnail')
if final_size_name is None:
final_size_name = 'thumb' if size_key == 'thumbnail' else size_key
# Создаем имя файла с фиксированным именем и расширением
filename = f"{final_size_name}.{extension}" filename = f"{final_size_name}.{extension}"
# Создаем путь в новой структуре: base_path/entity_id/photo_id/filename # Создаем путь: base_path/entity_id/photo_id/filename
file_path = f"{base_path}/{entity_id}/{photo_id}/{filename}" file_path = f"{base_path}/{entity_id}/{photo_id}/{filename}"
# Обрабатываем оригинал - делаем квадратным
if size_key == 'original':
max_size = ImageProcessor._get_size_dimensions('original')[0]
img = ImageProcessor._make_square_image(img, max_size)
# Сохраняем в памяти # Сохраняем в памяти
img_io = BytesIO() img_io = BytesIO()
# Масштабируем оригинал если необходимо (для original размера)
if size_key == 'original':
max_size = ImageProcessor._get_size_dimensions('original')[0] # квадратный размер
# Если оригинал больше максимального размера, масштабируем
if img.width > max_size or img.height > max_size:
# Вычисляем новый размер с сохранением пропорций
scale_factor = min(max_size / img.width, max_size / img.height)
new_width = int(img.width * scale_factor)
new_height = int(img.height * scale_factor)
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Делаем изображение квадратным: добавляем белый фон
size_obj = max(img.width, img.height)
# Но не больше max_size
if size_obj > max_size:
size_obj = max_size
square_img = Image.new('RGB', (size_obj, size_obj), (255, 255, 255))
offset_x = (size_obj - img.width) // 2
offset_y = (size_obj - img.height) // 2
square_img.paste(img, (offset_x, offset_y))
img = square_img
# Сохраняем с указанным форматом и качеством
save_kwargs = {'format': image_format, 'optimize': True} save_kwargs = {'format': image_format, 'optimize': True}
# Качество поддерживается только для JPEG и WebP
if image_format in ('JPEG', 'WEBP'): if image_format in ('JPEG', 'WEBP'):
save_kwargs['quality'] = quality save_kwargs['quality'] = quality
@@ -248,7 +259,7 @@ class ImageProcessor:
@staticmethod @staticmethod
def delete_all_versions(base_path, original_image_path, entity_id=None, photo_id=None): def delete_all_versions(base_path, original_image_path, entity_id=None, photo_id=None):
""" """
Удаляет все версии изображения (original, large, medium, thumb) из новой структуры. Удаляет все версии изображения (original, large, medium, thumb).
Args: Args:
base_path: Базовый путь (например, 'products') base_path: Базовый путь (например, 'products')
@@ -259,90 +270,28 @@ class ImageProcessor:
if not original_image_path: if not original_image_path:
return return
# Если переданы entity_id и photo_id, используем новую структуру if entity_id is None or photo_id is None:
if entity_id is not None and photo_id is not None: logger.error("entity_id and photo_id are required for deleting images")
# Удаляем файлы в новой структуре return
for size_key in ['original', 'large', 'medium', 'thumbnail']:
format_config = ImageProcessor._get_format_config(size_key)
image_format = format_config.get('format', 'JPEG')
# Определяем расширение # Удаляем все размеры
ext_map = { for size_key in ImageProcessor.SIZE_KEYS:
'JPEG': 'jpg', format_config = ImageProcessor._get_format_config(size_key)
'WEBP': 'webp', image_format = format_config.get('format', 'JPEG')
'PNG': 'png',
}
extension = ext_map.get(image_format, 'jpg')
# Определяем имя размера для файла ('thumb' вместо 'thumbnail') # Определяем расширение и имя файла
final_size_name = 'thumb' if size_key == 'thumbnail' else size_key extension = ImageProcessor._get_file_extension(image_format)
final_size_name = ImageProcessor._normalize_size_name(size_key)
size_filename = f"{final_size_name}.{extension}"
# Создаем имя файла для этого размера # Создаем путь
size_filename = f"{final_size_name}.{extension}" file_path = f"{base_path}/{entity_id}/{photo_id}/{size_filename}"
# Создаем путь в новой структуре try:
file_path = f"{base_path}/{entity_id}/{photo_id}/{size_filename}" if default_storage.exists(file_path):
default_storage.delete(file_path)
try: logger.info(f"Deleted file: {file_path}")
if default_storage.exists(file_path): else:
default_storage.delete(file_path) logger.warning(f"File not found: {file_path}")
logger.info(f"Deleted file: {file_path}") except Exception as e:
else: logger.error(f"Error deleting {file_path}: {str(e)}", exc_info=True)
logger.warning(f"File not found: {file_path}")
except Exception as e:
logger.error(f"Error deleting {file_path}: {str(e)}", exc_info=True)
else:
# Для совместимости с предыдущей структурой
# Извлекаем имя файла из пути
filename = os.path.basename(str(original_image_path))
# Удаляем расширение и последний размер для получения base_filename
# Из 'robot-50cm_1729611234567_original.jpg' получаем 'robot-50cm_1729611234567'
# Важно: используем максимум 2 разделителя, т.к. в base_filename может быть _
parts = filename.rsplit('_', 1)
if len(parts) == 2:
base_filename = parts[0]
else:
# Если формат не совпадает, используем полное имя без расширения
base_filename = os.path.splitext(filename)[0]
config = ImageProcessor._get_config()
# Удаляем все версии в старой структуре
for size_key in ['original', 'large', 'medium', 'thumbnail']:
format_config = ImageProcessor._get_format_config(size_key)
image_format = format_config.get('format', 'JPEG')
# Определяем расширение
ext_map = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
extension = ext_map.get(image_format, 'jpg')
# Создаем имя файла для этого размера
size_filename = f"{base_filename}_{size_key}.{extension}"
folder = ImageProcessor._get_folder(size_key)
file_path = f"{base_path}/{folder}/{size_filename}"
try:
if default_storage.exists(file_path):
default_storage.delete(file_path)
logger.info(f"Deleted file: {file_path}")
else:
logger.warning(f"File not found: {file_path}")
except Exception as e:
logger.error(f"Error deleting {file_path}: {str(e)}", exc_info=True)
@staticmethod
def _generate_unique_id():
"""
Генерирует уникальный ID для имени файла.
Returns:
str: Уникальный ID (timestamp + random)
"""
import time
import random
return f"{int(time.time()*1000)}{random.randint(1000, 9999)}"

View File

@@ -13,22 +13,18 @@ class ImageService:
Динамически строит URL на основе пути к оригинальному файлу. Динамически строит URL на основе пути к оригинальному файлу.
""" """
# Константы для маппинга форматов и расширений файлов
FORMAT_EXTENSIONS = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
@staticmethod @staticmethod
def _get_config(): def _get_config():
"""Получить конфигурацию из settings""" """Получить конфигурацию из settings"""
return getattr(settings, 'IMAGE_PROCESSING_CONFIG', {}) return getattr(settings, 'IMAGE_PROCESSING_CONFIG', {})
@staticmethod
def _get_size_folders():
"""Получить папки для разных размеров из конфигурации"""
config = ImageService._get_config()
return config.get('folders', {
'thumbnail': 'thumbnails',
'medium': 'medium',
'large': 'large',
'original': 'originals',
})
@staticmethod @staticmethod
def _get_format_config(size_key): def _get_format_config(size_key):
"""Получить конфигурацию формата для заданного типа изображения""" """Получить конфигурацию формата для заданного типа изображения"""
@@ -37,32 +33,25 @@ class ImageService:
return formats.get(size_key, {'format': 'JPEG', 'quality': 90}) return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
@staticmethod @staticmethod
def _get_file_extension(size_key): def _get_file_extension(image_format):
"""Получить расширение файла для заданного типа изображения""" """Получить расширение файла для заданного формата"""
format_config = ImageService._get_format_config(size_key) return ImageService.FORMAT_EXTENSIONS.get(image_format, 'jpg')
image_format = format_config.get('format', 'JPEG')
ext_map = { @staticmethod
'JPEG': 'jpg', def _normalize_size_name(size_key):
'WEBP': 'webp', """Преобразовать 'thumbnail' в 'thumb' для имени файла"""
'PNG': 'png', return 'thumb' if size_key == 'thumbnail' else size_key
}
return ext_map.get(image_format, 'jpg')
@staticmethod @staticmethod
def get_url(original_image_path, size='medium'): def get_url(original_image_path, size='medium'):
""" """
Получает URL изображения нужного размера. Получает URL изображения нужного размера.
Работает с новой структурой: Структура хранения: base_path/entity_id/photo_id/size.ext
- products/<entity_id>/<photo_id>/original.jpg Пример: products/123/456/medium.webp
- products/<entity_id>/<photo_id>/large.webp
- products/<entity_id>/<photo_id>/medium.webp
- products/<entity_id>/<photo_id>/thumb.webp
Args: Args:
original_image_path: Путь к оригинальному файлу (из models.image) original_image_path: Путь к оригинальному файлу (из models.image)
Обычно это путь к файлу 'original'
Пример: products/123/456/original.jpg Пример: products/123/456/original.jpg
size: Размер ('original', 'large', 'medium', 'thumbnail') size: Размер ('original', 'large', 'medium', 'thumbnail')
По умолчанию 'medium' По умолчанию 'medium'
@@ -74,115 +63,29 @@ class ImageService:
return '' return ''
try: try:
# Работаем с новой структурой: products/<entity_id>/<photo_id>/original.jpg
path_str = str(original_image_path) path_str = str(original_image_path)
parts = path_str.split('/') parts = path_str.split('/')
if len(parts) >= 3: if len(parts) < 3:
# Извлекаем base_path, entity_id, photo_id из пути return ''
base_path = parts[0] # products, kits, categories
entity_id = parts[1] # ID сущности
photo_id = parts[2] # ID фото
# Определяем размер в имени файла # Извлекаем base_path, entity_id, photo_id из пути
filename = parts[-1] if parts else os.path.basename(path_str) base_path = parts[0] # products, kits, categories
entity_id = parts[1] # ID сущности
photo_id = parts[2] # ID фото
# Проверяем, является ли это новой структурой # Определяем расширение из конфигурации
if filename in ['original.jpg', 'large.webp', 'medium.webp', 'thumb.webp']: format_config = ImageService._get_format_config(size)
# Это новая структура, заменяем только размер image_format = format_config.get('format', 'JPEG')
ext_map = { extension = ImageService._get_file_extension(image_format)
'original': 'jpg',
'large': 'webp',
'medium': 'webp',
'thumbnail': 'webp',
}
target_ext = ext_map.get(size, 'jpg')
# Переименовываем thumbnail в thumb # Преобразуем thumbnail в thumb
final_size_name = 'thumb' if size == 'thumbnail' else size final_size_name = ImageService._normalize_size_name(size)
# Создаем путь в новой структуре # Создаем путь и возвращаем URL
new_path = f"{base_path}/{entity_id}/{photo_id}/{final_size_name}.{target_ext}" file_path = f"{base_path}/{entity_id}/{photo_id}/{final_size_name}.{extension}"
return f"{settings.MEDIA_URL}{file_path}"
# Проверяем существование файла
if default_storage.exists(new_path):
return f"{settings.MEDIA_URL}{new_path}"
# Если файл не найден, пробуем с другим расширением
# Определяем расширение из конфигурации
format_config = ImageService._get_format_config(size)
image_format = format_config.get('format', 'JPEG')
ext_map_config = {
'JPEG': 'jpg',
'WEBP': 'webp',
'PNG': 'png',
}
target_ext = ext_map_config.get(image_format, 'jpg')
final_size_name = 'thumb' if size == 'thumbnail' else size
fallback_path = f"{base_path}/{entity_id}/{photo_id}/{final_size_name}.{target_ext}"
if default_storage.exists(fallback_path):
return f"{settings.MEDIA_URL}{fallback_path}"
return f"{settings.MEDIA_URL}{path_str}"
# Старая структура для совместимости
filename = os.path.basename(path_str)
# Определяем базовый путь (products, kits, categories)
if len(parts) > 0:
base_path = parts[0]
else:
base_path = 'products'
# Проверяем старый формат имени файла с расширением
# Поддерживаем jpg, webp, png расширения
if filename.endswith(('.jpg', '.webp', '.png')):
# Определяем расширение файла
file_ext = os.path.splitext(filename)[1] # .jpg, .webp и т.д.
filename_without_ext = filename[:-(len(file_ext))] # Имя без расширения
# Разделяем по последнему _ для получения base_filename и size_key
parts_of_name = filename_without_ext.rsplit('_', 1)
if len(parts_of_name) == 2:
base_filename, file_size_key = parts_of_name
# Это старый формат с явным указанием размера в имени
# Получаем расширение для целевого размера
target_ext = ImageService._get_file_extension(size)
# Строим папку
size_folders = ImageService._get_size_folders()
folder = size_folders.get(size, 'medium')
# Сначала пытаемся с правильным расширением из конфигурации
filename_new = f"{base_filename}_{size}.{target_ext}"
new_path_primary = f"{base_path}/{folder}/{filename_new}"
# Если файл существует - возвращаем его
if default_storage.exists(new_path_primary):
return f"{settings.MEDIA_URL}{new_path_primary}"
# Иначе пробуем старый формат (все .jpg) для совместимости
filename_fallback = f"{base_filename}_{size}.jpg"
new_path_fallback = f"{base_path}/{folder}/{filename_fallback}"
if default_storage.exists(new_path_fallback):
return f"{settings.MEDIA_URL}{new_path_fallback}"
# Если ничего не найдено, возвращаем путь с новым расширением (браузер покажет ошибку)
return f"{settings.MEDIA_URL}{new_path_primary}"
# Строим новый путь (для старых файлов без новой структуры)
size_folders = ImageService._get_size_folders()
folder = size_folders.get(size, 'medium')
new_path = f"{base_path}/{folder}/{filename}"
# Возвращаем URL
return f"{settings.MEDIA_URL}{new_path}"
except Exception: except Exception:
return '' return ''

View File

@@ -1,8 +1,10 @@
asgiref==3.10.0 asgiref==3.10.0
Django==5.2.7 Django==5.1.4
django-nested-admin==4.1.5 django-nested-admin==4.1.5
django-phonenumber-field==8.3.0 django-phonenumber-field==8.3.0
django-tenants==3.7.0
pillow==12.0.0 pillow==12.0.0
psycopg[binary]>=3.1
python-monkey-business==1.1.0 python-monkey-business==1.1.0
sqlparse==0.5.3 sqlparse==0.5.3
tzdata==2025.2 tzdata==2025.2

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.

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""
Скрипт для переключения в пространство конкретного тенанта.
Использование:
python switch_to_tenant.py grach
"""
import os
import sys
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()
from django.db import connection
from tenants.models import Client
if len(sys.argv) < 2:
print("Использование: python switch_to_tenant.py <schema_name>")
print("\nДоступные тенанты:")
for client in Client.objects.all():
print(f" - {client.schema_name}: {client.name}")
sys.exit(1)
schema_name = sys.argv[1]
try:
# Находим тенанта
client = Client.objects.get(schema_name=schema_name)
print(f"Найден тенант: {client.name} ({client.schema_name})")
# Переключаемся на схему тенанта
connection.set_tenant(client)
print(f"Переключено на схему: {connection.tenant.schema_name}")
print("")
# Теперь можем работать с данными тенанта
print("=" * 60)
print("Теперь вы работаете в контексте тенанта!")
print("=" * 60)
print("")
# Примеры работы с данными тенанта
from products.models import Product
from customers.models import Customer
from orders.models import Order
products_count = Product.objects.count()
customers_count = Customer.objects.count()
orders_count = Order.objects.count()
print(f"Товары: {products_count}")
print(f"Клиенты: {customers_count}")
print(f"Заказы: {orders_count}")
print("")
# Интерактивный режим
print("Запуск интерактивной оболочки...")
print("Вы можете использовать: Product, Customer, Order и другие модели")
print("")
import code
code.interact(local=locals())
except Client.DoesNotExist:
print(f"Ошибка: Тенант с schema_name '{schema_name}' не найден")
print("\nДоступные тенанты:")
for client in Client.objects.all():
print(f" - {client.schema_name}: {client.name}")
sys.exit(1)

View File

View 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
View 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

View File

View 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', 'да']

View 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

View 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': 'Домены',
},
),
]

View File

@@ -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'],
},
),
]

View File

284
myproject/tenants/models.py Normal file
View 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
)

View 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>

View 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 %}

View File

@@ -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
View 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'),
]

View 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