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:
302
myproject/DJANGO_TENANTS_SETUP.md
Normal file
302
myproject/DJANGO_TENANTS_SETUP.md
Normal 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
81
myproject/QUICK_START.md
Normal 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
297
myproject/START_HERE.md
Normal 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)
|
||||
324
myproject/TENANT_REGISTRATION_GUIDE.md
Normal file
324
myproject/TENANT_REGISTRATION_GUIDE.md
Normal 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` установлен
|
||||
|
||||
---
|
||||
|
||||
**Система готова к использованию!**
|
||||
|
||||
Теперь вы можете:
|
||||
- Принимать заявки на регистрацию
|
||||
- Модерировать их через админку
|
||||
- Управлять подписками
|
||||
- Контролировать доступ к магазинам
|
||||
@@ -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.utils.timezone
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-25 13:44
|
||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
||||
|
||||
import django.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
|
||||
@@ -104,51 +104,51 @@ class Customer(models.Model):
|
||||
return discounts.get(self.loyalty_tier, 0)
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
"""Override to handle unique phone validation properly during updates"""
|
||||
# Run the phone number normalization again before unique validation
|
||||
"""Переопределение для корректной проверки уникальности телефона при обновлениях"""
|
||||
# Снова нормализуем номер телефона перед проверкой уникальности
|
||||
if self.phone:
|
||||
# Check for existing customers with the same phone (excluding current instance if updating)
|
||||
# Проверяем существующих клиентов с таким же телефоном (исключая текущий экземпляр при обновлении)
|
||||
existing = Customer.objects.filter(phone=self.phone)
|
||||
if self.pk:
|
||||
existing = existing.exclude(pk=self.pk)
|
||||
if existing.exists():
|
||||
raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'})
|
||||
|
||||
# Call parent validate_unique to handle other validation
|
||||
# Вызываем родительский validate_unique для обработки других проверок
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
def clean_phone(self):
|
||||
"""Custom cleaning for phone field to normalize before validation."""
|
||||
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
|
||||
if self.phone:
|
||||
try:
|
||||
# Parse the phone number to check if it's valid and normalize it
|
||||
# Парсим номер телефона, чтобы проверить его валидность и нормализовать
|
||||
raw_phone = str(self.phone)
|
||||
|
||||
# If it starts with '8' and has 11 digits, it might be Russian domestic format
|
||||
# Если начинается с '8' и имеет 11 цифр, это может быть российский формат
|
||||
if raw_phone.startswith('8') and len(raw_phone) == 11:
|
||||
# Try BY first for Belarusian numbers
|
||||
# Сначала пробуем BY для белорусских номеров
|
||||
parsed = phonenumbers.parse(raw_phone, "BY")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
# If BY doesn't work, try RU as fallback
|
||||
# Если BY не работает, пробуем RU как запасной вариант
|
||||
parsed = phonenumbers.parse(raw_phone, "RU")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
# Try to parse without country code (might already be in international format)
|
||||
# Пробуем парсить без кода страны (может быть уже в международном формате)
|
||||
parsed = phonenumbers.parse(raw_phone, None)
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
|
||||
except phonenumbers.NumberParseException:
|
||||
# If parsing fails, return as is and let field validation handle it
|
||||
# Если парсинг не удался, возвращаем как есть и позволяем валидации поля обработать это
|
||||
pass
|
||||
|
||||
return self.phone
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
# Normalize phone before field validation runs
|
||||
# Нормализуем телефон перед запуском проверки полей
|
||||
if not exclude:
|
||||
exclude = []
|
||||
if 'phone' not in exclude and self.phone:
|
||||
@@ -158,33 +158,33 @@ class Customer(models.Model):
|
||||
except Exception:
|
||||
normalized = None
|
||||
if normalized:
|
||||
# assign normalized value (E.164) so PhoneNumberField sees корректный формат
|
||||
# присваиваем нормализованное значение (E.164), чтобы PhoneNumberField видел корректный формат
|
||||
self.phone = normalized
|
||||
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
def clean(self):
|
||||
"""Additional validation if needed."""
|
||||
"""Дополнительная валидация при необходимости."""
|
||||
super().clean()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Ensure phone is normalized even if save is called directly (not through form)
|
||||
# At this point, if it came through form validation, phone should already be normalized
|
||||
# But if save is called directly on the model, we still need to normalize
|
||||
# Обеспечиваем нормализацию телефона, даже если save вызывается напрямую (не через форму)
|
||||
# На данный момент, если вызов прошел через валидацию формы, телефон уже должен быть нормализован
|
||||
# Но если save вызывается непосредственно в модели, нам все равно нужно нормализовать
|
||||
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11:
|
||||
# This is likely a domestic format number that needs normalization
|
||||
# Это, вероятно, номер в формате страны, который требует нормализации
|
||||
try:
|
||||
# Try BY first for Belarusian numbers
|
||||
# Сначала пробуем BY для белорусских номеров
|
||||
parsed = phonenumbers.parse(str(self.phone), "BY")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
else:
|
||||
# If BY doesn't work, try RU as fallback
|
||||
# Если BY не работает, пробуем RU как запасной вариант
|
||||
parsed = phonenumbers.parse(str(self.phone), "RU")
|
||||
if phonenumbers.is_valid_number(parsed):
|
||||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||
except phonenumbers.NumberParseException:
|
||||
# If parsing fails, don't change it and let the field validation handle it
|
||||
# Если парсинг не удался, не меняем и позволяем валидации поля обработать это
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
@@ -269,7 +269,7 @@ class Address(models.Model):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.is_default:
|
||||
# If this address is being set as default, unset the default flag on other addresses for this customer
|
||||
# Если этот адрес устанавливается как адрес по умолчанию, снимаем флаг по умолчанию с других адресов этого клиента
|
||||
Address.objects.filter(customer=self.customer, is_default=True).update(is_default=False)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -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
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -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:
|
||||
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'))
|
||||
This is used for individual shop subdomains where shop owners manage their business.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('_nested_admin/', include('nested_admin.urls')), # Для nested admin
|
||||
path('admin/', admin.site.urls),
|
||||
path('', views.index, name='index'), # Main page
|
||||
path('accounts/', include('accounts.urls')),
|
||||
path('products/', include('products.urls')),
|
||||
path('customers/', include('customers.urls')),
|
||||
path('admin/', admin.site.urls), # Админка для владельца магазина (доступна на поддомене)
|
||||
# TODO: Add web interface for shop owners
|
||||
# path('', views.dashboard, name='dashboard'),
|
||||
# path('products/', include('products.urls')),
|
||||
# path('orders/', include('orders.urls')),
|
||||
# path('customers/', include('customers.urls')),
|
||||
]
|
||||
|
||||
# Serve media files during development
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
|
||||
23
myproject/myproject/urls_public.py
Normal file
23
myproject/myproject/urls_public.py
Normal 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)
|
||||
@@ -1,28 +1,174 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.contrib import admin
|
||||
from .models import Customer, 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'
|
||||
from .models import Order, OrderItem
|
||||
|
||||
|
||||
class OrderItemInline(admin.TabularInline):
|
||||
"""
|
||||
Inline для управления позициями заказа прямо в форме заказа.
|
||||
"""
|
||||
model = OrderItem
|
||||
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):
|
||||
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]
|
||||
|
||||
actions = [
|
||||
'mark_as_confirmed',
|
||||
'mark_as_in_assembly',
|
||||
'mark_as_in_delivery',
|
||||
'mark_as_delivered',
|
||||
'mark_as_paid',
|
||||
]
|
||||
|
||||
admin.site.register(Customer, CustomerAdmin)
|
||||
admin.site.register(Order, OrderAdmin)
|
||||
admin.site.register(OrderItem)
|
||||
def mark_as_confirmed(self, request, queryset):
|
||||
"""Отметить заказы как подтвержденные"""
|
||||
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',)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -10,80 +9,90 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('customers', '0001_initial'),
|
||||
('products', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('shops', '0001_initial'),
|
||||
]
|
||||
|
||||
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(
|
||||
name='Order',
|
||||
fields=[
|
||||
('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='Статус')),
|
||||
('total_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Общая сумма')),
|
||||
('order_number', models.CharField(editable=False, help_text='Уникальный номер заказа для отображения клиенту', max_length=50, unique=True, 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='Дата создания')),
|
||||
('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={
|
||||
'verbose_name': 'Заказ',
|
||||
'verbose_name_plural': 'Заказы',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderItem',
|
||||
fields=[
|
||||
('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='Количество')),
|
||||
('snapshot_name', models.CharField(max_length=200, verbose_name='Название (на момент заказа)')),
|
||||
('snapshot_sku', models.CharField(blank=True, max_length=100, null=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='Комплект')),
|
||||
('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
|
||||
('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, 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={
|
||||
'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(
|
||||
model_name='order',
|
||||
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(
|
||||
model_name='order',
|
||||
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(
|
||||
model_name='orderitem',
|
||||
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'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,135 +1,334 @@
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
from accounts.models import CustomUser
|
||||
from customers.models import Customer, Address
|
||||
from products.models import Product, ProductKit
|
||||
|
||||
|
||||
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})"
|
||||
from shops.models import Shop
|
||||
import uuid
|
||||
|
||||
|
||||
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 = [
|
||||
('created', 'Создан'),
|
||||
('new', 'Новый'),
|
||||
('confirmed', 'Подтвержден'),
|
||||
('assembled', 'Собран'),
|
||||
('in_assembly', 'В сборке'),
|
||||
('in_delivery', 'В доставке'),
|
||||
('delivered', 'Доставлен'),
|
||||
('cancelled', 'Отменен'),
|
||||
]
|
||||
|
||||
customer = models.ForeignKey(Customer, on_delete=models.CASCADE,
|
||||
related_name='orders', verbose_name="Клиент")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='created',
|
||||
verbose_name="Статус")
|
||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Общая сумма")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='new',
|
||||
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:
|
||||
verbose_name = "Заказ"
|
||||
verbose_name_plural = "Заказы"
|
||||
indexes = [
|
||||
models.Index(fields=['status']),
|
||||
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=['order_number']),
|
||||
]
|
||||
ordering = ['-created_at']
|
||||
|
||||
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):
|
||||
"""
|
||||
Строка заказа — может быть простым товаром или комплектом.
|
||||
Позиция (товар) в заказе.
|
||||
Хранит информацию о товаре или комплекте, количестве и цене на момент заказа.
|
||||
"""
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE,
|
||||
related_name='items', verbose_name="Заказ")
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True, blank=True,
|
||||
related_name='order_items', verbose_name="Товар")
|
||||
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, null=True, blank=True,
|
||||
related_name='order_items', verbose_name="Комплект")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, default=1,
|
||||
verbose_name="Количество")
|
||||
order = models.ForeignKey(
|
||||
Order,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='items',
|
||||
verbose_name="Заказ"
|
||||
)
|
||||
|
||||
# Снапшот-поля (для истории и отчётов)
|
||||
snapshot_name = models.CharField(max_length=200, verbose_name="Название (на момент заказа)")
|
||||
snapshot_sku = models.CharField(max_length=100, blank=True, null=True,
|
||||
verbose_name="Артикул (на момент заказа)")
|
||||
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена продажи")
|
||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Себестоимость")
|
||||
composition_snapshot = models.JSONField(null=True, blank=True,
|
||||
verbose_name="Состав комплекта (снапшот)")
|
||||
# Товар или комплект (один из двух должен быть заполнен)
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='order_items',
|
||||
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:
|
||||
verbose_name = "Позиция заказа"
|
||||
verbose_name_plural = "Позиции заказов"
|
||||
verbose_name_plural = "Позиции заказа"
|
||||
indexes = [
|
||||
models.Index(fields=['order']),
|
||||
models.Index(fields=['product']),
|
||||
models.Index(fields=['product_kit']),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Валидация: либо product, либо kit, но не оба
|
||||
if self.product and self.kit:
|
||||
raise ValueError("Нельзя одновременно указать товар и комплект")
|
||||
if not self.product and not self.kit:
|
||||
raise ValueError("Необходимо указать либо товар, либо комплект")
|
||||
|
||||
# Заполнение снапшот-полей
|
||||
def __str__(self):
|
||||
item_name = ""
|
||||
if self.product:
|
||||
if not self.snapshot_name:
|
||||
self.snapshot_name = self.product.name
|
||||
if not self.snapshot_sku:
|
||||
self.snapshot_sku = self.product.sku
|
||||
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
|
||||
item_name = self.product.name
|
||||
elif self.product_kit:
|
||||
item_name = self.product_kit.name
|
||||
return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
|
||||
|
||||
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)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.snapshot_name} x{self.quantity} в заказе #{self.order.id}"
|
||||
def get_total_price(self):
|
||||
"""Возвращает общую стоимость позиции"""
|
||||
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 "Не указано"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
from django.conf import settings
|
||||
@@ -91,7 +91,7 @@ class Migration(migrations.Migration):
|
||||
name='ProductCategoryPhoto',
|
||||
fields=[
|
||||
('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='Порядок')),
|
||||
('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='Категория')),
|
||||
@@ -131,7 +131,7 @@ class Migration(migrations.Migration):
|
||||
name='ProductKitPhoto',
|
||||
fields=[
|
||||
('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='Порядок')),
|
||||
('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='Комплект')),
|
||||
@@ -146,7 +146,7 @@ class Migration(migrations.Migration):
|
||||
name='ProductPhoto',
|
||||
fields=[
|
||||
('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='Порядок')),
|
||||
('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='Товар')),
|
||||
|
||||
@@ -21,6 +21,16 @@ class ImageProcessor:
|
||||
Использует конфигурацию из settings.IMAGE_PROCESSING_CONFIG
|
||||
"""
|
||||
|
||||
# Константы для маппинга форматов и расширений файлов
|
||||
FORMAT_EXTENSIONS = {
|
||||
'JPEG': 'jpg',
|
||||
'WEBP': 'webp',
|
||||
'PNG': 'png',
|
||||
}
|
||||
|
||||
# Размеры для обработки
|
||||
SIZE_KEYS = ['original', 'large', 'medium', 'thumbnail']
|
||||
|
||||
@staticmethod
|
||||
def _get_config():
|
||||
"""Получить конфигурацию из settings"""
|
||||
@@ -47,11 +57,15 @@ class ImageProcessor:
|
||||
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
|
||||
|
||||
@staticmethod
|
||||
def _get_folder(size_key):
|
||||
"""Получить папку для сохранения заданного типа изображения"""
|
||||
config = ImageProcessor._get_config()
|
||||
folders = config.get('folders', {})
|
||||
return folders.get(size_key, size_key)
|
||||
def _get_file_extension(image_format):
|
||||
"""Получить расширение файла для заданного формата"""
|
||||
return ImageProcessor.FORMAT_EXTENSIONS.get(image_format, 'jpg')
|
||||
|
||||
@staticmethod
|
||||
def _normalize_size_name(size_key):
|
||||
"""Преобразовать 'thumbnail' в 'thumb' для имени файла"""
|
||||
return 'thumb' if size_key == 'thumbnail' else size_key
|
||||
|
||||
|
||||
@staticmethod
|
||||
def process_image(image_file, base_path, entity_id=None, photo_id=None):
|
||||
@@ -74,8 +88,11 @@ class ImageProcessor:
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValueError: Если файл не является изображением
|
||||
ValueError: Если файл не является изображением или отсутствуют обязательные параметры
|
||||
"""
|
||||
if entity_id is None or photo_id is None:
|
||||
raise ValueError("entity_id and photo_id are required parameters")
|
||||
|
||||
try:
|
||||
# Открываем изображение
|
||||
img = Image.open(image_file)
|
||||
@@ -103,12 +120,9 @@ class ImageProcessor:
|
||||
for size_key in ['large', 'medium', 'thumbnail']:
|
||||
size_dims = ImageProcessor._get_size_dimensions(size_key)
|
||||
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(
|
||||
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
|
||||
|
||||
@@ -163,9 +177,39 @@ class ImageProcessor:
|
||||
return new_img
|
||||
|
||||
@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:
|
||||
@@ -174,7 +218,6 @@ class ImageProcessor:
|
||||
entity_id: ID сущности (product_id, category_id, kit_id)
|
||||
photo_id: ID фотографии
|
||||
size_key: Ключ размера ('original', 'large', 'medium', 'thumbnail')
|
||||
final_size_name: Имя размера в файле ('original', 'large', 'medium', 'thumb')
|
||||
|
||||
Returns:
|
||||
str: Путь сохраненного файла относительно MEDIA_ROOT
|
||||
@@ -184,55 +227,23 @@ class ImageProcessor:
|
||||
image_format = format_config.get('format', 'JPEG')
|
||||
quality = format_config.get('quality', 90)
|
||||
|
||||
# Определяем расширение файла в зависимости от формата
|
||||
ext_map = {
|
||||
'JPEG': 'jpg',
|
||||
'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
|
||||
|
||||
# Создаем имя файла с фиксированным именем и расширением
|
||||
# Определяем расширение и имя файла
|
||||
extension = ImageProcessor._get_file_extension(image_format)
|
||||
final_size_name = ImageProcessor._normalize_size_name(size_key)
|
||||
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}"
|
||||
|
||||
# Обрабатываем оригинал - делаем квадратным
|
||||
if size_key == 'original':
|
||||
max_size = ImageProcessor._get_size_dimensions('original')[0]
|
||||
img = ImageProcessor._make_square_image(img, max_size)
|
||||
|
||||
# Сохраняем в памяти
|
||||
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}
|
||||
|
||||
# Качество поддерживается только для JPEG и WebP
|
||||
if image_format in ('JPEG', 'WEBP'):
|
||||
save_kwargs['quality'] = quality
|
||||
|
||||
@@ -248,7 +259,7 @@ class ImageProcessor:
|
||||
@staticmethod
|
||||
def delete_all_versions(base_path, original_image_path, entity_id=None, photo_id=None):
|
||||
"""
|
||||
Удаляет все версии изображения (original, large, medium, thumb) из новой структуры.
|
||||
Удаляет все версии изображения (original, large, medium, thumb).
|
||||
|
||||
Args:
|
||||
base_path: Базовый путь (например, 'products')
|
||||
@@ -259,90 +270,28 @@ class ImageProcessor:
|
||||
if not original_image_path:
|
||||
return
|
||||
|
||||
# Если переданы entity_id и photo_id, используем новую структуру
|
||||
if entity_id is not None and photo_id is not None:
|
||||
# Удаляем файлы в новой структуре
|
||||
for size_key in ['original', 'large', 'medium', 'thumbnail']:
|
||||
format_config = ImageProcessor._get_format_config(size_key)
|
||||
image_format = format_config.get('format', 'JPEG')
|
||||
if entity_id is None or photo_id is None:
|
||||
logger.error("entity_id and photo_id are required for deleting images")
|
||||
return
|
||||
|
||||
# Определяем расширение
|
||||
ext_map = {
|
||||
'JPEG': 'jpg',
|
||||
'WEBP': 'webp',
|
||||
'PNG': 'png',
|
||||
}
|
||||
extension = ext_map.get(image_format, 'jpg')
|
||||
# Удаляем все размеры
|
||||
for size_key in ImageProcessor.SIZE_KEYS:
|
||||
format_config = ImageProcessor._get_format_config(size_key)
|
||||
image_format = format_config.get('format', 'JPEG')
|
||||
|
||||
# Определяем имя размера для файла ('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}"
|
||||
# Создаем путь
|
||||
file_path = f"{base_path}/{entity_id}/{photo_id}/{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)
|
||||
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)}"
|
||||
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)
|
||||
|
||||
@@ -13,22 +13,18 @@ class ImageService:
|
||||
Динамически строит URL на основе пути к оригинальному файлу.
|
||||
"""
|
||||
|
||||
# Константы для маппинга форматов и расширений файлов
|
||||
FORMAT_EXTENSIONS = {
|
||||
'JPEG': 'jpg',
|
||||
'WEBP': 'webp',
|
||||
'PNG': 'png',
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_config():
|
||||
"""Получить конфигурацию из settings"""
|
||||
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
|
||||
def _get_format_config(size_key):
|
||||
"""Получить конфигурацию формата для заданного типа изображения"""
|
||||
@@ -37,32 +33,25 @@ class ImageService:
|
||||
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
|
||||
|
||||
@staticmethod
|
||||
def _get_file_extension(size_key):
|
||||
"""Получить расширение файла для заданного типа изображения"""
|
||||
format_config = ImageService._get_format_config(size_key)
|
||||
image_format = format_config.get('format', 'JPEG')
|
||||
def _get_file_extension(image_format):
|
||||
"""Получить расширение файла для заданного формата"""
|
||||
return ImageService.FORMAT_EXTENSIONS.get(image_format, 'jpg')
|
||||
|
||||
ext_map = {
|
||||
'JPEG': 'jpg',
|
||||
'WEBP': 'webp',
|
||||
'PNG': 'png',
|
||||
}
|
||||
return ext_map.get(image_format, 'jpg')
|
||||
@staticmethod
|
||||
def _normalize_size_name(size_key):
|
||||
"""Преобразовать 'thumbnail' в 'thumb' для имени файла"""
|
||||
return 'thumb' if size_key == 'thumbnail' else size_key
|
||||
|
||||
@staticmethod
|
||||
def get_url(original_image_path, size='medium'):
|
||||
"""
|
||||
Получает URL изображения нужного размера.
|
||||
|
||||
Работает с новой структурой:
|
||||
- products/<entity_id>/<photo_id>/original.jpg
|
||||
- products/<entity_id>/<photo_id>/large.webp
|
||||
- products/<entity_id>/<photo_id>/medium.webp
|
||||
- products/<entity_id>/<photo_id>/thumb.webp
|
||||
Структура хранения: base_path/entity_id/photo_id/size.ext
|
||||
Пример: products/123/456/medium.webp
|
||||
|
||||
Args:
|
||||
original_image_path: Путь к оригинальному файлу (из models.image)
|
||||
Обычно это путь к файлу 'original'
|
||||
Пример: products/123/456/original.jpg
|
||||
size: Размер ('original', 'large', 'medium', 'thumbnail')
|
||||
По умолчанию 'medium'
|
||||
@@ -74,115 +63,29 @@ class ImageService:
|
||||
return ''
|
||||
|
||||
try:
|
||||
# Работаем с новой структурой: products/<entity_id>/<photo_id>/original.jpg
|
||||
path_str = str(original_image_path)
|
||||
parts = path_str.split('/')
|
||||
|
||||
if len(parts) >= 3:
|
||||
# Извлекаем base_path, entity_id, photo_id из пути
|
||||
base_path = parts[0] # products, kits, categories
|
||||
entity_id = parts[1] # ID сущности
|
||||
photo_id = parts[2] # ID фото
|
||||
|
||||
# Определяем размер в имени файла
|
||||
filename = parts[-1] if parts else os.path.basename(path_str)
|
||||
|
||||
# Проверяем, является ли это новой структурой
|
||||
if filename in ['original.jpg', 'large.webp', 'medium.webp', 'thumb.webp']:
|
||||
# Это новая структура, заменяем только размер
|
||||
ext_map = {
|
||||
'original': 'jpg',
|
||||
'large': 'webp',
|
||||
'medium': 'webp',
|
||||
'thumbnail': 'webp',
|
||||
}
|
||||
target_ext = ext_map.get(size, 'jpg')
|
||||
|
||||
# Переименовываем thumbnail в thumb
|
||||
final_size_name = 'thumb' if size == 'thumbnail' else size
|
||||
|
||||
# Создаем путь в новой структуре
|
||||
new_path = f"{base_path}/{entity_id}/{photo_id}/{final_size_name}.{target_ext}"
|
||||
|
||||
# Проверяем существование файла
|
||||
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'
|
||||
if len(parts) < 3:
|
||||
return ''
|
||||
|
||||
# Проверяем старый формат имени файла с расширением
|
||||
# Поддерживаем 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_path, entity_id, photo_id из пути
|
||||
base_path = parts[0] # products, kits, categories
|
||||
entity_id = parts[1] # ID сущности
|
||||
photo_id = parts[2] # ID фото
|
||||
|
||||
# Разделяем по последнему _ для получения base_filename и size_key
|
||||
parts_of_name = filename_without_ext.rsplit('_', 1)
|
||||
# Определяем расширение из конфигурации
|
||||
format_config = ImageService._get_format_config(size)
|
||||
image_format = format_config.get('format', 'JPEG')
|
||||
extension = ImageService._get_file_extension(image_format)
|
||||
|
||||
if len(parts_of_name) == 2:
|
||||
base_filename, file_size_key = parts_of_name
|
||||
# Это старый формат с явным указанием размера в имени
|
||||
# Преобразуем thumbnail в thumb
|
||||
final_size_name = ImageService._normalize_size_name(size)
|
||||
|
||||
# Получаем расширение для целевого размера
|
||||
target_ext = ImageService._get_file_extension(size)
|
||||
# Создаем путь и возвращаем URL
|
||||
file_path = f"{base_path}/{entity_id}/{photo_id}/{final_size_name}.{extension}"
|
||||
return f"{settings.MEDIA_URL}{file_path}"
|
||||
|
||||
# Строим папку
|
||||
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:
|
||||
return ''
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
asgiref==3.10.0
|
||||
Django==5.2.7
|
||||
Django==5.1.4
|
||||
django-nested-admin==4.1.5
|
||||
django-phonenumber-field==8.3.0
|
||||
django-tenants==3.7.0
|
||||
pillow==12.0.0
|
||||
psycopg[binary]>=3.1
|
||||
python-monkey-business==1.1.0
|
||||
sqlparse==0.5.3
|
||||
tzdata==2025.2
|
||||
|
||||
0
myproject/shops/__init__.py
Normal file
0
myproject/shops/__init__.py
Normal file
67
myproject/shops/admin.py
Normal file
67
myproject/shops/admin.py
Normal 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
6
myproject/shops/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ShopsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'shops'
|
||||
44
myproject/shops/migrations/0001_initial.py
Normal file
44
myproject/shops/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
myproject/shops/migrations/__init__.py
Normal file
0
myproject/shops/migrations/__init__.py
Normal file
142
myproject/shops/models.py
Normal file
142
myproject/shops/models.py
Normal 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
3
myproject/shops/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
myproject/shops/views.py
Normal file
3
myproject/shops/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
70
myproject/switch_to_tenant.py
Normal file
70
myproject/switch_to_tenant.py
Normal 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)
|
||||
0
myproject/tenants/__init__.py
Normal file
0
myproject/tenants/__init__.py
Normal file
8
myproject/tenants/apps.py
Normal file
8
myproject/tenants/apps.py
Normal file
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TenantsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'tenants'
|
||||
verbose_name = 'Управление тенантами'
|
||||
115
myproject/tenants/forms.py
Normal file
115
myproject/tenants/forms.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import TenantRegistration, Client, RESERVED_SCHEMA_NAMES
|
||||
|
||||
|
||||
class TenantRegistrationForm(forms.ModelForm):
|
||||
"""
|
||||
Форма регистрации нового тенанта
|
||||
"""
|
||||
class Meta:
|
||||
model = TenantRegistration
|
||||
fields = ['shop_name', 'schema_name', 'owner_name', 'owner_email', 'phone']
|
||||
widgets = {
|
||||
'shop_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Название вашего магазина',
|
||||
'required': True
|
||||
}),
|
||||
'schema_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'myshop',
|
||||
'required': True,
|
||||
'pattern': '[a-z0-9][a-z0-9\-]{1,61}[a-z0-9]',
|
||||
'title': 'Только латинские буквы в нижнем регистре, цифры и дефис (3-63 символа)'
|
||||
}),
|
||||
'owner_name': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Иван Иванов',
|
||||
'required': True
|
||||
}),
|
||||
'owner_email': forms.EmailInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'your@email.com',
|
||||
'required': True
|
||||
}),
|
||||
'phone': forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': '+375291234567',
|
||||
'required': True
|
||||
}),
|
||||
}
|
||||
labels = {
|
||||
'shop_name': 'Название магазина',
|
||||
'schema_name': 'Желаемый поддомен',
|
||||
'owner_name': 'Ваше имя',
|
||||
'owner_email': 'Email',
|
||||
'phone': 'Телефон',
|
||||
}
|
||||
help_texts = {
|
||||
'schema_name': 'Будет использоваться как поддомен: yourshop.inventory.by. '
|
||||
'Только латинские буквы, цифры и дефис (3-63 символа)',
|
||||
'owner_email': 'На этот email придет уведомление об активации',
|
||||
}
|
||||
|
||||
def clean_schema_name(self):
|
||||
"""
|
||||
Валидация поддомена:
|
||||
1. Приводим к нижнему регистру
|
||||
2. Проверяем длину
|
||||
3. Проверяем, что не зарезервировано
|
||||
4. Проверяем уникальность
|
||||
"""
|
||||
schema_name = self.cleaned_data.get('schema_name', '').lower().strip()
|
||||
|
||||
# Проверка длины
|
||||
if len(schema_name) < 3:
|
||||
raise ValidationError("Поддомен должен содержать минимум 3 символа.")
|
||||
|
||||
if len(schema_name) > 63:
|
||||
raise ValidationError("Поддомен не может быть длиннее 63 символов.")
|
||||
|
||||
# Проверка на зарезервированные имена
|
||||
if schema_name in RESERVED_SCHEMA_NAMES:
|
||||
raise ValidationError(f"Поддомен '{schema_name}' зарезервирован системой. Выберите другой.")
|
||||
|
||||
# Проверка уникальности в заявках
|
||||
if TenantRegistration.objects.filter(schema_name=schema_name).exists():
|
||||
raise ValidationError(f"Поддомен '{schema_name}' уже занят. Выберите другой.")
|
||||
|
||||
# Проверка уникальности в существующих тенантах
|
||||
if Client.objects.filter(schema_name=schema_name).exists():
|
||||
raise ValidationError(f"Поддомен '{schema_name}' уже занят. Выберите другой.")
|
||||
|
||||
return schema_name
|
||||
|
||||
def clean_owner_email(self):
|
||||
"""
|
||||
Валидация email: проверка на дубликаты для обычных пользователей
|
||||
(супер-админ может иметь несколько тенантов)
|
||||
"""
|
||||
email = self.cleaned_data.get('owner_email', '').lower().strip()
|
||||
|
||||
# Проверяем, есть ли активные заявки с таким email
|
||||
pending_registrations = TenantRegistration.objects.filter(
|
||||
owner_email=email,
|
||||
status=TenantRegistration.STATUS_PENDING
|
||||
).count()
|
||||
|
||||
if pending_registrations > 0:
|
||||
raise ValidationError(
|
||||
f"У вас уже есть заявка на регистрацию с email {email}, ожидающая проверки. "
|
||||
"Дождитесь активации или свяжитесь с поддержкой."
|
||||
)
|
||||
|
||||
# Проверяем количество существующих тенантов с таким email
|
||||
# Если больше 0 - это нормально для админов, но для обычных пользователей показываем предупреждение
|
||||
existing_tenants_count = Client.objects.filter(owner_email=email).count()
|
||||
|
||||
if existing_tenants_count > 0:
|
||||
# Добавляем предупреждение, но не блокируем (на случай если это супер-админ)
|
||||
# В реальности здесь можно добавить более сложную логику
|
||||
pass
|
||||
|
||||
return email
|
||||
0
myproject/tenants/management/__init__.py
Normal file
0
myproject/tenants/management/__init__.py
Normal file
0
myproject/tenants/management/commands/__init__.py
Normal file
0
myproject/tenants/management/commands/__init__.py
Normal file
145
myproject/tenants/management/commands/activate_registration.py
Normal file
145
myproject/tenants/management/commands/activate_registration.py
Normal file
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Management команда для ручной активации заявки на регистрацию тенанта.
|
||||
|
||||
Использование:
|
||||
python manage.py activate_registration <schema_name>
|
||||
|
||||
Пример:
|
||||
python manage.py activate_registration mixflowers
|
||||
"""
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from tenants.models import TenantRegistration, Client, Domain, Subscription
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Активировать заявку на регистрацию тенанта вручную'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'schema_name',
|
||||
type=str,
|
||||
help='Schema name заявки для активации'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Принудительная активация (пересоздать тенант если уже существует)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
schema_name = options['schema_name']
|
||||
force = options.get('force', False)
|
||||
|
||||
try:
|
||||
# Ищем заявку
|
||||
registration = TenantRegistration.objects.get(schema_name=schema_name)
|
||||
except TenantRegistration.DoesNotExist:
|
||||
raise CommandError(f'Заявка с schema_name "{schema_name}" не найдена')
|
||||
|
||||
self.stdout.write(f'Найдена заявка: {registration.shop_name} ({registration.schema_name})')
|
||||
self.stdout.write(f'Статус: {registration.get_status_display()}')
|
||||
self.stdout.write(f'Email: {registration.owner_email}')
|
||||
self.stdout.write('')
|
||||
|
||||
# Проверяем статус
|
||||
if registration.status == TenantRegistration.STATUS_APPROVED and not force:
|
||||
self.stdout.write(self.style.WARNING('Эта заявка уже была одобрена!'))
|
||||
if registration.tenant:
|
||||
self.stdout.write(f'Связанный тенант: {registration.tenant.name} (ID: {registration.tenant.id})')
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING('Но тенант не был создан. Используйте --force для пересоздания'))
|
||||
if not self._confirm('Продолжить с --force?'):
|
||||
return
|
||||
force = True
|
||||
|
||||
# Проверяем существование тенанта
|
||||
existing_client = Client.objects.filter(schema_name=schema_name).first()
|
||||
if existing_client and not force:
|
||||
raise CommandError(
|
||||
f'Тенант с schema_name "{schema_name}" уже существует (ID: {existing_client.id}). '
|
||||
'Используйте --force для пересоздания (ОПАСНО: удалит все данные!)'
|
||||
)
|
||||
|
||||
# Активируем
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.WARNING('НАЧИНАЮ АКТИВАЦИЮ...'))
|
||||
self.stdout.write('')
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Если force - удаляем старый тенант
|
||||
if force and existing_client:
|
||||
self.stdout.write(self.style.WARNING(f'Удаление существующего тенанта {existing_client.id}...'))
|
||||
# Удаляем подписку
|
||||
try:
|
||||
existing_client.subscription.delete()
|
||||
except:
|
||||
pass
|
||||
# Удаляем домены
|
||||
Domain.objects.filter(tenant=existing_client).delete()
|
||||
# Удаляем тенант (это также удалит схему из PostgreSQL)
|
||||
existing_client.delete()
|
||||
self.stdout.write(self.style.SUCCESS('Старый тенант удален'))
|
||||
|
||||
# Создаем тенант
|
||||
self.stdout.write(f'Создание тенанта: {registration.schema_name}')
|
||||
client = Client.objects.create(
|
||||
schema_name=registration.schema_name,
|
||||
name=registration.shop_name,
|
||||
owner_email=registration.owner_email,
|
||||
owner_name=registration.owner_name,
|
||||
phone=registration.phone,
|
||||
is_active=True
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Тенант создан (ID: {client.id})'))
|
||||
|
||||
# Создаем домен
|
||||
domain_name = f"{registration.schema_name}.localhost"
|
||||
self.stdout.write(f'Создание домена: {domain_name}')
|
||||
domain = Domain.objects.create(
|
||||
domain=domain_name,
|
||||
tenant=client,
|
||||
is_primary=True
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Домен создан (ID: {domain.id})'))
|
||||
|
||||
# Создаем триальную подписку
|
||||
self.stdout.write('Создание триальной подписки на 90 дней')
|
||||
subscription = Subscription.create_trial(client)
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'✓ Подписка создана (ID: {subscription.id}), '
|
||||
f'истекает: {subscription.expires_at.strftime("%Y-%m-%d")}'
|
||||
))
|
||||
|
||||
# Обновляем заявку
|
||||
registration.status = TenantRegistration.STATUS_APPROVED
|
||||
registration.processed_at = timezone.now()
|
||||
registration.processed_by = None # Активировано через команду
|
||||
registration.tenant = client
|
||||
registration.save()
|
||||
self.stdout.write(self.style.SUCCESS('✓ Заявка обновлена'))
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write(self.style.SUCCESS('АКТИВАЦИЯ ЗАВЕРШЕНА УСПЕШНО!'))
|
||||
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||
self.stdout.write('')
|
||||
self.stdout.write(f'Магазин: {client.name}')
|
||||
self.stdout.write(f'Schema: {client.schema_name}')
|
||||
self.stdout.write(f'Домен: http://{domain_name}:8000/')
|
||||
self.stdout.write(f'Подписка до: {subscription.expires_at.strftime("%Y-%m-%d")} ({subscription.days_left()} дней)')
|
||||
self.stdout.write('')
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'ОШИБКА: {str(e)}'))
|
||||
import traceback
|
||||
self.stdout.write(traceback.format_exc())
|
||||
raise CommandError('Активация не удалась')
|
||||
|
||||
def _confirm(self, question):
|
||||
"""Запрос подтверждения у пользователя"""
|
||||
answer = input(f'{question} (yes/no): ')
|
||||
return answer.lower() in ['yes', 'y', 'да']
|
||||
158
myproject/tenants/management/commands/create_tenant.py
Normal file
158
myproject/tenants/management/commands/create_tenant.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Management команда для создания нового тенанта (магазина).
|
||||
|
||||
Использование:
|
||||
python manage.py create_tenant
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from tenants.models import Client, Domain
|
||||
import re
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Создать нового тенанта (магазин) с собственной схемой БД'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS('\n=== Создание нового магазина (тенанта) ===\n'))
|
||||
|
||||
# Получаем данные от пользователя
|
||||
name = self.get_shop_name()
|
||||
schema_name = self.get_schema_name()
|
||||
domain_name = self.get_domain_name(schema_name)
|
||||
owner_name = self.get_owner_name()
|
||||
owner_email = self.get_owner_email()
|
||||
phone = input('Телефон владельца (опционально): ').strip()
|
||||
|
||||
# Подтверждение
|
||||
self.stdout.write('\n' + '='*60)
|
||||
self.stdout.write(self.style.WARNING('Проверьте введенные данные:'))
|
||||
self.stdout.write(f'Название магазина: {name}')
|
||||
self.stdout.write(f'Схема БД: {schema_name}')
|
||||
self.stdout.write(f'Домен: {domain_name}')
|
||||
self.stdout.write(f'Владелец: {owner_name} ({owner_email})')
|
||||
if phone:
|
||||
self.stdout.write(f'Телефон: {phone}')
|
||||
self.stdout.write('='*60 + '\n')
|
||||
|
||||
confirm = input('Создать магазин? (yes/no): ').strip().lower()
|
||||
if confirm not in ['yes', 'y', 'да']:
|
||||
self.stdout.write(self.style.ERROR('Отменено'))
|
||||
return
|
||||
|
||||
# Создаем тенанта
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# Создаем тенанта
|
||||
self.stdout.write('Создание тенанта...')
|
||||
tenant = Client.objects.create(
|
||||
schema_name=schema_name,
|
||||
name=name,
|
||||
owner_name=owner_name,
|
||||
owner_email=owner_email,
|
||||
phone=phone if phone else None,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Тенант создан: {tenant}'))
|
||||
|
||||
# Создаем домен
|
||||
self.stdout.write(f'Создание домена {domain_name}...')
|
||||
domain = Domain.objects.create(
|
||||
domain=domain_name,
|
||||
tenant=tenant,
|
||||
is_primary=True
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'✓ Домен создан: {domain}'))
|
||||
|
||||
self.stdout.write('\n' + '='*60)
|
||||
self.stdout.write(self.style.SUCCESS('✓ Магазин успешно создан!'))
|
||||
self.stdout.write('='*60 + '\n')
|
||||
|
||||
self.stdout.write(self.style.WARNING('Следующие шаги:'))
|
||||
self.stdout.write(f'1. Добавьте в hosts файл: 127.0.0.1 {domain_name}')
|
||||
self.stdout.write(f'2. Откройте в браузере: http://{domain_name}:8000/admin/')
|
||||
self.stdout.write(f'3. Схема БД "{schema_name}" создана автоматически')
|
||||
self.stdout.write(f'4. Все таблицы тенанта созданы в схеме "{schema_name}"')
|
||||
self.stdout.write('')
|
||||
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'\n✗ Ошибка при создании тенанта: {e}'))
|
||||
raise
|
||||
|
||||
def get_shop_name(self):
|
||||
"""Получить название магазина"""
|
||||
while True:
|
||||
name = input('Название магазина: ').strip()
|
||||
if name:
|
||||
return name
|
||||
self.stdout.write(self.style.ERROR('Название не может быть пустым'))
|
||||
|
||||
def get_schema_name(self):
|
||||
"""Получить имя схемы БД"""
|
||||
while True:
|
||||
schema = input('Имя схемы БД (латиница, цифры, подчеркивания): ').strip().lower()
|
||||
|
||||
# Валидация
|
||||
if not schema:
|
||||
self.stdout.write(self.style.ERROR('Имя схемы не может быть пустым'))
|
||||
continue
|
||||
|
||||
if not re.match(r'^[a-z0-9_]+$', schema):
|
||||
self.stdout.write(self.style.ERROR('Только латинские буквы, цифры и подчеркивания'))
|
||||
continue
|
||||
|
||||
if schema in ['public', 'postgres', 'information_schema', 'pg_catalog']:
|
||||
self.stdout.write(self.style.ERROR('Это зарезервированное имя схемы'))
|
||||
continue
|
||||
|
||||
# Проверка существования
|
||||
if Client.objects.filter(schema_name=schema).exists():
|
||||
self.stdout.write(self.style.ERROR(f'Схема "{schema}" уже существует'))
|
||||
continue
|
||||
|
||||
return schema
|
||||
|
||||
def get_domain_name(self, default_subdomain):
|
||||
"""Получить доменное имя"""
|
||||
while True:
|
||||
default_domain = f'{default_subdomain}.localhost'
|
||||
domain = input(f'Доменное имя [{default_domain}]: ').strip().lower()
|
||||
|
||||
if not domain:
|
||||
domain = default_domain
|
||||
|
||||
# Валидация
|
||||
if not re.match(r'^[a-z0-9.-]+$', domain):
|
||||
self.stdout.write(self.style.ERROR('Неверный формат домена'))
|
||||
continue
|
||||
|
||||
# Проверка существования
|
||||
if Domain.objects.filter(domain=domain).exists():
|
||||
self.stdout.write(self.style.ERROR(f'Домен "{domain}" уже используется'))
|
||||
continue
|
||||
|
||||
return domain
|
||||
|
||||
def get_owner_name(self):
|
||||
"""Получить имя владельца"""
|
||||
while True:
|
||||
name = input('Имя владельца: ').strip()
|
||||
if name:
|
||||
return name
|
||||
self.stdout.write(self.style.ERROR('Имя не может быть пустым'))
|
||||
|
||||
def get_owner_email(self):
|
||||
"""Получить email владельца"""
|
||||
while True:
|
||||
email = input('Email владельца: ').strip().lower()
|
||||
|
||||
# Простая валидация email
|
||||
if not email:
|
||||
self.stdout.write(self.style.ERROR('Email не может быть пустым'))
|
||||
continue
|
||||
|
||||
if '@' not in email or '.' not in email:
|
||||
self.stdout.write(self.style.ERROR('Неверный формат email'))
|
||||
continue
|
||||
|
||||
return email
|
||||
48
myproject/tenants/migrations/0001_initial.py
Normal file
48
myproject/tenants/migrations/0001_initial.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_tenants.postgresql_backend.base
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Client',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название магазина')),
|
||||
('owner_email', models.EmailField(help_text='Контактный email владельца магазина', max_length=254, verbose_name='Email владельца')),
|
||||
('owner_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Имя владельца')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Активна ли учетная запись магазина', verbose_name='Активен')),
|
||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон')),
|
||||
('notes', models.TextField(blank=True, help_text='Внутренние заметки администратора', null=True, verbose_name='Заметки')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тенант (Магазин)',
|
||||
'verbose_name_plural': 'Тенанты (Магазины)',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Domain',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('domain', models.CharField(db_index=True, max_length=253, unique=True)),
|
||||
('is_primary', models.BooleanField(db_index=True, default=True)),
|
||||
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='tenants.client')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Домен',
|
||||
'verbose_name_plural': 'Домены',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 5.1.4 on 2025-10-27 09:45
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import phonenumber_field.modelfields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenants', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='client',
|
||||
name='is_active',
|
||||
field=models.BooleanField(default=True, help_text='Активна ли учетная запись магазина (ручная блокировка админом)', verbose_name='Активен'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='client',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, max_length=200, verbose_name='Название магазина'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='client',
|
||||
name='owner_email',
|
||||
field=models.EmailField(help_text='Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)', max_length=254, verbose_name='Email владельца'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='client',
|
||||
name='phone',
|
||||
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='Телефон'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Subscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('plan', models.CharField(choices=[('trial', 'Триальный (90 дней)'), ('monthly', 'Месячный'), ('quarterly', 'Квартальный (3 месяца)'), ('yearly', 'Годовой')], default='trial', max_length=20, verbose_name='План подписки')),
|
||||
('started_at', models.DateTimeField(verbose_name='Дата начала')),
|
||||
('expires_at', models.DateTimeField(verbose_name='Дата окончания')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Активна ли подписка (может быть отключена вручную)', verbose_name='Активна')),
|
||||
('auto_renew', models.BooleanField(default=False, help_text='Автоматически продлевать подписку', verbose_name='Автопродление')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('client', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='tenants.client', verbose_name='Тенант')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Подписка',
|
||||
'verbose_name_plural': 'Подписки',
|
||||
'ordering': ['-expires_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TenantRegistration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('shop_name', models.CharField(max_length=200, verbose_name='Название магазина')),
|
||||
('schema_name', models.CharField(help_text='Например: myshop (будет доступен как myshop.inventory.by)', max_length=63, unique=True, validators=[django.core.validators.RegexValidator(message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.', regex='^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$')], verbose_name='Желаемый поддомен')),
|
||||
('owner_email', models.EmailField(max_length=254, verbose_name='Email владельца')),
|
||||
('owner_name', models.CharField(max_length=200, verbose_name='Имя владельца')),
|
||||
('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='Телефон')),
|
||||
('status', models.CharField(choices=[('pending', 'Ожидает проверки'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')),
|
||||
('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата обработки')),
|
||||
('rejection_reason', models.TextField(blank=True, verbose_name='Причина отклонения')),
|
||||
('processed_by', models.ForeignKey(blank=True, help_text='Администратор, который обработал заявку', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Обработал')),
|
||||
('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Заявка на регистрацию',
|
||||
'verbose_name_plural': 'Заявки на регистрацию',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
myproject/tenants/migrations/__init__.py
Normal file
0
myproject/tenants/migrations/__init__.py
Normal file
284
myproject/tenants/models.py
Normal file
284
myproject/tenants/models.py
Normal file
@@ -0,0 +1,284 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.utils import timezone
|
||||
from django_tenants.models import TenantMixin, DomainMixin
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
# Зарезервированные имена схем, которые нельзя использовать
|
||||
RESERVED_SCHEMA_NAMES = [
|
||||
'public', 'admin', 'api', 'www', 'mail', 'ftp', 'smtp',
|
||||
'static', 'media', 'assets', 'cdn', 'app', 'web',
|
||||
'billing', 'register', 'login', 'logout', 'dashboard',
|
||||
'test', 'dev', 'staging', 'production', 'demo'
|
||||
]
|
||||
|
||||
|
||||
class Client(TenantMixin):
|
||||
"""
|
||||
Модель тенанта (владельца магазина).
|
||||
Каждый тенант = отдельная схема в PostgreSQL с изолированными данными.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=200,
|
||||
db_index=True,
|
||||
verbose_name="Название магазина"
|
||||
)
|
||||
|
||||
owner_email = models.EmailField(
|
||||
verbose_name="Email владельца",
|
||||
help_text="Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)"
|
||||
)
|
||||
|
||||
owner_name = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Имя владельца"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания"
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Активен",
|
||||
help_text="Активна ли учетная запись магазина (ручная блокировка админом)"
|
||||
)
|
||||
|
||||
# Дополнительные поля для будущего расширения
|
||||
phone = PhoneNumberField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Телефон"
|
||||
)
|
||||
|
||||
notes = models.TextField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Заметки",
|
||||
help_text="Внутренние заметки администратора"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Тенант (Магазин)"
|
||||
verbose_name_plural = "Тенанты (Магазины)"
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.schema_name})"
|
||||
|
||||
# auto_create_schema наследуется от TenantMixin
|
||||
# auto_drop_schema наследуется от TenantMixin
|
||||
auto_create_schema = True # Автоматически создавать схему при создании тенанта
|
||||
|
||||
|
||||
class Domain(DomainMixin):
|
||||
"""
|
||||
Модель домена для тенанта.
|
||||
Связывает поддомен (например shop1.inventory.by) с тенантом.
|
||||
"""
|
||||
class Meta:
|
||||
verbose_name = "Домен"
|
||||
verbose_name_plural = "Домены"
|
||||
|
||||
def __str__(self):
|
||||
return self.domain
|
||||
|
||||
|
||||
class TenantRegistration(models.Model):
|
||||
"""
|
||||
Модель заявки на регистрацию нового тенанта.
|
||||
Заявки сначала создаются со статусом 'pending', затем админ их активирует.
|
||||
"""
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_APPROVED = 'approved'
|
||||
STATUS_REJECTED = 'rejected'
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_PENDING, 'Ожидает проверки'),
|
||||
(STATUS_APPROVED, 'Одобрено'),
|
||||
(STATUS_REJECTED, 'Отклонено'),
|
||||
]
|
||||
|
||||
# Данные от пользователя
|
||||
shop_name = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name="Название магазина"
|
||||
)
|
||||
|
||||
schema_name = models.CharField(
|
||||
max_length=63,
|
||||
unique=True,
|
||||
validators=[
|
||||
RegexValidator(
|
||||
regex=r'^[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?$',
|
||||
message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. '
|
||||
'Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.'
|
||||
)
|
||||
],
|
||||
verbose_name="Желаемый поддомен",
|
||||
help_text="Например: myshop (будет доступен как myshop.inventory.by)"
|
||||
)
|
||||
|
||||
owner_email = models.EmailField(
|
||||
verbose_name="Email владельца"
|
||||
)
|
||||
|
||||
owner_name = models.CharField(
|
||||
max_length=200,
|
||||
verbose_name="Имя владельца"
|
||||
)
|
||||
|
||||
phone = PhoneNumberField(
|
||||
verbose_name="Телефон"
|
||||
)
|
||||
|
||||
# Служебные поля
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default=STATUS_PENDING,
|
||||
db_index=True,
|
||||
verbose_name="Статус"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата подачи заявки"
|
||||
)
|
||||
|
||||
processed_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата обработки"
|
||||
)
|
||||
|
||||
processed_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name="Обработал",
|
||||
help_text="Администратор, который обработал заявку"
|
||||
)
|
||||
|
||||
rejection_reason = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Причина отклонения"
|
||||
)
|
||||
|
||||
# Ссылка на созданный тенант (заполняется после активации)
|
||||
tenant = models.OneToOneField(
|
||||
Client,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name="Созданный тенант"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Заявка на регистрацию"
|
||||
verbose_name_plural = "Заявки на регистрацию"
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.shop_name} ({self.schema_name}) - {self.get_status_display()}"
|
||||
|
||||
|
||||
class Subscription(models.Model):
|
||||
"""
|
||||
Модель подписки тенанта.
|
||||
Определяет план подписки и срок действия.
|
||||
"""
|
||||
PLAN_TRIAL = 'trial'
|
||||
PLAN_MONTHLY = 'monthly'
|
||||
PLAN_QUARTERLY = 'quarterly'
|
||||
PLAN_YEARLY = 'yearly'
|
||||
|
||||
PLAN_CHOICES = [
|
||||
(PLAN_TRIAL, 'Триальный (90 дней)'),
|
||||
(PLAN_MONTHLY, 'Месячный'),
|
||||
(PLAN_QUARTERLY, 'Квартальный (3 месяца)'),
|
||||
(PLAN_YEARLY, 'Годовой'),
|
||||
]
|
||||
|
||||
client = models.OneToOneField(
|
||||
Client,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='subscription',
|
||||
verbose_name="Тенант"
|
||||
)
|
||||
|
||||
plan = models.CharField(
|
||||
max_length=20,
|
||||
choices=PLAN_CHOICES,
|
||||
default=PLAN_TRIAL,
|
||||
verbose_name="План подписки"
|
||||
)
|
||||
|
||||
started_at = models.DateTimeField(
|
||||
verbose_name="Дата начала"
|
||||
)
|
||||
|
||||
expires_at = models.DateTimeField(
|
||||
verbose_name="Дата окончания"
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Активна",
|
||||
help_text="Активна ли подписка (может быть отключена вручную)"
|
||||
)
|
||||
|
||||
auto_renew = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Автопродление",
|
||||
help_text="Автоматически продлевать подписку"
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания"
|
||||
)
|
||||
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name="Дата обновления"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Подписка"
|
||||
verbose_name_plural = "Подписки"
|
||||
ordering = ['-expires_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.client.name} - {self.get_plan_display()} (до {self.expires_at.date()})"
|
||||
|
||||
def is_expired(self):
|
||||
"""Проверка истечения подписки"""
|
||||
return timezone.now() > self.expires_at
|
||||
|
||||
def days_left(self):
|
||||
"""Количество дней до окончания подписки"""
|
||||
if self.is_expired():
|
||||
return 0
|
||||
delta = self.expires_at - timezone.now()
|
||||
return delta.days
|
||||
|
||||
@staticmethod
|
||||
def create_trial(client):
|
||||
"""Создать триальную подписку на 90 дней"""
|
||||
now = timezone.now()
|
||||
return Subscription.objects.create(
|
||||
client=client,
|
||||
plan=Subscription.PLAN_TRIAL,
|
||||
started_at=now,
|
||||
expires_at=now + timedelta(days=90),
|
||||
is_active=True
|
||||
)
|
||||
70
myproject/tenants/templates/tenants/base.html
Normal file
70
myproject/tenants/templates/tenants/base.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Регистрация магазина{% endblock %} - Inventory System</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.card {
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
border: none;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 15px 15px 0 0 !important;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.form-control:focus {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
.alert {
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 col-lg-6">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap 5 JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
133
myproject/tenants/templates/tenants/registration_form.html
Normal file
133
myproject/tenants/templates/tenants/registration_form.html
Normal file
@@ -0,0 +1,133 @@
|
||||
{% extends "tenants/base.html" %}
|
||||
|
||||
{% block title %}Регистрация нового магазина{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h3 class="mb-0">Регистрация нового магазина</h3>
|
||||
<p class="mb-0 mt-2">Заполните форму для создания вашего интернет-магазина</p>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<!-- Название магазина -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.shop_name.id_for_label }}" class="form-label">
|
||||
{{ form.shop_name.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.shop_name }}
|
||||
{% if form.shop_name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.shop_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.shop_name.help_text %}
|
||||
<div class="form-text">{{ form.shop_name.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Поддомен -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.schema_name.id_for_label }}" class="form-label">
|
||||
{{ form.schema_name.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="input-group">
|
||||
{{ form.schema_name }}
|
||||
<span class="input-group-text">.inventory.by</span>
|
||||
</div>
|
||||
{% if form.schema_name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.schema_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.schema_name.help_text %}
|
||||
<div class="form-text">{{ form.schema_name.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Имя владельца -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.owner_name.id_for_label }}" class="form-label">
|
||||
{{ form.owner_name.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.owner_name }}
|
||||
{% if form.owner_name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.owner_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.owner_email.id_for_label }}" class="form-label">
|
||||
{{ form.owner_email.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.owner_email }}
|
||||
{% if form.owner_email.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.owner_email.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.owner_email.help_text %}
|
||||
<div class="form-text">{{ form.owner_email.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Телефон -->
|
||||
<div class="mb-4">
|
||||
<label for="{{ form.phone.id_for_label }}" class="form-label">
|
||||
{{ form.phone.label }}
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.phone }}
|
||||
{% if form.phone.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{{ form.phone.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Кнопка отправки -->
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
Отправить заявку
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">
|
||||
После отправки заявки ваш магазин будет проверен администратором.<br>
|
||||
Уведомление придет на указанный email в течение 24 часов.
|
||||
</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="/admin/" class="text-white text-decoration-none">
|
||||
<small>Войти в панель администратора</small>
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Автоматическое преобразование поддомена в lowercase
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const schemaNameInput = document.getElementById('{{ form.schema_name.id_for_label }}');
|
||||
if (schemaNameInput) {
|
||||
schemaNameInput.addEventListener('input', function() {
|
||||
this.value = this.value.toLowerCase();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "tenants/base.html" %}
|
||||
|
||||
{% block title %}Заявка отправлена{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-header text-center">
|
||||
<h3 class="mb-0">Спасибо за регистрацию!</h3>
|
||||
</div>
|
||||
<div class="card-body p-5 text-center">
|
||||
<div class="mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" fill="currentColor" class="bi bi-check-circle text-success" viewBox="0 0 16 16">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
|
||||
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h4 class="mb-3">Ваша заявка успешно отправлена!</h4>
|
||||
|
||||
<p class="lead mb-4">
|
||||
Мы получили вашу заявку на создание магазина.<br>
|
||||
Наш администратор проверит данные и активирует ваш магазин.
|
||||
</p>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h5 class="alert-heading">Что дальше?</h5>
|
||||
<hr>
|
||||
<ul class="list-unstyled text-start mb-0">
|
||||
<li class="mb-2">✓ В течение 24 часов администратор проверит вашу заявку</li>
|
||||
<li class="mb-2">✓ После активации вы получите письмо на указанный email</li>
|
||||
<li class="mb-2">✓ В письме будет ссылка на ваш магазин и инструкции</li>
|
||||
<li>✓ Вам будет предоставлен триальный период на 90 дней</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'tenants:register' %}" class="btn btn-outline-primary">
|
||||
Подать еще одну заявку
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<small class="text-muted">
|
||||
Если у вас возникли вопросы, свяжитесь с нами:<br>
|
||||
<a href="mailto:support@inventory.by">support@inventory.by</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
10
myproject/tenants/urls.py
Normal file
10
myproject/tenants/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.urls import path
|
||||
from .views import TenantRegistrationView, RegistrationSuccessView
|
||||
|
||||
app_name = 'tenants'
|
||||
|
||||
urlpatterns = [
|
||||
path('register/', TenantRegistrationView.as_view(), name='register'),
|
||||
path('register/success/', RegistrationSuccessView.as_view(), name='registration_success'),
|
||||
]
|
||||
59
myproject/tenants/views.py
Normal file
59
myproject/tenants/views.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views.generic import CreateView, TemplateView
|
||||
from django.urls import reverse_lazy
|
||||
from django.contrib import messages
|
||||
from .forms import TenantRegistrationForm
|
||||
from .models import TenantRegistration
|
||||
|
||||
|
||||
class TenantRegistrationView(CreateView):
|
||||
"""
|
||||
Публичная форма регистрации нового тенанта
|
||||
"""
|
||||
model = TenantRegistration
|
||||
form_class = TenantRegistrationForm
|
||||
template_name = 'tenants/registration_form.html'
|
||||
success_url = reverse_lazy('tenants:registration_success')
|
||||
|
||||
def form_valid(self, form):
|
||||
"""
|
||||
Обработка успешной отправки формы
|
||||
"""
|
||||
response = super().form_valid(form)
|
||||
|
||||
# Добавляем сообщение об успехе
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Спасибо за регистрацию! Ваша заявка на магазин '{self.object.shop_name}' "
|
||||
f"отправлена на проверку. Мы свяжемся с вами по email {self.object.owner_email} "
|
||||
f"в ближайшее время."
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def form_invalid(self, form):
|
||||
"""
|
||||
Обработка ошибок валидации
|
||||
"""
|
||||
messages.error(
|
||||
self.request,
|
||||
"Пожалуйста, исправьте ошибки в форме."
|
||||
)
|
||||
return super().form_invalid(form)
|
||||
|
||||
|
||||
class RegistrationSuccessView(TemplateView):
|
||||
"""
|
||||
Страница благодарности после успешной регистрации
|
||||
"""
|
||||
template_name = 'tenants/registration_success.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Получаем последнюю заявку текущего пользователя (если есть)
|
||||
# Здесь можно добавить фильтрацию по сессии или email
|
||||
context['show_details'] = False
|
||||
|
||||
return context
|
||||
Reference in New Issue
Block a user