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

Регистрация нового магазина

+

Заполните форму для создания вашего интернет-магазина

+
+
+
+ {% csrf_token %} + + +
+ + {{ form.shop_name }} + {% if form.shop_name.errors %} +
+ {{ form.shop_name.errors.0 }} +
+ {% endif %} + {% if form.shop_name.help_text %} +
{{ form.shop_name.help_text }}
+ {% endif %} +
+ + +
+ +
+ {{ form.schema_name }} + .inventory.by +
+ {% if form.schema_name.errors %} +
+ {{ form.schema_name.errors.0 }} +
+ {% endif %} + {% if form.schema_name.help_text %} +
{{ form.schema_name.help_text }}
+ {% endif %} +
+ + +
+ + {{ form.owner_name }} + {% if form.owner_name.errors %} +
+ {{ form.owner_name.errors.0 }} +
+ {% endif %} +
+ + +
+ + {{ form.owner_email }} + {% if form.owner_email.errors %} +
+ {{ form.owner_email.errors.0 }} +
+ {% endif %} + {% if form.owner_email.help_text %} +
{{ form.owner_email.help_text }}
+ {% endif %} +
+ + +
+ + {{ form.phone }} + {% if form.phone.errors %} +
+ {{ form.phone.errors.0 }} +
+ {% endif %} +
+ + +
+ +
+ +
+ + После отправки заявки ваш магазин будет проверен администратором.
+ Уведомление придет на указанный email в течение 24 часов. +
+
+
+
+
+ + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/myproject/tenants/templates/tenants/registration_success.html b/myproject/tenants/templates/tenants/registration_success.html new file mode 100644 index 0000000..adf841a --- /dev/null +++ b/myproject/tenants/templates/tenants/registration_success.html @@ -0,0 +1,50 @@ +{% extends "tenants/base.html" %} + +{% block title %}Заявка отправлена{% endblock %} + +{% block content %} +
+
+

Спасибо за регистрацию!

+
+
+
+ + + + +
+ +

Ваша заявка успешно отправлена!

+ +

+ Мы получили вашу заявку на создание магазина.
+ Наш администратор проверит данные и активирует ваш магазин. +

+ + + + + +
+ + Если у вас возникли вопросы, свяжитесь с нами:
+ support@inventory.by +
+
+
+
+{% endblock %} diff --git a/myproject/tenants/urls.py b/myproject/tenants/urls.py new file mode 100644 index 0000000..95037eb --- /dev/null +++ b/myproject/tenants/urls.py @@ -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'), +] diff --git a/myproject/tenants/views.py b/myproject/tenants/views.py new file mode 100644 index 0000000..71ddee3 --- /dev/null +++ b/myproject/tenants/views.py @@ -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