Daphne (ASGI) для WebSocket, подписки, email, регистрация, mentor gate, telegram-bot
Deploy to Dev / deploy-dev (push) Failing after 3s Details
Deploy to Production / deploy-production (push) Successful in 26s Details

This commit is contained in:
root 2026-02-13 02:58:25 +03:00
parent e3517b39ff
commit 6420a9b182
28 changed files with 2984 additions and 1237 deletions

104
backend/BACKEND.md Normal file
View File

@ -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 часто не нужен при обычном перезапуске.

View File

@ -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 и пароль приложения '
'(Настройки → Безопасность → Пароли для внешних приложений).'
)

View File

@ -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)

View File

@ -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):
"""

View File

@ -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)
import os
possible_paths = [
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
if cities_data is None:
if cities_data is None and csv_path:
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
]
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 файл
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 = []
# Префиксы типов населенных пунктов, которые нужно убирать при поиске

File diff suppressed because it is too large Load Diff

View File

@ -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'))

View File

@ -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:

View File

@ -0,0 +1,6 @@
return {
["server_key"] = "5c6ba6f0e2c106400cff5d5ede6b92d54cb4c542";
["salt"] = "8dfb94a5-1800-4a6b-9759-d471a4195051";
["iteration_count"] = 10000;
["stored_key"] = "ead64b3f6ed500ec594070710ec1164acf72de28";
};

View File

@ -0,0 +1,6 @@
return {
["stored_key"] = "bc4b65242478d384b81701604c4ca2a9caf4f49f";
["server_key"] = "3dab6265e494481c98ed07016c3362383f5e18af";
["salt"] = "612f6256-9917-4ff6-b0ae-f166417342fc";
["iteration_count"] = 10000;
};

View File

@ -0,0 +1,10 @@
return {
[false] = {
["pending"] = {};
["version"] = 2;
};
["focus.meet.jitsi"] = {
["groups"] = {};
["subscription"] = "from";
};
};

View File

@ -0,0 +1,6 @@
return {
["IOklipPaqd0e"] = {
["h"] = 20;
["t"] = 1765397837;
};
};

View File

@ -0,0 +1,6 @@
return {
["86Nsc6lW_ExG"] = {
["h"] = 22;
["t"] = 1765397837;
};
};

View File

@ -0,0 +1,6 @@
return {
["2MUuXJqW06wx"] = {
["h"] = 76;
["t"] = 1765397765;
};
};

View File

@ -0,0 +1,6 @@
return {
["Egmzt3mnNFZm"] = {
["t"] = 1765396098;
["h"] = 391;
};
};

View File

@ -0,0 +1,6 @@
return {
["HxFnTU-GZE_C"] = {
["t"] = 1765399806;
["h"] = 24;
};
};

View File

@ -0,0 +1,6 @@
return {
["Zdp0KD3khbCQ"] = {
["h"] = 113;
["t"] = 1765397776;
};
};

View File

@ -0,0 +1,6 @@
return {
["EBWgvXb8H8bD"] = {
["t"] = 1765396098;
["h"] = 385;
};
};

View File

@ -0,0 +1,6 @@
return {
["5EclNF5SoDsw"] = {
["t"] = 1765407413;
["h"] = 2711;
};
};

View File

@ -0,0 +1,6 @@
return {
["akU3gJyLh9SF"] = {
["h"] = 39;
["t"] = 1765397776;
};
};

View File

@ -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;

View File

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

View File

@ -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;
}

View File

@ -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,27 +139,38 @@ export default function LoginPage() {
>
{loading ? 'Вход...' : 'Войти'}
</md-filled-button>
</form>
<div
<div
style={{
textAlign: 'center',
marginTop: '20px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<Link
href="/forgot-password"
style={{
textAlign: 'center',
marginTop: '20px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
fontSize: '14px',
color: 'var(--md-sys-color-primary, #6750a4)',
textDecoration: 'none',
}}
>
<md-text-button
onClick={() => router.push('/forgot-password')}
style={{ fontSize: '14px' }}
>
Забыли пароль?
</md-text-button>
<md-text-button onClick={() => router.push('/register')} style={{ fontSize: '14px' }}>
Нет аккаунта? Зарегистрироваться
</md-text-button>
</div>
</form>
Забыли пароль?
</Link>
<Link
href="/register"
style={{
fontSize: '14px',
color: 'var(--md-sys-color-primary, #6750a4)',
textDecoration: 'none',
}}
>
Нет аккаунта? Зарегистрироваться
</Link>
</div>
</div>
);
}

View File

@ -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';
return;
}
// Не авторизуем сразу — требуется подтверждение 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' }}>

View File

@ -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}</>;

View File

@ -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,24 +70,60 @@ 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);
const loadData = useCallback(async () => {
try {
const [plansRes, subRes] = await Promise.all([
apiClient.get<any>('/subscriptions/plans/').then((r) => r.data?.results || r.data || []),
getActiveSubscription(),
]);
setPlans(Array.isArray(plansRes) ? plansRes : []);
setSubscription(subRes);
} catch {
setPlans([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const load = 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),
]);
setPlans(Array.isArray(plansRes) ? plansRes : []);
setSubscription(subRes);
} catch {
setPlans([]);
} finally {
setLoading(false);
}
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,
};
load();
}, []);
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">
<Link href="/payment" className="ios26-plan-card__action">
Подробнее и оплатить
</Link>
{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>
);

View File

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