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.contrib.auth.validators
|
||||||
import django.utils.timezone
|
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 django.db.models.deletion
|
||||||
import phonenumber_field.modelfields
|
import phonenumber_field.modelfields
|
||||||
|
|||||||
@@ -104,51 +104,51 @@ class Customer(models.Model):
|
|||||||
return discounts.get(self.loyalty_tier, 0)
|
return discounts.get(self.loyalty_tier, 0)
|
||||||
|
|
||||||
def validate_unique(self, exclude=None):
|
def validate_unique(self, exclude=None):
|
||||||
"""Override to handle unique phone validation properly during updates"""
|
"""Переопределение для корректной проверки уникальности телефона при обновлениях"""
|
||||||
# Run the phone number normalization again before unique validation
|
# Снова нормализуем номер телефона перед проверкой уникальности
|
||||||
if self.phone:
|
if self.phone:
|
||||||
# Check for existing customers with the same phone (excluding current instance if updating)
|
# Проверяем существующих клиентов с таким же телефоном (исключая текущий экземпляр при обновлении)
|
||||||
existing = Customer.objects.filter(phone=self.phone)
|
existing = Customer.objects.filter(phone=self.phone)
|
||||||
if self.pk:
|
if self.pk:
|
||||||
existing = existing.exclude(pk=self.pk)
|
existing = existing.exclude(pk=self.pk)
|
||||||
if existing.exists():
|
if existing.exists():
|
||||||
raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'})
|
raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'})
|
||||||
|
|
||||||
# Call parent validate_unique to handle other validation
|
# Вызываем родительский validate_unique для обработки других проверок
|
||||||
super().validate_unique(exclude=exclude)
|
super().validate_unique(exclude=exclude)
|
||||||
|
|
||||||
def clean_phone(self):
|
def clean_phone(self):
|
||||||
"""Custom cleaning for phone field to normalize before validation."""
|
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
|
||||||
if self.phone:
|
if self.phone:
|
||||||
try:
|
try:
|
||||||
# Parse the phone number to check if it's valid and normalize it
|
# Парсим номер телефона, чтобы проверить его валидность и нормализовать
|
||||||
raw_phone = str(self.phone)
|
raw_phone = str(self.phone)
|
||||||
|
|
||||||
# If it starts with '8' and has 11 digits, it might be Russian domestic format
|
# Если начинается с '8' и имеет 11 цифр, это может быть российский формат
|
||||||
if raw_phone.startswith('8') and len(raw_phone) == 11:
|
if raw_phone.startswith('8') and len(raw_phone) == 11:
|
||||||
# Try BY first for Belarusian numbers
|
# Сначала пробуем BY для белорусских номеров
|
||||||
parsed = phonenumbers.parse(raw_phone, "BY")
|
parsed = phonenumbers.parse(raw_phone, "BY")
|
||||||
if phonenumbers.is_valid_number(parsed):
|
if phonenumbers.is_valid_number(parsed):
|
||||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||||
|
|
||||||
# If BY doesn't work, try RU as fallback
|
# Если BY не работает, пробуем RU как запасной вариант
|
||||||
parsed = phonenumbers.parse(raw_phone, "RU")
|
parsed = phonenumbers.parse(raw_phone, "RU")
|
||||||
if phonenumbers.is_valid_number(parsed):
|
if phonenumbers.is_valid_number(parsed):
|
||||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||||
|
|
||||||
# Try to parse without country code (might already be in international format)
|
# Пробуем парсить без кода страны (может быть уже в международном формате)
|
||||||
parsed = phonenumbers.parse(raw_phone, None)
|
parsed = phonenumbers.parse(raw_phone, None)
|
||||||
if phonenumbers.is_valid_number(parsed):
|
if phonenumbers.is_valid_number(parsed):
|
||||||
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||||
|
|
||||||
except phonenumbers.NumberParseException:
|
except phonenumbers.NumberParseException:
|
||||||
# If parsing fails, return as is and let field validation handle it
|
# Если парсинг не удался, возвращаем как есть и позволяем валидации поля обработать это
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return self.phone
|
return self.phone
|
||||||
|
|
||||||
def clean_fields(self, exclude=None):
|
def clean_fields(self, exclude=None):
|
||||||
# Normalize phone before field validation runs
|
# Нормализуем телефон перед запуском проверки полей
|
||||||
if not exclude:
|
if not exclude:
|
||||||
exclude = []
|
exclude = []
|
||||||
if 'phone' not in exclude and self.phone:
|
if 'phone' not in exclude and self.phone:
|
||||||
@@ -158,33 +158,33 @@ class Customer(models.Model):
|
|||||||
except Exception:
|
except Exception:
|
||||||
normalized = None
|
normalized = None
|
||||||
if normalized:
|
if normalized:
|
||||||
# assign normalized value (E.164) so PhoneNumberField sees корректный формат
|
# присваиваем нормализованное значение (E.164), чтобы PhoneNumberField видел корректный формат
|
||||||
self.phone = normalized
|
self.phone = normalized
|
||||||
|
|
||||||
super().clean_fields(exclude=exclude)
|
super().clean_fields(exclude=exclude)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Additional validation if needed."""
|
"""Дополнительная валидация при необходимости."""
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Ensure phone is normalized even if save is called directly (not through form)
|
# Обеспечиваем нормализацию телефона, даже если save вызывается напрямую (не через форму)
|
||||||
# At this point, if it came through form validation, phone should already be normalized
|
# На данный момент, если вызов прошел через валидацию формы, телефон уже должен быть нормализован
|
||||||
# But if save is called directly on the model, we still need to normalize
|
# Но если save вызывается непосредственно в модели, нам все равно нужно нормализовать
|
||||||
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11:
|
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11:
|
||||||
# This is likely a domestic format number that needs normalization
|
# Это, вероятно, номер в формате страны, который требует нормализации
|
||||||
try:
|
try:
|
||||||
# Try BY first for Belarusian numbers
|
# Сначала пробуем BY для белорусских номеров
|
||||||
parsed = phonenumbers.parse(str(self.phone), "BY")
|
parsed = phonenumbers.parse(str(self.phone), "BY")
|
||||||
if phonenumbers.is_valid_number(parsed):
|
if phonenumbers.is_valid_number(parsed):
|
||||||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||||
else:
|
else:
|
||||||
# If BY doesn't work, try RU as fallback
|
# Если BY не работает, пробуем RU как запасной вариант
|
||||||
parsed = phonenumbers.parse(str(self.phone), "RU")
|
parsed = phonenumbers.parse(str(self.phone), "RU")
|
||||||
if phonenumbers.is_valid_number(parsed):
|
if phonenumbers.is_valid_number(parsed):
|
||||||
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
||||||
except phonenumbers.NumberParseException:
|
except phonenumbers.NumberParseException:
|
||||||
# If parsing fails, don't change it and let the field validation handle it
|
# Если парсинг не удался, не меняем и позволяем валидации поля обработать это
|
||||||
pass
|
pass
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
@@ -269,7 +269,7 @@ class Address(models.Model):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.is_default:
|
if self.is_default:
|
||||||
# If this address is being set as default, unset the default flag on other addresses for this customer
|
# Если этот адрес устанавливается как адрес по умолчанию, снимаем флаг по умолчанию с других адресов этого клиента
|
||||||
Address.objects.filter(customer=self.customer, is_default=True).update(is_default=False)
|
Address.objects.filter(customer=self.customer, is_default=True).update(is_default=False)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-25 13:44
|
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|||||||
@@ -1,34 +1,25 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
URL configuration for myproject project.
|
URL configuration for TENANT schemas (shop1.inventory.by, shop2.inventory.by, etc.).
|
||||||
|
|
||||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
This is used for individual shop subdomains where shop owners manage their business.
|
||||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
|
||||||
Examples:
|
|
||||||
Function views
|
|
||||||
1. Add an import: from my_app import views
|
|
||||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
||||||
Class-based views
|
|
||||||
1. Add an import: from other_app.views import Home
|
|
||||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
||||||
Including another URLconf
|
|
||||||
1. Import the include() function: from django.urls import include, path
|
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from . import views
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('_nested_admin/', include('nested_admin.urls')), # Для nested admin
|
path('_nested_admin/', include('nested_admin.urls')), # Для nested admin
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls), # Админка для владельца магазина (доступна на поддомене)
|
||||||
path('', views.index, name='index'), # Main page
|
# TODO: Add web interface for shop owners
|
||||||
path('accounts/', include('accounts.urls')),
|
# path('', views.dashboard, name='dashboard'),
|
||||||
path('products/', include('products.urls')),
|
# path('products/', include('products.urls')),
|
||||||
path('customers/', include('customers.urls')),
|
# path('orders/', include('orders.urls')),
|
||||||
|
# path('customers/', include('customers.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Serve media files during development
|
# Serve media files during development
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
|||||||
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 django.contrib import admin
|
||||||
from .models import Customer, Order, OrderItem
|
from .models import Order, OrderItem
|
||||||
|
|
||||||
|
|
||||||
class CustomerAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ('first_name', 'last_name', 'email', 'phone', 'created_at')
|
|
||||||
list_filter = ('created_at', 'updated_at')
|
|
||||||
search_fields = ('first_name', 'last_name', 'email')
|
|
||||||
date_hierarchy = 'created_at'
|
|
||||||
|
|
||||||
|
|
||||||
class OrderItemInline(admin.TabularInline):
|
class OrderItemInline(admin.TabularInline):
|
||||||
|
"""
|
||||||
|
Inline для управления позициями заказа прямо в форме заказа.
|
||||||
|
"""
|
||||||
model = OrderItem
|
model = OrderItem
|
||||||
extra = 1
|
extra = 1
|
||||||
readonly_fields = ('snapshot_name', 'snapshot_sku', 'sale_price', 'cost_price')
|
fields = ['product', 'product_kit', 'quantity', 'price']
|
||||||
|
readonly_fields = []
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
"""Делаем цену readonly для существующих позиций"""
|
||||||
|
if obj and obj.pk:
|
||||||
|
return ['price']
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Order)
|
||||||
class OrderAdmin(admin.ModelAdmin):
|
class OrderAdmin(admin.ModelAdmin):
|
||||||
list_display = ('id', 'customer', 'status', 'total_price', 'created_at', 'updated_at')
|
"""
|
||||||
list_filter = ('status', 'created_at', 'updated_at')
|
Админ-панель для управления заказами.
|
||||||
search_fields = ('customer__first_name', 'customer__last_name', 'customer__email', 'id')
|
"""
|
||||||
date_hierarchy = 'created_at'
|
list_display = [
|
||||||
|
'order_number',
|
||||||
|
'customer',
|
||||||
|
'delivery_type',
|
||||||
|
'delivery_date',
|
||||||
|
'status',
|
||||||
|
'total_amount',
|
||||||
|
'is_paid',
|
||||||
|
'created_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
list_filter = [
|
||||||
|
'status',
|
||||||
|
'delivery_type',
|
||||||
|
'is_paid',
|
||||||
|
'delivery_date',
|
||||||
|
'created_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
search_fields = [
|
||||||
|
'order_number',
|
||||||
|
'customer__name',
|
||||||
|
'customer__phone',
|
||||||
|
'customer__email',
|
||||||
|
'delivery_address__recipient_name',
|
||||||
|
'delivery_address__street',
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly_fields = [
|
||||||
|
'order_number',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'delivery_info',
|
||||||
|
'delivery_time_window',
|
||||||
|
]
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Основная информация', {
|
||||||
|
'fields': ('order_number', 'customer', 'status')
|
||||||
|
}),
|
||||||
|
('Доставка', {
|
||||||
|
'fields': (
|
||||||
|
'delivery_type',
|
||||||
|
'delivery_address',
|
||||||
|
'pickup_shop',
|
||||||
|
'delivery_date',
|
||||||
|
'delivery_time_start',
|
||||||
|
'delivery_time_end',
|
||||||
|
'delivery_cost',
|
||||||
|
'delivery_info',
|
||||||
|
'delivery_time_window',
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
('Оплата', {
|
||||||
|
'fields': ('payment_method', 'is_paid', 'total_amount')
|
||||||
|
}),
|
||||||
|
('Дополнительно', {
|
||||||
|
'fields': ('is_anonymous', 'special_instructions'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Системная информация', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
inlines = [OrderItemInline]
|
inlines = [OrderItemInline]
|
||||||
|
|
||||||
|
actions = [
|
||||||
|
'mark_as_confirmed',
|
||||||
|
'mark_as_in_assembly',
|
||||||
|
'mark_as_in_delivery',
|
||||||
|
'mark_as_delivered',
|
||||||
|
'mark_as_paid',
|
||||||
|
]
|
||||||
|
|
||||||
admin.site.register(Customer, CustomerAdmin)
|
def mark_as_confirmed(self, request, queryset):
|
||||||
admin.site.register(Order, OrderAdmin)
|
"""Отметить заказы как подтвержденные"""
|
||||||
admin.site.register(OrderItem)
|
updated = queryset.update(status='confirmed')
|
||||||
|
self.message_user(request, f'{updated} заказ(ов) отмечено как подтвержденные')
|
||||||
|
mark_as_confirmed.short_description = 'Отметить как подтвержденные'
|
||||||
|
|
||||||
|
def mark_as_in_assembly(self, request, queryset):
|
||||||
|
"""Отметить заказы как в сборке"""
|
||||||
|
updated = queryset.update(status='in_assembly')
|
||||||
|
self.message_user(request, f'{updated} заказ(ов) отмечено как в сборке')
|
||||||
|
mark_as_in_assembly.short_description = 'Отметить как в сборке'
|
||||||
|
|
||||||
|
def mark_as_in_delivery(self, request, queryset):
|
||||||
|
"""Отметить заказы как в доставке"""
|
||||||
|
updated = queryset.update(status='in_delivery')
|
||||||
|
self.message_user(request, f'{updated} заказ(ов) отмечено как в доставке')
|
||||||
|
mark_as_in_delivery.short_description = 'Отметить как в доставке'
|
||||||
|
|
||||||
|
def mark_as_delivered(self, request, queryset):
|
||||||
|
"""Отметить заказы как доставленные"""
|
||||||
|
updated = queryset.update(status='delivered')
|
||||||
|
self.message_user(request, f'{updated} заказ(ов) отмечено как доставленные')
|
||||||
|
mark_as_delivered.short_description = 'Отметить как доставленные'
|
||||||
|
|
||||||
|
def mark_as_paid(self, request, queryset):
|
||||||
|
"""Отметить заказы как оплаченные"""
|
||||||
|
updated = queryset.update(is_paid=True)
|
||||||
|
self.message_user(request, f'{updated} заказ(ов) отмечено как оплаченные')
|
||||||
|
mark_as_paid.short_description = 'Отметить как оплаченные'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(OrderItem)
|
||||||
|
class OrderItemAdmin(admin.ModelAdmin):
|
||||||
|
"""
|
||||||
|
Админ-панель для управления позициями заказов.
|
||||||
|
"""
|
||||||
|
list_display = [
|
||||||
|
'order',
|
||||||
|
'item_name',
|
||||||
|
'quantity',
|
||||||
|
'price',
|
||||||
|
'get_total_price',
|
||||||
|
]
|
||||||
|
|
||||||
|
list_filter = [
|
||||||
|
'order__status',
|
||||||
|
'order__created_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
search_fields = [
|
||||||
|
'order__order_number',
|
||||||
|
'product__name',
|
||||||
|
'product_kit__name',
|
||||||
|
]
|
||||||
|
|
||||||
|
readonly_fields = ['created_at', 'get_total_price']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Заказ', {
|
||||||
|
'fields': ('order',)
|
||||||
|
}),
|
||||||
|
('Товар/Комплект', {
|
||||||
|
'fields': ('product', 'product_kit')
|
||||||
|
}),
|
||||||
|
('Информация', {
|
||||||
|
'fields': ('quantity', 'price', 'get_total_price')
|
||||||
|
}),
|
||||||
|
('Системная информация', {
|
||||||
|
'fields': ('created_at',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
# Generated by Django 5.2.7 on 2025-10-25 13:44
|
# Generated by Django 5.1.4 on 2025-10-26 22:44
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -10,80 +9,90 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
('customers', '0001_initial'),
|
||||||
('products', '0001_initial'),
|
('products', '0001_initial'),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
('shops', '0001_initial'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
|
||||||
name='Customer',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('first_name', models.CharField(max_length=100, verbose_name='Имя')),
|
|
||||||
('last_name', models.CharField(max_length=100, verbose_name='Фамилия')),
|
|
||||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
|
|
||||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон')),
|
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата регистрации')),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
|
||||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'verbose_name': 'Покупатель',
|
|
||||||
'verbose_name_plural': 'Покупатели',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Order',
|
name='Order',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('status', models.CharField(choices=[('created', 'Создан'), ('confirmed', 'Подтвержден'), ('assembled', 'Собран'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='created', max_length=20, verbose_name='Статус')),
|
('order_number', models.CharField(editable=False, help_text='Уникальный номер заказа для отображения клиенту', max_length=50, unique=True, verbose_name='Номер заказа')),
|
||||||
('total_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Общая сумма')),
|
('delivery_type', models.CharField(choices=[('courier', 'Курьерская доставка'), ('pickup', 'Самовывоз')], default='courier', max_length=20, verbose_name='Тип доставки')),
|
||||||
|
('delivery_date', models.DateField(verbose_name='Дата доставки/самовывоза')),
|
||||||
|
('delivery_time_start', models.TimeField(help_text='Начало временного интервала', verbose_name='Время от')),
|
||||||
|
('delivery_time_end', models.TimeField(help_text='Конец временного интервала', verbose_name='Время до')),
|
||||||
|
('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
|
||||||
|
('status', models.CharField(choices=[('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, verbose_name='Статус заказа')),
|
||||||
|
('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')),
|
||||||
|
('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')),
|
||||||
|
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')),
|
||||||
|
('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')),
|
||||||
|
('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, verbose_name='Особые пожелания')),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='orders.customer', verbose_name='Клиент')),
|
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент')),
|
||||||
|
('delivery_address', models.ForeignKey(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.address', verbose_name='Адрес доставки')),
|
||||||
|
('pickup_shop', models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='shops.shop', verbose_name='Точка самовывоза')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Заказ',
|
'verbose_name': 'Заказ',
|
||||||
'verbose_name_plural': 'Заказы',
|
'verbose_name_plural': 'Заказы',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='OrderItem',
|
name='OrderItem',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('quantity', models.DecimalField(decimal_places=3, default=1, max_digits=10, verbose_name='Количество')),
|
('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
|
||||||
('snapshot_name', models.CharField(max_length=200, verbose_name='Название (на момент заказа)')),
|
('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
|
||||||
('snapshot_sku', models.CharField(blank=True, max_length=100, null=True, verbose_name='Артикул (на момент заказа)')),
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
|
||||||
('sale_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Цена продажи')),
|
|
||||||
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Себестоимость')),
|
|
||||||
('composition_snapshot', models.JSONField(blank=True, null=True, verbose_name='Состав комплекта (снапшот)')),
|
|
||||||
('kit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order_items', to='products.productkit', verbose_name='Комплект')),
|
|
||||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ')),
|
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ')),
|
||||||
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order_items', to='products.product', verbose_name='Товар')),
|
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.product', verbose_name='Товар')),
|
||||||
|
('product_kit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.productkit', verbose_name='Комплект товаров')),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
'verbose_name': 'Позиция заказа',
|
'verbose_name': 'Позиция заказа',
|
||||||
'verbose_name_plural': 'Позиции заказов',
|
'verbose_name_plural': 'Позиции заказа',
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='customer',
|
|
||||||
index=models.Index(fields=['email'], name='orders_cust_email_e97b09_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name='order',
|
|
||||||
index=models.Index(fields=['status'], name='orders_orde_status_c6dd84_idx'),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='order',
|
model_name='order',
|
||||||
index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'),
|
index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'),
|
||||||
),
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='order',
|
||||||
|
index=models.Index(fields=['status'], name='orders_orde_status_c6dd84_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='order',
|
||||||
|
index=models.Index(fields=['delivery_date'], name='orders_orde_deliver_e4274f_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='order',
|
||||||
|
index=models.Index(fields=['delivery_type'], name='orders_orde_deliver_f68568_idx'),
|
||||||
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='order',
|
model_name='order',
|
||||||
index=models.Index(fields=['created_at'], name='orders_orde_created_0e92de_idx'),
|
index=models.Index(fields=['created_at'], name='orders_orde_created_0e92de_idx'),
|
||||||
),
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='order',
|
||||||
|
index=models.Index(fields=['order_number'], name='orders_orde_order_n_f3ada5_idx'),
|
||||||
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name='orderitem',
|
model_name='orderitem',
|
||||||
index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'),
|
index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'),
|
||||||
),
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='orderitem',
|
||||||
|
index=models.Index(fields=['product'], name='orders_orde_product_32ff41_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='orderitem',
|
||||||
|
index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,135 +1,334 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from accounts.models import CustomUser
|
from accounts.models import CustomUser
|
||||||
|
from customers.models import Customer, Address
|
||||||
from products.models import Product, ProductKit
|
from products.models import Product, ProductKit
|
||||||
|
from shops.models import Shop
|
||||||
|
import uuid
|
||||||
class Customer(models.Model):
|
|
||||||
"""
|
|
||||||
Модель покупателя.
|
|
||||||
"""
|
|
||||||
user = models.OneToOneField(CustomUser, on_delete=models.CASCADE, null=True, blank=True,
|
|
||||||
related_name='customer', verbose_name="Пользователь")
|
|
||||||
first_name = models.CharField(max_length=100, verbose_name="Имя")
|
|
||||||
last_name = models.CharField(max_length=100, verbose_name="Фамилия")
|
|
||||||
email = models.EmailField(unique=True, verbose_name="Email")
|
|
||||||
phone = models.CharField(max_length=20, blank=True, null=True, verbose_name="Телефон")
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата регистрации")
|
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name = "Покупатель"
|
|
||||||
verbose_name_plural = "Покупатели"
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['email']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.first_name} {self.last_name} ({self.email})"
|
|
||||||
|
|
||||||
|
|
||||||
class Order(models.Model):
|
class Order(models.Model):
|
||||||
"""
|
"""
|
||||||
Заказ клиента.
|
Заказ клиента для доставки цветов.
|
||||||
"""
|
"""
|
||||||
|
# Основная информация
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
Customer,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='orders',
|
||||||
|
verbose_name="Клиент"
|
||||||
|
)
|
||||||
|
|
||||||
|
order_number = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
unique=True,
|
||||||
|
editable=False,
|
||||||
|
verbose_name="Номер заказа",
|
||||||
|
help_text="Уникальный номер заказа для отображения клиенту"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Тип доставки
|
||||||
|
DELIVERY_TYPE_CHOICES = [
|
||||||
|
('courier', 'Курьерская доставка'),
|
||||||
|
('pickup', 'Самовывоз'),
|
||||||
|
]
|
||||||
|
|
||||||
|
delivery_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=DELIVERY_TYPE_CHOICES,
|
||||||
|
default='courier',
|
||||||
|
verbose_name="Тип доставки"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Адрес доставки (для курьерской доставки)
|
||||||
|
delivery_address = models.ForeignKey(
|
||||||
|
Address,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='orders',
|
||||||
|
verbose_name="Адрес доставки",
|
||||||
|
help_text="Обязательно для курьерской доставки"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Пункт самовывоза (для самовывоза)
|
||||||
|
pickup_shop = models.ForeignKey(
|
||||||
|
Shop,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='pickup_orders',
|
||||||
|
verbose_name="Точка самовывоза",
|
||||||
|
help_text="Обязательно для самовывоза"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дата и время доставки/самовывоза
|
||||||
|
delivery_date = models.DateField(
|
||||||
|
verbose_name="Дата доставки/самовывоза"
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery_time_start = models.TimeField(
|
||||||
|
verbose_name="Время от",
|
||||||
|
help_text="Начало временного интервала"
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery_time_end = models.TimeField(
|
||||||
|
verbose_name="Время до",
|
||||||
|
help_text="Конец временного интервала"
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery_cost = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name="Стоимость доставки",
|
||||||
|
help_text="0 для самовывоза"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Статус заказа
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('created', 'Создан'),
|
('new', 'Новый'),
|
||||||
('confirmed', 'Подтвержден'),
|
('confirmed', 'Подтвержден'),
|
||||||
('assembled', 'Собран'),
|
('in_assembly', 'В сборке'),
|
||||||
|
('in_delivery', 'В доставке'),
|
||||||
('delivered', 'Доставлен'),
|
('delivered', 'Доставлен'),
|
||||||
('cancelled', 'Отменен'),
|
('cancelled', 'Отменен'),
|
||||||
]
|
]
|
||||||
|
|
||||||
customer = models.ForeignKey(Customer, on_delete=models.CASCADE,
|
status = models.CharField(
|
||||||
related_name='orders', verbose_name="Клиент")
|
max_length=20,
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='created',
|
choices=STATUS_CHOICES,
|
||||||
verbose_name="Статус")
|
default='new',
|
||||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Общая сумма")
|
verbose_name="Статус заказа"
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
)
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
|
||||||
|
# Оплата
|
||||||
|
PAYMENT_METHOD_CHOICES = [
|
||||||
|
('cash_to_courier', 'Наличные курьеру'),
|
||||||
|
('card_to_courier', 'Карта курьеру'),
|
||||||
|
('online', 'Онлайн оплата'),
|
||||||
|
('bank_transfer', 'Банковский перевод'),
|
||||||
|
]
|
||||||
|
|
||||||
|
payment_method = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=PAYMENT_METHOD_CHOICES,
|
||||||
|
default='cash_to_courier',
|
||||||
|
verbose_name="Способ оплаты"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_paid = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Оплачен"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_amount = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
default=0,
|
||||||
|
verbose_name="Итоговая сумма заказа",
|
||||||
|
help_text="Общая сумма заказа включая доставку"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дополнительная информация
|
||||||
|
is_anonymous = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name="Анонимная доставка",
|
||||||
|
help_text="Не сообщать получателю имя отправителя"
|
||||||
|
)
|
||||||
|
|
||||||
|
special_instructions = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Особые пожелания",
|
||||||
|
help_text="Комментарии и пожелания к заказу"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Временные метки
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата создания"
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at = models.DateTimeField(
|
||||||
|
auto_now=True,
|
||||||
|
verbose_name="Дата обновления"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Заказ"
|
verbose_name = "Заказ"
|
||||||
verbose_name_plural = "Заказы"
|
verbose_name_plural = "Заказы"
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['status']),
|
|
||||||
models.Index(fields=['customer']),
|
models.Index(fields=['customer']),
|
||||||
|
models.Index(fields=['status']),
|
||||||
|
models.Index(fields=['delivery_date']),
|
||||||
|
models.Index(fields=['delivery_type']),
|
||||||
models.Index(fields=['created_at']),
|
models.Index(fields=['created_at']),
|
||||||
|
models.Index(fields=['order_number']),
|
||||||
]
|
]
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Заказ #{self.id} - {self.customer}"
|
return f"Заказ #{self.order_number} - {self.customer}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Генерируем уникальный номер заказа при создании
|
||||||
|
if not self.order_number:
|
||||||
|
self.order_number = self.generate_order_number()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def generate_order_number(self):
|
||||||
|
"""Генерирует уникальный номер заказа"""
|
||||||
|
# Формат: ORD-YYYYMMDD-XXXX (например: ORD-20250126-A3F2)
|
||||||
|
from datetime import datetime
|
||||||
|
date_str = datetime.now().strftime('%Y%m%d')
|
||||||
|
unique_id = uuid.uuid4().hex[:4].upper()
|
||||||
|
return f"ORD-{date_str}-{unique_id}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Валидация модели"""
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Проверка: для курьерской доставки обязателен адрес
|
||||||
|
if self.delivery_type == 'courier' and not self.delivery_address:
|
||||||
|
raise ValidationError({
|
||||||
|
'delivery_address': 'Для курьерской доставки необходимо указать адрес доставки'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Проверка: для самовывоза обязателен пункт самовывоза
|
||||||
|
if self.delivery_type == 'pickup' and not self.pickup_shop:
|
||||||
|
raise ValidationError({
|
||||||
|
'pickup_shop': 'Для самовывоза необходимо выбрать точку самовывоза'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Проверка: время окончания должно быть позже времени начала
|
||||||
|
if self.delivery_time_start and self.delivery_time_end:
|
||||||
|
if self.delivery_time_end <= self.delivery_time_start:
|
||||||
|
raise ValidationError({
|
||||||
|
'delivery_time_end': 'Время окончания должно быть позже времени начала'
|
||||||
|
})
|
||||||
|
|
||||||
|
def calculate_total(self):
|
||||||
|
"""Рассчитывает итоговую сумму заказа"""
|
||||||
|
items_total = sum(item.get_total_price() for item in self.items.all())
|
||||||
|
self.total_amount = items_total + self.delivery_cost
|
||||||
|
return self.total_amount
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delivery_info(self):
|
||||||
|
"""Информация о доставке для отображения"""
|
||||||
|
if self.delivery_type == 'courier':
|
||||||
|
return f"Доставка по адресу: {self.delivery_address.full_address}"
|
||||||
|
elif self.delivery_type == 'pickup':
|
||||||
|
return f"Самовывоз из: {self.pickup_shop.name}"
|
||||||
|
return "Не указано"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def delivery_time_window(self):
|
||||||
|
"""Временное окно доставки"""
|
||||||
|
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
|
||||||
|
|
||||||
|
|
||||||
class OrderItem(models.Model):
|
class OrderItem(models.Model):
|
||||||
"""
|
"""
|
||||||
Строка заказа — может быть простым товаром или комплектом.
|
Позиция (товар) в заказе.
|
||||||
|
Хранит информацию о товаре или комплекте, количестве и цене на момент заказа.
|
||||||
"""
|
"""
|
||||||
order = models.ForeignKey(Order, on_delete=models.CASCADE,
|
order = models.ForeignKey(
|
||||||
related_name='items', verbose_name="Заказ")
|
Order,
|
||||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True, blank=True,
|
on_delete=models.CASCADE,
|
||||||
related_name='order_items', verbose_name="Товар")
|
related_name='items',
|
||||||
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, null=True, blank=True,
|
verbose_name="Заказ"
|
||||||
related_name='order_items', verbose_name="Комплект")
|
)
|
||||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, default=1,
|
|
||||||
verbose_name="Количество")
|
|
||||||
|
|
||||||
# Снапшот-поля (для истории и отчётов)
|
# Товар или комплект (один из двух должен быть заполнен)
|
||||||
snapshot_name = models.CharField(max_length=200, verbose_name="Название (на момент заказа)")
|
product = models.ForeignKey(
|
||||||
snapshot_sku = models.CharField(max_length=100, blank=True, null=True,
|
Product,
|
||||||
verbose_name="Артикул (на момент заказа)")
|
on_delete=models.PROTECT,
|
||||||
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена продажи")
|
null=True,
|
||||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Себестоимость")
|
blank=True,
|
||||||
composition_snapshot = models.JSONField(null=True, blank=True,
|
related_name='order_items',
|
||||||
verbose_name="Состав комплекта (снапшот)")
|
verbose_name="Товар"
|
||||||
|
)
|
||||||
|
|
||||||
|
product_kit = models.ForeignKey(
|
||||||
|
ProductKit,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='order_items',
|
||||||
|
verbose_name="Комплект товаров"
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity = models.PositiveIntegerField(
|
||||||
|
default=1,
|
||||||
|
verbose_name="Количество"
|
||||||
|
)
|
||||||
|
|
||||||
|
price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
verbose_name="Цена за единицу",
|
||||||
|
help_text="Цена на момент создания заказа (фиксируется)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Временные метки
|
||||||
|
created_at = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
verbose_name="Дата добавления"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Позиция заказа"
|
verbose_name = "Позиция заказа"
|
||||||
verbose_name_plural = "Позиции заказов"
|
verbose_name_plural = "Позиции заказа"
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['order']),
|
models.Index(fields=['order']),
|
||||||
|
models.Index(fields=['product']),
|
||||||
|
models.Index(fields=['product_kit']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def __str__(self):
|
||||||
# Валидация: либо product, либо kit, но не оба
|
item_name = ""
|
||||||
if self.product and self.kit:
|
|
||||||
raise ValueError("Нельзя одновременно указать товар и комплект")
|
|
||||||
if not self.product and not self.kit:
|
|
||||||
raise ValueError("Необходимо указать либо товар, либо комплект")
|
|
||||||
|
|
||||||
# Заполнение снапшот-полей
|
|
||||||
if self.product:
|
if self.product:
|
||||||
if not self.snapshot_name:
|
item_name = self.product.name
|
||||||
self.snapshot_name = self.product.name
|
elif self.product_kit:
|
||||||
if not self.snapshot_sku:
|
item_name = self.product_kit.name
|
||||||
self.snapshot_sku = self.product.sku
|
return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
|
||||||
if not self.sale_price:
|
|
||||||
self.sale_price = self.product.sale_price
|
|
||||||
if not self.cost_price:
|
|
||||||
self.cost_price = self.product.cost_price
|
|
||||||
elif self.kit:
|
|
||||||
if not self.snapshot_name:
|
|
||||||
self.snapshot_name = self.kit.name
|
|
||||||
if not self.sale_price or not self.cost_price:
|
|
||||||
# Здесь можно реализовать логику подсчета цены комплекта
|
|
||||||
# в зависимости от метода ценообразования
|
|
||||||
if self.kit.pricing_method == 'fixed' and self.kit.fixed_price:
|
|
||||||
self.sale_price = self.kit.fixed_price
|
|
||||||
# В реальном приложении нужно реализовать все методы ценообразования
|
|
||||||
if self.kit.pricing_method != 'fixed' and not self.composition_snapshot:
|
|
||||||
# Формирование снапшота состава комплекта
|
|
||||||
composition = []
|
|
||||||
for item in self.kit.kit_items.all():
|
|
||||||
composition.append({
|
|
||||||
"product_id": item.product.id,
|
|
||||||
"name": item.product.name,
|
|
||||||
"sku": item.product.sku,
|
|
||||||
"quantity": float(item.quantity),
|
|
||||||
"cost_price": float(item.product.cost_price),
|
|
||||||
"sale_price": float(item.product.sale_price)
|
|
||||||
})
|
|
||||||
self.composition_snapshot = composition
|
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Валидация модели"""
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
# Проверка: должен быть заполнен либо product, либо product_kit
|
||||||
|
if not self.product and not self.product_kit:
|
||||||
|
raise ValidationError(
|
||||||
|
'Необходимо указать либо товар, либо комплект товаров'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверка: не должны быть заполнены оба поля одновременно
|
||||||
|
if self.product and self.product_kit:
|
||||||
|
raise ValidationError(
|
||||||
|
'Нельзя указать одновременно и товар, и комплект'
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# Автоматически фиксируем цену при создании, если она не указана
|
||||||
|
if not self.price:
|
||||||
|
if self.product:
|
||||||
|
self.price = self.product.price
|
||||||
|
elif self.product_kit:
|
||||||
|
self.price = self.product_kit.price
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def __str__(self):
|
def get_total_price(self):
|
||||||
return f"{self.snapshot_name} x{self.quantity} в заказе #{self.order.id}"
|
"""Возвращает общую стоимость позиции"""
|
||||||
|
return self.price * self.quantity
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_name(self):
|
||||||
|
"""Название товара/комплекта"""
|
||||||
|
if self.product:
|
||||||
|
return self.product.name
|
||||||
|
elif self.product_kit:
|
||||||
|
return self.product_kit.name
|
||||||
|
return "Не указано"
|
||||||
|
|||||||
@@ -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
|
import django.db.models.deletion
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -91,7 +91,7 @@ class Migration(migrations.Migration):
|
|||||||
name='ProductCategoryPhoto',
|
name='ProductCategoryPhoto',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('image', models.ImageField(upload_to='categories/originals/', verbose_name='Оригинальное фото')),
|
('image', models.ImageField(upload_to='categories/temp/', verbose_name='Оригинальное фото')),
|
||||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')),
|
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')),
|
||||||
@@ -131,7 +131,7 @@ class Migration(migrations.Migration):
|
|||||||
name='ProductKitPhoto',
|
name='ProductKitPhoto',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('image', models.ImageField(upload_to='kits/originals/', verbose_name='Оригинальное фото')),
|
('image', models.ImageField(upload_to='kits/temp/', verbose_name='Оригинальное фото')),
|
||||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')),
|
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')),
|
||||||
@@ -146,7 +146,7 @@ class Migration(migrations.Migration):
|
|||||||
name='ProductPhoto',
|
name='ProductPhoto',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
('image', models.ImageField(upload_to='products/originals/', verbose_name='Оригинальное фото')),
|
('image', models.ImageField(upload_to='products/temp/', verbose_name='Оригинальное фото')),
|
||||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')),
|
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')),
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ class ImageProcessor:
|
|||||||
Использует конфигурацию из settings.IMAGE_PROCESSING_CONFIG
|
Использует конфигурацию из settings.IMAGE_PROCESSING_CONFIG
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Константы для маппинга форматов и расширений файлов
|
||||||
|
FORMAT_EXTENSIONS = {
|
||||||
|
'JPEG': 'jpg',
|
||||||
|
'WEBP': 'webp',
|
||||||
|
'PNG': 'png',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Размеры для обработки
|
||||||
|
SIZE_KEYS = ['original', 'large', 'medium', 'thumbnail']
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_config():
|
def _get_config():
|
||||||
"""Получить конфигурацию из settings"""
|
"""Получить конфигурацию из settings"""
|
||||||
@@ -47,11 +57,15 @@ class ImageProcessor:
|
|||||||
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
|
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_folder(size_key):
|
def _get_file_extension(image_format):
|
||||||
"""Получить папку для сохранения заданного типа изображения"""
|
"""Получить расширение файла для заданного формата"""
|
||||||
config = ImageProcessor._get_config()
|
return ImageProcessor.FORMAT_EXTENSIONS.get(image_format, 'jpg')
|
||||||
folders = config.get('folders', {})
|
|
||||||
return folders.get(size_key, size_key)
|
@staticmethod
|
||||||
|
def _normalize_size_name(size_key):
|
||||||
|
"""Преобразовать 'thumbnail' в 'thumb' для имени файла"""
|
||||||
|
return 'thumb' if size_key == 'thumbnail' else size_key
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def process_image(image_file, base_path, entity_id=None, photo_id=None):
|
def process_image(image_file, base_path, entity_id=None, photo_id=None):
|
||||||
@@ -74,8 +88,11 @@ class ImageProcessor:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: Если файл не является изображением
|
ValueError: Если файл не является изображением или отсутствуют обязательные параметры
|
||||||
"""
|
"""
|
||||||
|
if entity_id is None or photo_id is None:
|
||||||
|
raise ValueError("entity_id and photo_id are required parameters")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Открываем изображение
|
# Открываем изображение
|
||||||
img = Image.open(image_file)
|
img = Image.open(image_file)
|
||||||
@@ -103,12 +120,9 @@ class ImageProcessor:
|
|||||||
for size_key in ['large', 'medium', 'thumbnail']:
|
for size_key in ['large', 'medium', 'thumbnail']:
|
||||||
size_dims = ImageProcessor._get_size_dimensions(size_key)
|
size_dims = ImageProcessor._get_size_dimensions(size_key)
|
||||||
resized_img = ImageProcessor._resize_image(img, size_dims)
|
resized_img = ImageProcessor._resize_image(img, size_dims)
|
||||||
|
|
||||||
# Переименовываем thumbnail в thumb для конечного пользователя
|
|
||||||
final_size_key = 'thumb' if size_key == 'thumbnail' else size_key
|
|
||||||
|
|
||||||
size_path = ImageProcessor._save_image_version(
|
size_path = ImageProcessor._save_image_version(
|
||||||
resized_img, base_path, entity_id, photo_id, size_key, final_size_key
|
resized_img, base_path, entity_id, photo_id, size_key
|
||||||
)
|
)
|
||||||
saved_paths[size_key] = size_path
|
saved_paths[size_key] = size_path
|
||||||
|
|
||||||
@@ -163,9 +177,39 @@ class ImageProcessor:
|
|||||||
return new_img
|
return new_img
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _save_image_version(img, base_path, entity_id, photo_id, size_key, final_size_name=None):
|
def _make_square_image(img, max_size):
|
||||||
"""
|
"""
|
||||||
Сохраняет версию изображения в новой структуре с фиксированными именами.
|
Делает изображение квадратным с белым фоном.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: PIL Image object
|
||||||
|
max_size: Максимальный размер стороны квадрата
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PIL Image object - квадратное изображение
|
||||||
|
"""
|
||||||
|
# Если изображение больше max_size, масштабируем
|
||||||
|
if img.width > max_size or img.height > max_size:
|
||||||
|
scale_factor = min(max_size / img.width, max_size / img.height)
|
||||||
|
new_width = int(img.width * scale_factor)
|
||||||
|
new_height = int(img.height * scale_factor)
|
||||||
|
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Создаем квадратное изображение
|
||||||
|
size_obj = min(max(img.width, img.height), max_size)
|
||||||
|
square_img = Image.new('RGB', (size_obj, size_obj), (255, 255, 255))
|
||||||
|
|
||||||
|
# Центрируем изображение
|
||||||
|
offset_x = (size_obj - img.width) // 2
|
||||||
|
offset_y = (size_obj - img.height) // 2
|
||||||
|
square_img.paste(img, (offset_x, offset_y))
|
||||||
|
|
||||||
|
return square_img
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _save_image_version(img, base_path, entity_id, photo_id, size_key):
|
||||||
|
"""
|
||||||
|
Сохраняет версию изображения с фиксированными именами.
|
||||||
Использует формат и качество из конфигурации для каждого размера.
|
Использует формат и качество из конфигурации для каждого размера.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -174,7 +218,6 @@ class ImageProcessor:
|
|||||||
entity_id: ID сущности (product_id, category_id, kit_id)
|
entity_id: ID сущности (product_id, category_id, kit_id)
|
||||||
photo_id: ID фотографии
|
photo_id: ID фотографии
|
||||||
size_key: Ключ размера ('original', 'large', 'medium', 'thumbnail')
|
size_key: Ключ размера ('original', 'large', 'medium', 'thumbnail')
|
||||||
final_size_name: Имя размера в файле ('original', 'large', 'medium', 'thumb')
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Путь сохраненного файла относительно MEDIA_ROOT
|
str: Путь сохраненного файла относительно MEDIA_ROOT
|
||||||
@@ -184,55 +227,23 @@ class ImageProcessor:
|
|||||||
image_format = format_config.get('format', 'JPEG')
|
image_format = format_config.get('format', 'JPEG')
|
||||||
quality = format_config.get('quality', 90)
|
quality = format_config.get('quality', 90)
|
||||||
|
|
||||||
# Определяем расширение файла в зависимости от формата
|
# Определяем расширение и имя файла
|
||||||
ext_map = {
|
extension = ImageProcessor._get_file_extension(image_format)
|
||||||
'JPEG': 'jpg',
|
final_size_name = ImageProcessor._normalize_size_name(size_key)
|
||||||
'WEBP': 'webp',
|
|
||||||
'PNG': 'png',
|
|
||||||
}
|
|
||||||
extension = ext_map.get(image_format, 'jpg')
|
|
||||||
|
|
||||||
# Определяем имя размера для файла ('thumb' вместо 'thumbnail')
|
|
||||||
if final_size_name is None:
|
|
||||||
final_size_name = 'thumb' if size_key == 'thumbnail' else size_key
|
|
||||||
|
|
||||||
# Создаем имя файла с фиксированным именем и расширением
|
|
||||||
filename = f"{final_size_name}.{extension}"
|
filename = f"{final_size_name}.{extension}"
|
||||||
|
|
||||||
# Создаем путь в новой структуре: base_path/entity_id/photo_id/filename
|
# Создаем путь: base_path/entity_id/photo_id/filename
|
||||||
file_path = f"{base_path}/{entity_id}/{photo_id}/{filename}"
|
file_path = f"{base_path}/{entity_id}/{photo_id}/{filename}"
|
||||||
|
|
||||||
|
# Обрабатываем оригинал - делаем квадратным
|
||||||
|
if size_key == 'original':
|
||||||
|
max_size = ImageProcessor._get_size_dimensions('original')[0]
|
||||||
|
img = ImageProcessor._make_square_image(img, max_size)
|
||||||
|
|
||||||
# Сохраняем в памяти
|
# Сохраняем в памяти
|
||||||
img_io = BytesIO()
|
img_io = BytesIO()
|
||||||
|
|
||||||
# Масштабируем оригинал если необходимо (для original размера)
|
|
||||||
if size_key == 'original':
|
|
||||||
max_size = ImageProcessor._get_size_dimensions('original')[0] # квадратный размер
|
|
||||||
|
|
||||||
# Если оригинал больше максимального размера, масштабируем
|
|
||||||
if img.width > max_size or img.height > max_size:
|
|
||||||
# Вычисляем новый размер с сохранением пропорций
|
|
||||||
scale_factor = min(max_size / img.width, max_size / img.height)
|
|
||||||
new_width = int(img.width * scale_factor)
|
|
||||||
new_height = int(img.height * scale_factor)
|
|
||||||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
# Делаем изображение квадратным: добавляем белый фон
|
|
||||||
size_obj = max(img.width, img.height)
|
|
||||||
# Но не больше max_size
|
|
||||||
if size_obj > max_size:
|
|
||||||
size_obj = max_size
|
|
||||||
|
|
||||||
square_img = Image.new('RGB', (size_obj, size_obj), (255, 255, 255))
|
|
||||||
offset_x = (size_obj - img.width) // 2
|
|
||||||
offset_y = (size_obj - img.height) // 2
|
|
||||||
square_img.paste(img, (offset_x, offset_y))
|
|
||||||
img = square_img
|
|
||||||
|
|
||||||
# Сохраняем с указанным форматом и качеством
|
|
||||||
save_kwargs = {'format': image_format, 'optimize': True}
|
save_kwargs = {'format': image_format, 'optimize': True}
|
||||||
|
|
||||||
# Качество поддерживается только для JPEG и WebP
|
|
||||||
if image_format in ('JPEG', 'WEBP'):
|
if image_format in ('JPEG', 'WEBP'):
|
||||||
save_kwargs['quality'] = quality
|
save_kwargs['quality'] = quality
|
||||||
|
|
||||||
@@ -248,7 +259,7 @@ class ImageProcessor:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_all_versions(base_path, original_image_path, entity_id=None, photo_id=None):
|
def delete_all_versions(base_path, original_image_path, entity_id=None, photo_id=None):
|
||||||
"""
|
"""
|
||||||
Удаляет все версии изображения (original, large, medium, thumb) из новой структуры.
|
Удаляет все версии изображения (original, large, medium, thumb).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_path: Базовый путь (например, 'products')
|
base_path: Базовый путь (например, 'products')
|
||||||
@@ -259,90 +270,28 @@ class ImageProcessor:
|
|||||||
if not original_image_path:
|
if not original_image_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Если переданы entity_id и photo_id, используем новую структуру
|
if entity_id is None or photo_id is None:
|
||||||
if entity_id is not None and photo_id is not None:
|
logger.error("entity_id and photo_id are required for deleting images")
|
||||||
# Удаляем файлы в новой структуре
|
return
|
||||||
for size_key in ['original', 'large', 'medium', 'thumbnail']:
|
|
||||||
format_config = ImageProcessor._get_format_config(size_key)
|
|
||||||
image_format = format_config.get('format', 'JPEG')
|
|
||||||
|
|
||||||
# Определяем расширение
|
# Удаляем все размеры
|
||||||
ext_map = {
|
for size_key in ImageProcessor.SIZE_KEYS:
|
||||||
'JPEG': 'jpg',
|
format_config = ImageProcessor._get_format_config(size_key)
|
||||||
'WEBP': 'webp',
|
image_format = format_config.get('format', 'JPEG')
|
||||||
'PNG': 'png',
|
|
||||||
}
|
|
||||||
extension = ext_map.get(image_format, 'jpg')
|
|
||||||
|
|
||||||
# Определяем имя размера для файла ('thumb' вместо 'thumbnail')
|
# Определяем расширение и имя файла
|
||||||
final_size_name = 'thumb' if size_key == 'thumbnail' else size_key
|
extension = ImageProcessor._get_file_extension(image_format)
|
||||||
|
final_size_name = ImageProcessor._normalize_size_name(size_key)
|
||||||
|
size_filename = f"{final_size_name}.{extension}"
|
||||||
|
|
||||||
# Создаем имя файла для этого размера
|
# Создаем путь
|
||||||
size_filename = f"{final_size_name}.{extension}"
|
file_path = f"{base_path}/{entity_id}/{photo_id}/{size_filename}"
|
||||||
|
|
||||||
# Создаем путь в новой структуре
|
|
||||||
file_path = f"{base_path}/{entity_id}/{photo_id}/{size_filename}"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if default_storage.exists(file_path):
|
if default_storage.exists(file_path):
|
||||||
default_storage.delete(file_path)
|
default_storage.delete(file_path)
|
||||||
logger.info(f"Deleted file: {file_path}")
|
logger.info(f"Deleted file: {file_path}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"File not found: {file_path}")
|
logger.warning(f"File not found: {file_path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error deleting {file_path}: {str(e)}", exc_info=True)
|
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)}"
|
|
||||||
|
|||||||
@@ -13,22 +13,18 @@ class ImageService:
|
|||||||
Динамически строит URL на основе пути к оригинальному файлу.
|
Динамически строит URL на основе пути к оригинальному файлу.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Константы для маппинга форматов и расширений файлов
|
||||||
|
FORMAT_EXTENSIONS = {
|
||||||
|
'JPEG': 'jpg',
|
||||||
|
'WEBP': 'webp',
|
||||||
|
'PNG': 'png',
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_config():
|
def _get_config():
|
||||||
"""Получить конфигурацию из settings"""
|
"""Получить конфигурацию из settings"""
|
||||||
return getattr(settings, 'IMAGE_PROCESSING_CONFIG', {})
|
return getattr(settings, 'IMAGE_PROCESSING_CONFIG', {})
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_size_folders():
|
|
||||||
"""Получить папки для разных размеров из конфигурации"""
|
|
||||||
config = ImageService._get_config()
|
|
||||||
return config.get('folders', {
|
|
||||||
'thumbnail': 'thumbnails',
|
|
||||||
'medium': 'medium',
|
|
||||||
'large': 'large',
|
|
||||||
'original': 'originals',
|
|
||||||
})
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_format_config(size_key):
|
def _get_format_config(size_key):
|
||||||
"""Получить конфигурацию формата для заданного типа изображения"""
|
"""Получить конфигурацию формата для заданного типа изображения"""
|
||||||
@@ -37,32 +33,25 @@ class ImageService:
|
|||||||
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
|
return formats.get(size_key, {'format': 'JPEG', 'quality': 90})
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_file_extension(size_key):
|
def _get_file_extension(image_format):
|
||||||
"""Получить расширение файла для заданного типа изображения"""
|
"""Получить расширение файла для заданного формата"""
|
||||||
format_config = ImageService._get_format_config(size_key)
|
return ImageService.FORMAT_EXTENSIONS.get(image_format, 'jpg')
|
||||||
image_format = format_config.get('format', 'JPEG')
|
|
||||||
|
|
||||||
ext_map = {
|
@staticmethod
|
||||||
'JPEG': 'jpg',
|
def _normalize_size_name(size_key):
|
||||||
'WEBP': 'webp',
|
"""Преобразовать 'thumbnail' в 'thumb' для имени файла"""
|
||||||
'PNG': 'png',
|
return 'thumb' if size_key == 'thumbnail' else size_key
|
||||||
}
|
|
||||||
return ext_map.get(image_format, 'jpg')
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_url(original_image_path, size='medium'):
|
def get_url(original_image_path, size='medium'):
|
||||||
"""
|
"""
|
||||||
Получает URL изображения нужного размера.
|
Получает URL изображения нужного размера.
|
||||||
|
|
||||||
Работает с новой структурой:
|
Структура хранения: base_path/entity_id/photo_id/size.ext
|
||||||
- products/<entity_id>/<photo_id>/original.jpg
|
Пример: products/123/456/medium.webp
|
||||||
- products/<entity_id>/<photo_id>/large.webp
|
|
||||||
- products/<entity_id>/<photo_id>/medium.webp
|
|
||||||
- products/<entity_id>/<photo_id>/thumb.webp
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
original_image_path: Путь к оригинальному файлу (из models.image)
|
original_image_path: Путь к оригинальному файлу (из models.image)
|
||||||
Обычно это путь к файлу 'original'
|
|
||||||
Пример: products/123/456/original.jpg
|
Пример: products/123/456/original.jpg
|
||||||
size: Размер ('original', 'large', 'medium', 'thumbnail')
|
size: Размер ('original', 'large', 'medium', 'thumbnail')
|
||||||
По умолчанию 'medium'
|
По умолчанию 'medium'
|
||||||
@@ -74,115 +63,29 @@ class ImageService:
|
|||||||
return ''
|
return ''
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Работаем с новой структурой: products/<entity_id>/<photo_id>/original.jpg
|
|
||||||
path_str = str(original_image_path)
|
path_str = str(original_image_path)
|
||||||
parts = path_str.split('/')
|
parts = path_str.split('/')
|
||||||
|
|
||||||
if len(parts) >= 3:
|
|
||||||
# Извлекаем 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) < 3:
|
||||||
if len(parts) > 0:
|
return ''
|
||||||
base_path = parts[0]
|
|
||||||
else:
|
|
||||||
base_path = 'products'
|
|
||||||
|
|
||||||
# Проверяем старый формат имени файла с расширением
|
# Извлекаем base_path, entity_id, photo_id из пути
|
||||||
# Поддерживаем jpg, webp, png расширения
|
base_path = parts[0] # products, kits, categories
|
||||||
if filename.endswith(('.jpg', '.webp', '.png')):
|
entity_id = parts[1] # ID сущности
|
||||||
# Определяем расширение файла
|
photo_id = parts[2] # ID фото
|
||||||
file_ext = os.path.splitext(filename)[1] # .jpg, .webp и т.д.
|
|
||||||
filename_without_ext = filename[:-(len(file_ext))] # Имя без расширения
|
|
||||||
|
|
||||||
# Разделяем по последнему _ для получения base_filename и size_key
|
# Определяем расширение из конфигурации
|
||||||
parts_of_name = filename_without_ext.rsplit('_', 1)
|
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:
|
# Преобразуем thumbnail в thumb
|
||||||
base_filename, file_size_key = parts_of_name
|
final_size_name = ImageService._normalize_size_name(size)
|
||||||
# Это старый формат с явным указанием размера в имени
|
|
||||||
|
|
||||||
# Получаем расширение для целевого размера
|
# Создаем путь и возвращаем URL
|
||||||
target_ext = ImageService._get_file_extension(size)
|
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:
|
except Exception:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
asgiref==3.10.0
|
asgiref==3.10.0
|
||||||
Django==5.2.7
|
Django==5.1.4
|
||||||
django-nested-admin==4.1.5
|
django-nested-admin==4.1.5
|
||||||
django-phonenumber-field==8.3.0
|
django-phonenumber-field==8.3.0
|
||||||
|
django-tenants==3.7.0
|
||||||
pillow==12.0.0
|
pillow==12.0.0
|
||||||
|
psycopg[binary]>=3.1
|
||||||
python-monkey-business==1.1.0
|
python-monkey-business==1.1.0
|
||||||
sqlparse==0.5.3
|
sqlparse==0.5.3
|
||||||
tzdata==2025.2
|
tzdata==2025.2
|
||||||
|
|||||||
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