Daphne (ASGI) для WebSocket, подписки, email, регистрация, mentor gate, telegram-bot
This commit is contained in:
parent
e3517b39ff
commit
6420a9b182
|
|
@ -0,0 +1,104 @@
|
|||
# Backend (Django): миграции, суперпользователь, команды
|
||||
|
||||
Краткая инструкция по типичным операциям с бэкендом платформы.
|
||||
|
||||
---
|
||||
|
||||
## Через Docker (рекомендуется на сервере)
|
||||
|
||||
Все команды выполняйте из каталога проекта (`/var/www/platform/prod`). Сервис Django в compose называется **web**.
|
||||
|
||||
### Миграции
|
||||
|
||||
```bash
|
||||
# Применить все миграции
|
||||
docker compose -p platform exec web python manage.py migrate
|
||||
|
||||
# Создать миграции по изменениям в моделях (после правок в models.py)
|
||||
docker compose -p platform exec web python manage.py makemigrations
|
||||
|
||||
# Создать миграции для конкретного приложения
|
||||
docker compose -p platform exec web python manage.py makemigrations app_name
|
||||
|
||||
# Показать список миграций и их статус
|
||||
docker compose -p platform exec web python manage.py showmigrations
|
||||
```
|
||||
|
||||
После изменения моделей: сначала `makemigrations`, затем `migrate`.
|
||||
|
||||
### Суперпользователь (админ)
|
||||
|
||||
```bash
|
||||
# Создать суперпользователя (интерактивно запросит email и пароль)
|
||||
docker compose -p platform exec web python manage.py createsuperuser
|
||||
|
||||
# Сменить пароль существующего пользователя
|
||||
docker compose -p platform exec web python manage.py changepassword <email_или_username>
|
||||
```
|
||||
|
||||
Доступ в админку: **https://api.uchill.online/admin/** (логин/пароль суперпользователя).
|
||||
|
||||
### Сбор статики
|
||||
|
||||
```bash
|
||||
# Собрать статические файлы в STATIC_ROOT (для production)
|
||||
docker compose -p platform exec web python manage.py collectstatic --noinput
|
||||
```
|
||||
|
||||
### Django shell
|
||||
|
||||
```bash
|
||||
# Обычный shell
|
||||
docker compose -p platform exec web python manage.py shell
|
||||
|
||||
# Shell с автоматическим импортом моделей (если установлен django-extensions)
|
||||
docker compose -p platform exec web python manage.py shell_plus
|
||||
```
|
||||
|
||||
### Другие полезные команды
|
||||
|
||||
```bash
|
||||
# Проверка конфигурации
|
||||
docker compose -p platform exec web python manage.py check
|
||||
|
||||
# Список всех команд
|
||||
docker compose -p platform exec web python manage.py help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Локально (без Docker)
|
||||
|
||||
Из каталога **backend** (`/var/www/platform/prod/backend`). Нужны: Python 3, установленные зависимости, переменные окружения (или `.env` с `DATABASE_URL` и т.д.).
|
||||
|
||||
```bash
|
||||
cd /var/www/platform/prod/backend
|
||||
|
||||
# Виртуальное окружение (если используется)
|
||||
# source .venv/bin/activate # Linux/macOS
|
||||
# .venv\Scripts\activate # Windows
|
||||
|
||||
# Миграции
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
|
||||
# Суперпользователь
|
||||
python manage.py createsuperuser
|
||||
|
||||
# Статика
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Запуск сервера разработки
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Порядок при первом развёртывании
|
||||
|
||||
1. Запустить контейнеры: `docker compose -p platform up -d`
|
||||
2. Применить миграции: `docker compose -p platform exec web python manage.py migrate`
|
||||
3. Создать суперпользователя: `docker compose -p platform exec web python manage.py createsuperuser`
|
||||
4. При необходимости: `docker compose -p platform exec web python manage.py collectstatic --noinput`
|
||||
|
||||
В текущем `docker-compose` при старте сервиса **web** уже выполняется `migrate` в command, поэтому шаг 2 часто не нужен при обычном перезапуске.
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"""
|
||||
Отправка тестового письма для проверки SMTP.
|
||||
Запуск: python manage.py send_test_email recipient@example.com
|
||||
Из Docker: docker compose exec web python manage.py send_test_email recipient@example.com
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Отправляет тестовое письмо на указанный адрес для проверки SMTP'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'email',
|
||||
type=str,
|
||||
help='Email получателя',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
to_email = options['email'].strip()
|
||||
self.stdout.write('Текущие настройки почты:')
|
||||
self.stdout.write(f' EMAIL_BACKEND = {getattr(settings, "EMAIL_BACKEND", "?")}')
|
||||
self.stdout.write(f' EMAIL_HOST = {getattr(settings, "EMAIL_HOST", "?")}')
|
||||
self.stdout.write(f' EMAIL_PORT = {getattr(settings, "EMAIL_PORT", "?")}')
|
||||
self.stdout.write(f' EMAIL_USE_TLS = {getattr(settings, "EMAIL_USE_TLS", "?")}')
|
||||
self.stdout.write(f' EMAIL_USE_SSL = {getattr(settings, "EMAIL_USE_SSL", "?")}')
|
||||
self.stdout.write(f' EMAIL_HOST_USER = {getattr(settings, "EMAIL_HOST_USER", "?")}')
|
||||
pw = getattr(settings, 'EMAIL_HOST_PASSWORD', '') or ''
|
||||
self.stdout.write(f' EMAIL_HOST_PASSWORD = {"***" if pw else "(пусто)"}')
|
||||
self.stdout.write(f' DEFAULT_FROM_EMAIL = {getattr(settings, "DEFAULT_FROM_EMAIL", "?")}')
|
||||
self.stdout.write('')
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject='Тест отправки с платформы',
|
||||
message='Если вы получили это письмо, SMTP настроен верно.',
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[to_email],
|
||||
fail_silently=False,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f'Письмо отправлено на {to_email}'))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f'Ошибка: {e}'))
|
||||
import traceback
|
||||
self.stdout.write(traceback.format_exc())
|
||||
self.stdout.write('')
|
||||
self.stdout.write(
|
||||
'Подсказка: для Mail.ru используйте порт 465 с SSL и пароль приложения '
|
||||
'(Настройки → Безопасность → Пароли для внешних приложений).'
|
||||
)
|
||||
|
|
@ -151,11 +151,11 @@ class NotificationPreferenceViewSet(viewsets.ModelViewSet):
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if not settings.TELEGRAM_BOT_TOKEN:
|
||||
if not getattr(settings, 'TELEGRAM_BOT_TOKEN', None) or not settings.TELEGRAM_BOT_TOKEN.strip():
|
||||
return Response({
|
||||
'success': False,
|
||||
'error': 'TELEGRAM_BOT_TOKEN не настроен'
|
||||
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
async def get_bot_info():
|
||||
bot = Bot(token=settings.TELEGRAM_BOT_TOKEN)
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
|
|||
@action(detail=False, methods=['get'])
|
||||
def active(self, request):
|
||||
"""
|
||||
Активная подписка.
|
||||
Активная подписка. При отсутствии — 200 и null (без 404), чтобы фронт не получал ошибку.
|
||||
|
||||
GET /api/subscriptions/subscriptions/active/
|
||||
"""
|
||||
|
|
@ -242,11 +242,8 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
|
|||
status__in=['trial', 'active']
|
||||
).select_related('plan', 'user').order_by('-end_date').first()
|
||||
|
||||
if not subscription:
|
||||
return Response(
|
||||
{'error': 'Активная подписка не найдена'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
if not subscription or not subscription.is_active():
|
||||
return Response(None)
|
||||
|
||||
serializer = SubscriptionSerializer(subscription, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
|
|
@ -291,6 +288,78 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
|
|||
serializer = SubscriptionSerializer(subscription, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def activate_free(self, request):
|
||||
"""
|
||||
Активировать бесплатный тариф (цена плана = 0). Платёжный запрос не создаётся.
|
||||
|
||||
POST /api/subscriptions/subscriptions/activate_free/
|
||||
Body: {
|
||||
"plan_id": 1,
|
||||
"duration_days": 30, // опционально
|
||||
"student_count": 1 // опционально, для тарифа "за ученика"
|
||||
}
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
plan_id = request.data.get('plan_id')
|
||||
if not plan_id:
|
||||
return Response(
|
||||
{'error': 'Требуется plan_id'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
try:
|
||||
plan = SubscriptionPlan.objects.get(id=plan_id, is_active=True)
|
||||
except SubscriptionPlan.DoesNotExist:
|
||||
return Response(
|
||||
{'error': 'Тариф не найден'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
# Только для тарифов с нулевой ценой (monthly: price=0, per_student: price_per_student=0)
|
||||
base_price = Decimal(str(plan.price if plan.price is not None else 0))
|
||||
price_per_student = Decimal(str(plan.price_per_student if getattr(plan, 'price_per_student', None) is not None else 0))
|
||||
st = getattr(plan, 'subscription_type', None) or 'monthly'
|
||||
is_free = (st == 'per_student' and price_per_student == Decimal('0')) or (st != 'per_student' and base_price == Decimal('0'))
|
||||
if not is_free:
|
||||
return Response(
|
||||
{'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
duration_days = int(request.data.get('duration_days', 30))
|
||||
student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0
|
||||
if st == 'per_student' and student_count <= 0:
|
||||
student_count = 1
|
||||
available = plan.get_available_durations() if hasattr(plan, 'get_available_durations') else [30]
|
||||
if not available:
|
||||
available = [30]
|
||||
if duration_days not in available:
|
||||
duration_days = available[0]
|
||||
try:
|
||||
subscription = SubscriptionService.create_subscription(
|
||||
user=request.user,
|
||||
plan=plan,
|
||||
student_count=student_count,
|
||||
duration_days=duration_days,
|
||||
start_date=timezone.now(),
|
||||
promo_code=None
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).exception("activate_free create_subscription failed")
|
||||
return Response(
|
||||
{'error': str(e)},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
subscription.status = 'active'
|
||||
subscription.save(update_fields=['status'])
|
||||
serializer = SubscriptionSerializer(subscription, context={'request': request})
|
||||
return Response({
|
||||
'success': True,
|
||||
'message': 'Подписка активирована',
|
||||
'subscription': serializer.data,
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def check_feature(self, request):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -605,71 +605,49 @@ class ProfileViewSet(viewsets.ViewSet):
|
|||
# Нормализуем запрос: приводим к lowercase после проверки на пустоту
|
||||
query_lower = query.lower().strip()
|
||||
|
||||
# Используем кеш для хранения данных CSV
|
||||
cache_key = 'cities_csv_data'
|
||||
cities_data = cache.get(cache_key)
|
||||
|
||||
if cities_data is None:
|
||||
try:
|
||||
# Путь к локальному файлу city.csv
|
||||
import os
|
||||
# Пробуем разные пути
|
||||
possible_paths = [
|
||||
os.path.join(settings.BASE_DIR, '..', '..', 'city.csv'), # Из backend/config/ -> platform/
|
||||
os.path.join(settings.BASE_DIR, '..', 'city.csv'), # Из backend/ -> platform/
|
||||
os.path.join(settings.BASE_DIR, 'city.csv'), # В backend/config/
|
||||
'/app/city.csv', # В Docker контейнере (если монтирован в /app)
|
||||
'/code/city.csv', # Альтернативный путь в Docker
|
||||
os.path.join(settings.BASE_DIR, '..', '..', 'city.csv'),
|
||||
os.path.join(settings.BASE_DIR, '..', 'city.csv'),
|
||||
os.path.join(settings.BASE_DIR, 'city.csv'),
|
||||
'/app/city.csv',
|
||||
'/code/city.csv',
|
||||
]
|
||||
csv_path = next((p for p in possible_paths if os.path.exists(p)), None)
|
||||
# Ключ кеша по mtime — после загрузки нового city.csv подхватятся свежие данные
|
||||
if csv_path:
|
||||
cache_key = 'cities_csv_data_%s' % int(os.path.getmtime(csv_path))
|
||||
cities_data = cache.get(cache_key)
|
||||
else:
|
||||
cache_key = None
|
||||
cities_data = None
|
||||
|
||||
csv_path = None
|
||||
for path in possible_paths:
|
||||
if os.path.exists(path):
|
||||
csv_path = path
|
||||
break
|
||||
|
||||
if not csv_path:
|
||||
raise FileNotFoundError("city.csv не найден ни в одном из возможных мест")
|
||||
|
||||
# Читаем CSV файл
|
||||
if cities_data is None and csv_path:
|
||||
try:
|
||||
cities_data = []
|
||||
with open(csv_path, 'r', encoding='utf-8') as f:
|
||||
reader = csv.DictReader(f)
|
||||
|
||||
for row in reader:
|
||||
city_name = row.get('city', '').strip()
|
||||
city_type = row.get('city_type', '').strip()
|
||||
timezone = row.get('timezone', '').strip()
|
||||
region = row.get('region', '').strip()
|
||||
|
||||
# Если city пустое, но есть region (как в случае с Москвой),
|
||||
# используем region как название города
|
||||
if not city_name and region:
|
||||
city_name = region
|
||||
|
||||
if city_name and timezone:
|
||||
# Формируем полное название с типом (для поиска)
|
||||
full_city_name = f"{city_type} {city_name}".strip() if city_type else city_name
|
||||
|
||||
cities_data.append({
|
||||
'name': city_name, # Оригинальное имя без типа для отображения
|
||||
'full_search_name': full_city_name.lower(), # Полное имя с типом для поиска
|
||||
'name': city_name,
|
||||
'full_search_name': full_city_name.lower(),
|
||||
'timezone': timezone,
|
||||
'region': region,
|
||||
'city_type': city_type,
|
||||
'full_name': f"{city_name}" + (f", {region}" if region and region != city_name else ""),
|
||||
})
|
||||
|
||||
# Сохраняем в кеш на 24 часа
|
||||
cache.set(cache_key, cities_data, 24 * 60 * 60)
|
||||
|
||||
# Отладочная информация
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Loaded {len(cities_data)} cities from CSV")
|
||||
# Проверяем наличие Москвы в данных
|
||||
moscow_found = any(c.get('name', '').lower() == 'москва' for c in cities_data)
|
||||
logger.info(f"Moscow found in data: {moscow_found}")
|
||||
except Exception as e:
|
||||
# В случае ошибки возвращаем пустой список
|
||||
import logging
|
||||
|
|
@ -679,14 +657,24 @@ class ProfileViewSet(viewsets.ViewSet):
|
|||
logger.error(traceback.format_exc())
|
||||
return Response({'error': str(e)}, status=500)
|
||||
|
||||
# Проверяем, что данные загружены
|
||||
# Если CSV пустой или не найден — используем cities_ru.json (geo_utils)
|
||||
if not cities_data:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("cities_data is empty, cache might be expired or file not found")
|
||||
return Response([])
|
||||
logger.info("city.csv empty or missing, using cities_ru.json fallback")
|
||||
from .geo_utils import search_cities as search_cities_json
|
||||
json_cities = search_cities_json(country="RU", query=query_lower)
|
||||
results = []
|
||||
for c in json_cities[:limit]:
|
||||
results.append({
|
||||
'name': c.get('city', ''),
|
||||
'timezone': c.get('timezone', ''),
|
||||
'region': c.get('country_name', ''),
|
||||
'full_name': f"{c.get('city', '')}, {c.get('country_name', '')}".strip(', '),
|
||||
})
|
||||
return Response(results)
|
||||
|
||||
# Ищем города по запросу
|
||||
# Ищем города по запросу в данных из CSV
|
||||
results = []
|
||||
|
||||
# Префиксы типов населенных пунктов, которые нужно убирать при поиске
|
||||
|
|
|
|||
1118
backend/city.csv
1118
backend/city.csv
File diff suppressed because it is too large
Load Diff
|
|
@ -494,12 +494,17 @@ else:
|
|||
|
||||
# SMTP настройки (используются только если EMAIL_BACKEND=smtp)
|
||||
EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.gmail.com')
|
||||
EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587'))
|
||||
EMAIL_PORT = int(os.getenv('EMAIL_PORT', '2525'))
|
||||
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'True') == 'True'
|
||||
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'False') == 'True' # Для порта 465
|
||||
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
|
||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'noreply@platform.com')
|
||||
_default_from = os.getenv('DEFAULT_FROM_EMAIL', '').strip()
|
||||
# Для Mail.ru и др.: From должен совпадать с ящиком SMTP, иначе письма не доходят или уходят в спам
|
||||
if EMAIL_HOST_USER and (not _default_from or _default_from == 'noreply@platform.com'):
|
||||
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
|
||||
else:
|
||||
DEFAULT_FROM_EMAIL = _default_from or 'noreply@platform.com'
|
||||
|
||||
# Таймауты для отправки email
|
||||
EMAIL_TIMEOUT = int(os.getenv('EMAIL_TIMEOUT', '10'))
|
||||
|
|
|
|||
|
|
@ -1,31 +1,35 @@
|
|||
# ==============================================
|
||||
# Docker Compose для DEV окружения
|
||||
# Docker Compose PROD (порты не пересекаются с dev на одном хосте)
|
||||
# ==============================================
|
||||
# Порты на хосте (prod): db 5434, redis 6381, web 8123, nginx 8084,
|
||||
# front_material 3010, yjs 1236, excalidraw 3004, whiteboard 8083,
|
||||
# livekit 7880/7881, celery/beat — без портов (внутренние)
|
||||
# Dev использует: 5433, 6380, 8124, 8081, 3002, 1235, 3003, 8082, livekit 7890/7891
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: platform_dev_db
|
||||
container_name: platform_prod_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: platform_dev_db
|
||||
POSTGRES_USER: platform_dev_user
|
||||
POSTGRES_PASSWORD: platform_dev_password
|
||||
POSTGRES_DB: platform_prod_db
|
||||
POSTGRES_USER: platform_prod_user
|
||||
POSTGRES_PASSWORD: platform_prod_password
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- dev_postgres_data:/var/lib/postgresql/data
|
||||
- prod_postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: platform_dev_redis
|
||||
container_name: platform_prod_redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6380:6379"
|
||||
- "6381:6379"
|
||||
volumes:
|
||||
- dev_redis_data:/data
|
||||
- prod_redis_data:/data
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
|
|
@ -33,16 +37,39 @@ services:
|
|||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: platform_dev_web
|
||||
container_name: platform_prod_web
|
||||
restart: unless-stopped
|
||||
command: sh -c "python manage.py migrate && gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --threads 2"
|
||||
user: "0:0"
|
||||
env_file: .env
|
||||
# Daphne (ASGI): HTTP + WebSocket (/ws/notifications/, /ws/chat/, /ws/board/ и т.д.)
|
||||
command: sh -c "python manage.py migrate && daphne -b 0.0.0.0 -p 8000 config.asgi:application"
|
||||
environment:
|
||||
- DEBUG=True
|
||||
- DEBUG=${DEBUG:-True}
|
||||
- SECRET_KEY=dev_secret_key
|
||||
- DATABASE_URL=postgresql://platform_dev_user:platform_dev_password@db:5432/platform_dev_db
|
||||
- ALLOWED_HOSTS=api.uchill.online,app.uchill.online,uchill.online,www.uchill.online,localhost,127.0.0.1,85.192.56.185
|
||||
- DATABASE_URL=postgresql://platform_prod_user:platform_prod_password@db:5432/platform_prod_db
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||
# Явно передаём переменные почты из .env (иначе контейнер может не видеть их)
|
||||
- EMAIL_BACKEND=${EMAIL_BACKEND:-smtp}
|
||||
- EMAIL_HOST=${EMAIL_HOST}
|
||||
- EMAIL_PORT=${EMAIL_PORT:-2525}
|
||||
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-True}
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-False}
|
||||
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
|
||||
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
|
||||
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
||||
- EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10}
|
||||
# Ссылки в письмах (сброс пароля, подтверждение, приглашения) — без localhost
|
||||
- FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online}
|
||||
# Telegram бот (профиль: bot-info, привязка аккаунта)
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
|
||||
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||
- TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}
|
||||
ports:
|
||||
- "8124:8000"
|
||||
- "8123:8000"
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
|
|
@ -51,12 +78,128 @@ services:
|
|||
networks:
|
||||
- dev_network
|
||||
|
||||
celery:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: platform_prod_celery
|
||||
restart: unless-stopped
|
||||
user: "0:0"
|
||||
env_file: .env
|
||||
command: celery -A config worker -l info
|
||||
environment:
|
||||
- DEBUG=${DEBUG:-True}
|
||||
- DATABASE_URL=postgresql://platform_prod_user:platform_prod_password@db:5432/platform_prod_db
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||
- EMAIL_BACKEND=${EMAIL_BACKEND:-smtp}
|
||||
- EMAIL_HOST=${EMAIL_HOST}
|
||||
- EMAIL_PORT=${EMAIL_PORT:-2525}
|
||||
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-True}
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-False}
|
||||
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
|
||||
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
|
||||
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
||||
- EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
|
||||
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||
- TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
- web
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
celery-beat:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: platform_prod_celery_beat
|
||||
restart: unless-stopped
|
||||
user: "0:0"
|
||||
env_file: .env
|
||||
command: celery -A config beat -l info
|
||||
environment:
|
||||
- DEBUG=${DEBUG:-True}
|
||||
- DATABASE_URL=postgresql://platform_prod_user:platform_prod_password@db:5432/platform_prod_db
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- CELERY_BROKER_URL=redis://redis:6379/1
|
||||
- CELERY_RESULT_BACKEND=redis://redis:6379/2
|
||||
- EMAIL_BACKEND=${EMAIL_BACKEND:-smtp}
|
||||
- EMAIL_HOST=${EMAIL_HOST}
|
||||
- EMAIL_PORT=${EMAIL_PORT:-2525}
|
||||
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-True}
|
||||
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-False}
|
||||
- EMAIL_HOST_USER=${EMAIL_HOST_USER}
|
||||
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD}
|
||||
- DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL}
|
||||
- EMAIL_TIMEOUT=${EMAIL_TIMEOUT:-10}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-https://app.uchill.online}
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
|
||||
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||
- TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
- web
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
# Telegram бот (polling): получает /start, /link <код> и т.д. Если используете webhook — не поднимайте этот сервис.
|
||||
telegram-bot:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: platform_prod_telegram_bot
|
||||
restart: unless-stopped
|
||||
user: "0:0"
|
||||
env_file: .env
|
||||
command: python manage.py runtelegrambot
|
||||
environment:
|
||||
- DEBUG=${DEBUG:-True}
|
||||
- DATABASE_URL=postgresql://platform_prod_user:platform_prod_password@db:5432/platform_prod_db
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- TELEGRAM_USE_WEBHOOK=${TELEGRAM_USE_WEBHOOK:-False}
|
||||
- TELEGRAM_WEBHOOK_URL=${TELEGRAM_WEBHOOK_URL:-}
|
||||
- TELEGRAM_WEBHOOK_SECRET_TOKEN=${TELEGRAM_WEBHOOK_SECRET_TOKEN:-}
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
- web
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
container_name: platform_prod_livekit
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- LIVEKIT_KEYS=${LIVEKIT_API_KEY:-APIKeyPlatform2024Secret}:${LIVEKIT_API_SECRET:-ThisIsAVerySecureSecretKeyForPlatform2024VideoConf}
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881"
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: platform_dev_nginx
|
||||
container_name: platform_prod_nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:80"
|
||||
- "8084:80"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
|
|
@ -69,10 +212,21 @@ services:
|
|||
build:
|
||||
context: ./front_material
|
||||
dockerfile: Dockerfile
|
||||
container_name: platform_dev_front_material
|
||||
target: development
|
||||
container_name: platform_prod_front_material
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- WATCHPACK_POLLING=true
|
||||
- HOSTNAME=0.0.0.0
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
ports:
|
||||
- "3002:3000"
|
||||
- "3010:3000"
|
||||
volumes:
|
||||
- ./front_material:/app
|
||||
- front_material_node_modules:/app/node_modules
|
||||
- front_material_next:/app/.next
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
|
|
@ -80,10 +234,10 @@ services:
|
|||
build:
|
||||
context: ./yjs-whiteboard-server
|
||||
dockerfile: Dockerfile
|
||||
container_name: platform_dev_yjs_whiteboard
|
||||
container_name: platform_prod_yjs_whiteboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1235:1234"
|
||||
- "1236:1234"
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
|
|
@ -91,10 +245,10 @@ services:
|
|||
build:
|
||||
context: ./excalidraw-server
|
||||
dockerfile: Dockerfile
|
||||
container_name: platform_dev_excalidraw
|
||||
container_name: platform_prod_excalidraw
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3003:3001"
|
||||
- "3004:3001"
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
|
|
@ -102,16 +256,18 @@ services:
|
|||
build:
|
||||
context: ./whiteboard-server
|
||||
dockerfile: Dockerfile
|
||||
container_name: platform_dev_whiteboard
|
||||
container_name: platform_prod_whiteboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8082:8080"
|
||||
- "8083:8080"
|
||||
networks:
|
||||
- dev_network
|
||||
|
||||
volumes:
|
||||
dev_postgres_data:
|
||||
dev_redis_data:
|
||||
prod_postgres_data:
|
||||
prod_redis_data:
|
||||
front_material_node_modules:
|
||||
front_material_next:
|
||||
|
||||
networks:
|
||||
dev_network:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["server_key"] = "5c6ba6f0e2c106400cff5d5ede6b92d54cb4c542";
|
||||
["salt"] = "8dfb94a5-1800-4a6b-9759-d471a4195051";
|
||||
["iteration_count"] = 10000;
|
||||
["stored_key"] = "ead64b3f6ed500ec594070710ec1164acf72de28";
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["stored_key"] = "bc4b65242478d384b81701604c4ca2a9caf4f49f";
|
||||
["server_key"] = "3dab6265e494481c98ed07016c3362383f5e18af";
|
||||
["salt"] = "612f6256-9917-4ff6-b0ae-f166417342fc";
|
||||
["iteration_count"] = 10000;
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
return {
|
||||
[false] = {
|
||||
["pending"] = {};
|
||||
["version"] = 2;
|
||||
};
|
||||
["focus.meet.jitsi"] = {
|
||||
["groups"] = {};
|
||||
["subscription"] = "from";
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["IOklipPaqd0e"] = {
|
||||
["h"] = 20;
|
||||
["t"] = 1765397837;
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["86Nsc6lW_ExG"] = {
|
||||
["h"] = 22;
|
||||
["t"] = 1765397837;
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["2MUuXJqW06wx"] = {
|
||||
["h"] = 76;
|
||||
["t"] = 1765397765;
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["Egmzt3mnNFZm"] = {
|
||||
["t"] = 1765396098;
|
||||
["h"] = 391;
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["HxFnTU-GZE_C"] = {
|
||||
["t"] = 1765399806;
|
||||
["h"] = 24;
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["Zdp0KD3khbCQ"] = {
|
||||
["h"] = 113;
|
||||
["t"] = 1765397776;
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["EBWgvXb8H8bD"] = {
|
||||
["t"] = 1765396098;
|
||||
["h"] = 385;
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["5EclNF5SoDsw"] = {
|
||||
["t"] = 1765407413;
|
||||
["h"] = 2711;
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
return {
|
||||
["akU3gJyLh9SF"] = {
|
||||
["h"] = 39;
|
||||
["t"] = 1765397776;
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
# ==============================================
|
||||
# Nginx конфигурация для разработки
|
||||
# Nginx в контейнере (PROD: порт 8084 на хосте).
|
||||
# Upstream — по имени сервиса: web:8000, front_material:3000 (внутри сети).
|
||||
# ==============================================
|
||||
|
||||
# API Backend (Django) — default_server: сюда попадают запросы на localhost и api.localhost
|
||||
|
|
@ -122,10 +123,10 @@ server {
|
|||
}
|
||||
}
|
||||
|
||||
# Frontend (Next.js)
|
||||
# Frontend (Next.js) — app.localhost и app.uchill.online
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.localhost;
|
||||
server_name app.localhost app.uchill.online;
|
||||
|
||||
charset utf-8;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ ARG NEXT_PUBLIC_LIVEKIT_URL
|
|||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV NEXT_PUBLIC_LIVEKIT_URL=$NEXT_PUBLIC_LIVEKIT_URL
|
||||
ENV NODE_ENV=development
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV WATCHPACK_POLLING=true
|
||||
ENV CHOKIDAR_USEPOLLING=true
|
||||
|
||||
# Копируем package files
|
||||
COPY package*.json ./
|
||||
|
|
@ -20,14 +24,20 @@ COPY package*.json ./
|
|||
# Устанавливаем зависимости
|
||||
RUN npm install
|
||||
|
||||
# Копируем остальные файлы
|
||||
# Копируем остальные файлы (при запуске в compose поверх монтируется volume)
|
||||
COPY . .
|
||||
|
||||
# Entrypoint: при volume-монтировании /app/node_modules при первом запуске пуст — ставим зависимости
|
||||
RUN echo '#!/bin/sh' > /entrypoint.sh && \
|
||||
echo 'set -e' >> /entrypoint.sh && \
|
||||
echo 'if [ ! -d node_modules/next ] 2>/dev/null || [ ! -f node_modules/.package-lock.json ] 2>/dev/null; then npm install; fi' >> /entrypoint.sh && \
|
||||
echo 'exec npx next dev --webpack --hostname 0.0.0.0' >> /entrypoint.sh && \
|
||||
chmod +x /entrypoint.sh
|
||||
|
||||
# Открываем порт
|
||||
EXPOSE 3000
|
||||
|
||||
# Запускаем dev server с Turbopack
|
||||
CMD ["npm", "run", "dev"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Production dependencies stage
|
||||
FROM node:20-alpine AS production-deps
|
||||
|
|
|
|||
|
|
@ -20,3 +20,19 @@ export async function getActiveSubscription(): Promise<Subscription | null> {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActivateFreeParams {
|
||||
plan_id: number;
|
||||
duration_days?: number;
|
||||
student_count?: number;
|
||||
}
|
||||
|
||||
/** Активировать бесплатный тариф (цена 0) без создания платежа */
|
||||
export async function activateFreeSubscription(params: ActivateFreeParams): Promise<{ success: boolean; subscription: Subscription }> {
|
||||
const url = '/subscriptions/subscriptions/activate_free/';
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('[API] POST', url, params);
|
||||
}
|
||||
const response = await apiClient.post<{ success: boolean; subscription: Subscription }>(url, params);
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { login } from '@/api/auth';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getErrorMessage } from '@/lib/error-utils';
|
||||
|
|
@ -138,6 +139,7 @@ export default function LoginPage() {
|
|||
>
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</md-filled-button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -148,17 +150,27 @@ export default function LoginPage() {
|
|||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<md-text-button
|
||||
onClick={() => router.push('/forgot-password')}
|
||||
style={{ fontSize: '14px' }}
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--md-sys-color-primary, #6750a4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Забыли пароль?
|
||||
</md-text-button>
|
||||
<md-text-button onClick={() => router.push('/register')} style={{ fontSize: '14px' }}>
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--md-sys-color-primary, #6750a4)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
Нет аккаунта? Зарегистрироваться
|
||||
</md-text-button>
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ export default function RegisterPage() {
|
|||
const [consent, setConsent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||
const [registeredEmail, setRegisteredEmail] = useState('');
|
||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||
const roleSelectRef = useRef<HTMLElement & { value: string } | null>(null);
|
||||
|
||||
|
|
@ -128,7 +130,7 @@ export default function RegisterPage() {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await register({
|
||||
await register({
|
||||
email,
|
||||
password,
|
||||
password_confirm: confirmPassword,
|
||||
|
|
@ -139,21 +141,10 @@ export default function RegisterPage() {
|
|||
timezone: getTimezoneForSubmit(),
|
||||
});
|
||||
|
||||
if (response.access) {
|
||||
localStorage.setItem('access_token', response.access);
|
||||
if (response.refresh) {
|
||||
localStorage.setItem('refresh_token', response.refresh);
|
||||
}
|
||||
if (referralCode.trim()) {
|
||||
try {
|
||||
await setReferrer(referralCode.trim());
|
||||
} catch (_) {
|
||||
// не блокируем вход при ошибке реферального кода
|
||||
}
|
||||
}
|
||||
window.location.href = '/dashboard';
|
||||
// Не авторизуем сразу — требуется подтверждение email
|
||||
setRegisteredEmail(email);
|
||||
setRegistrationSuccess(true);
|
||||
return;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err.response?.data?.detail ||
|
||||
|
|
@ -185,6 +176,57 @@ export default function RegisterPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (registrationSuccess) {
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginBottom: '28px' }}>
|
||||
Регистрация
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
padding: '24px',
|
||||
background: 'var(--md-sys-color-surface-container, #f5f5f5)',
|
||||
borderRadius: '16px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
color: 'var(--md-sys-color-on-surface, #1a1a1a)',
|
||||
lineHeight: 1.5,
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
На адрес <strong>{registeredEmail}</strong> отправлено письмо с ссылкой для подтверждения.
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'var(--md-sys-color-on-surface-variant, #666)',
|
||||
lineHeight: 1.5,
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
Перейдите по ссылке из письма, затем войдите в аккаунт.
|
||||
</p>
|
||||
<md-filled-button
|
||||
onClick={() => router.push('/login')}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '48px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
Вернуться ко входу
|
||||
</md-filled-button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', maxWidth: '400px' }}>
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
|||
import { NavBadgesProvider } from '@/contexts/NavBadgesContext';
|
||||
import { SelectedChildProvider } from '@/contexts/SelectedChildContext';
|
||||
import { getNavBadges } from '@/api/navBadges';
|
||||
import { getActiveSubscription } from '@/api/subscriptions';
|
||||
import type { NavBadges } from '@/api/navBadges';
|
||||
|
||||
export default function ProtectedLayout({
|
||||
|
|
@ -21,6 +22,7 @@ export default function ProtectedLayout({
|
|||
const pathname = usePathname();
|
||||
const { user, loading } = useAuth();
|
||||
const [navBadges, setNavBadges] = useState<NavBadges | null>(null);
|
||||
const [subscriptionChecked, setSubscriptionChecked] = useState(false);
|
||||
|
||||
const refreshNavBadges = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -36,6 +38,26 @@ export default function ProtectedLayout({
|
|||
refreshNavBadges();
|
||||
}, [user, refreshNavBadges]);
|
||||
|
||||
// Для ментора: редирект на /payment, если нет активной подписки (кроме самой страницы /payment)
|
||||
useEffect(() => {
|
||||
if (!user || user.role !== 'mentor' || pathname === '/payment') {
|
||||
if (user?.role === 'mentor' && pathname === '/payment') setSubscriptionChecked(true);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setSubscriptionChecked(false);
|
||||
getActiveSubscription()
|
||||
.then((sub) => {
|
||||
if (cancelled) return;
|
||||
setSubscriptionChecked(true);
|
||||
if (!sub) router.replace('/payment');
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSubscriptionChecked(true);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [user, pathname, router]);
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем токен в localStorage напрямую, чтобы избежать race condition
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null;
|
||||
|
|
@ -68,6 +90,17 @@ export default function ProtectedLayout({
|
|||
return null;
|
||||
}
|
||||
|
||||
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
|
||||
const isMentorCheckingSubscription =
|
||||
user.role === 'mentor' && pathname !== '/payment' && !subscriptionChecked;
|
||||
if (isMentorCheckingSubscription) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Не показываем навигацию на страницах авторизации
|
||||
if (pathname?.startsWith('/login') || pathname?.startsWith('/register')) {
|
||||
return <>{children}</>;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import apiClient from '@/lib/api-client';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import Link from 'next/link';
|
||||
import { activateFreeSubscription, getActiveSubscription } from '@/api/subscriptions';
|
||||
|
||||
/** Подписи преимуществ из plan.features (API) */
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
|
|
@ -50,6 +51,15 @@ function getPlanDescription(plan: any): string {
|
|||
return 'Ежемесячная подписка без ограничений по количеству учеников. Все функции доступны.';
|
||||
}
|
||||
|
||||
function isFreePlan(plan: any): boolean {
|
||||
const price = Number(plan.price) || 0;
|
||||
const pricePerStudent = Number(plan.price_per_student) ?? 0;
|
||||
if (plan.subscription_type === 'per_student') {
|
||||
return pricePerStudent === 0;
|
||||
}
|
||||
return price === 0;
|
||||
}
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
|
||||
<path d="M16.7071 5.29289C17.0976 5.68342 17.0976 6.31658 16.7071 6.70711L8.70711 14.7071C8.31658 15.0976 7.68342 15.0976 7.29289 14.7071L3.29289 10.7071C2.90237 10.3166 2.90237 9.68342 3.29289 9.29289C3.68342 8.90237 4.31658 8.90237 4.70711 9.29289L8 12.5858L15.2929 5.29289C15.6834 4.90237 16.3166 4.90237 16.7071 5.29289Z" fill="currentColor" />
|
||||
|
|
@ -60,13 +70,14 @@ export function ProfilePaymentTab() {
|
|||
const [plans, setPlans] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [subscription, setSubscription] = useState<any>(null);
|
||||
const [activatingPlanId, setActivatingPlanId] = useState<number | null>(null);
|
||||
const [activateError, setActivateError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [plansRes, subRes] = await Promise.all([
|
||||
apiClient.get<any>('/subscriptions/plans/').then((r) => r.data?.results || r.data || []),
|
||||
apiClient.get<any>('/subscriptions/subscriptions/active/').then((r) => r.data).catch(() => null),
|
||||
getActiveSubscription(),
|
||||
]);
|
||||
setPlans(Array.isArray(plansRes) ? plansRes : []);
|
||||
setSubscription(subRes);
|
||||
|
|
@ -75,10 +86,45 @@ export function ProfilePaymentTab() {
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleActivateFree = async (plan: any) => {
|
||||
setActivateError(null);
|
||||
setActivatingPlanId(plan.id);
|
||||
const body = {
|
||||
plan_id: plan.id,
|
||||
duration_days: 30,
|
||||
student_count: plan.subscription_type === 'per_student' ? 1 : undefined,
|
||||
};
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('[Subscription] Отправка запроса activate_free:', body);
|
||||
}
|
||||
try {
|
||||
await activateFreeSubscription(body);
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('[Subscription] activate_free успешно');
|
||||
}
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
if (typeof window !== 'undefined') {
|
||||
console.log('[Subscription] activate_free ошибка:', err.response?.status, err.response?.data);
|
||||
}
|
||||
const data = err.response?.data;
|
||||
const message =
|
||||
(typeof data?.error === 'string' && data.error) ||
|
||||
(typeof data?.detail === 'string' && data.detail) ||
|
||||
(Array.isArray(data?.detail) ? data.detail[0] : null) ||
|
||||
'Не удалось активировать подписку';
|
||||
setActivateError(message);
|
||||
} finally {
|
||||
setActivatingPlanId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSpinner size="medium" />;
|
||||
}
|
||||
|
|
@ -104,11 +150,17 @@ export function ProfilePaymentTab() {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{activateError && (
|
||||
<p style={{ color: 'var(--md-sys-color-error)', fontSize: 14, marginBottom: 12 }}>
|
||||
{activateError}
|
||||
</p>
|
||||
)}
|
||||
<span className="ios26-payment-tab__label">ТАРИФНЫЕ ПЛАНЫ</span>
|
||||
<div className="ios26-plan-card-grid">
|
||||
{plans.slice(0, 5).map((plan: any) => {
|
||||
const benefits = getBenefitList(plan);
|
||||
const description = getPlanDescription(plan);
|
||||
const free = isFreePlan(plan);
|
||||
const priceText = plan.price_per_student
|
||||
? `${Math.round(plan.price_per_student || 0).toLocaleString('ru-RU')} ₽/уч.`
|
||||
: `${Math.round(plan.price || 0).toLocaleString('ru-RU')} ₽/мес`;
|
||||
|
|
@ -135,9 +187,21 @@ export function ProfilePaymentTab() {
|
|||
))}
|
||||
</ul>
|
||||
<div className="ios26-plan-card__actions">
|
||||
{free ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ios26-plan-card__action"
|
||||
onClick={() => handleActivateFree(plan)}
|
||||
disabled={!!activatingPlanId}
|
||||
style={{ cursor: activatingPlanId ? 'wait' : 'pointer' }}
|
||||
>
|
||||
{activatingPlanId === plan.id ? 'Активация...' : 'Активировать'}
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/payment" className="ios26-plan-card__action">
|
||||
Подробнее и оплатить
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,12 +31,13 @@ const nextConfig = {
|
|||
|
||||
// Оптимизация webpack
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
// Hot reload для development
|
||||
// Hot reload для development (в т.ч. в Docker с volume)
|
||||
if (dev) {
|
||||
config.watchOptions = {
|
||||
...config.watchOptions,
|
||||
poll: 1000,
|
||||
poll: process.env.WATCHPACK_POLLING ? 500 : 1000,
|
||||
aggregateTimeout: 300,
|
||||
ignored: ['**/node_modules', '**/.git'],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue