commit cbfbd8652d2f4541aebebc07f11feeb1dc642418 Author: Aleksandr Meshchriakov Date: Mon Jan 19 14:12:33 2026 +0100 feat: Add comprehensive Django user app with tests using model-bakery - Implemented user authentication with JWT tokens - Added user and profile models with OneToOne relationship - Created service layer for business logic separation - Implemented DRF serializers and views - Added comprehensive test suite with model-bakery factories - Fixed ipdb/pdbpp dependency conflicts with custom test runner - Configured development and production environments - Added deployment configurations for Apache, systemd, and Docker diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f3fe2f2 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Файл окружения для разработки +# Скопируйте этот файл в .env и измените значения по необходимости + +# Django Settings +DEBUG=True +SECRET_KEY=django-insecure-development-key-change-in-production +ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + +# Database Settings +POSTGRES_DB=project_dev +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 + +# Redis Settings +REDIS_URL=redis://localhost:6379/0 +REDIS_CACHE_URL=redis://localhost:6379/1 + +# Celery Settings +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 + +# CORS Settings +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + +# Logging +LOG_LEVEL=INFO + +# Scrapy Settings +SCRAPY_LOG_LEVEL=INFO \ No newline at end of file diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..8d1be55 --- /dev/null +++ b/.env.test @@ -0,0 +1,25 @@ +# Test environment for user app +DEBUG=True +SECRET_KEY=test-secret-key-for-development +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Database Settings - using existing tenant_db container +POSTGRES_DB=project_dev +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_HOST=localhost +POSTGRES_PORT=8432 # social_db container port + +# Redis Settings +REDIS_URL=redis://localhost:6379/0 +REDIS_CACHE_URL=redis://localhost:6379/1 + +# Celery Settings +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 + +# CORS Settings +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 + +# Logging +LOG_LEVEL=INFO \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a5bd201 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +*.pyc +*.pyo +*.pyc +__pycache__/ +.pytest_cache/ +.coverage +htmlcov/ +*.egg-info/ +dist/ +build/ +*.log +.env +.venv +venv/ +uv.lock +.env.local +.env.*.local + +# Django +*.sqlite3 +media/ +staticfiles/ +logs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Docker +.dockerignore + +# Backup files +*.bak +*.backup \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e831679 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.14 + hooks: + - id: ruff + name: ruff lint (src only) + files: ^src/.*\.py$ + args: [--config=ruff.toml, --fix, --exit-non-zero-on-fix] + exclude: | + (?x)^( + src/.*/migrations/.*| + src/.*/__pycache__/.* + )$ + + - id: ruff-format + name: ruff format (src only) + files: ^src/.*\.py$ + args: [--config=ruff.toml] + exclude: | + (?x)^( + src/.*/migrations/.*| + src/.*/__pycache__/.* + )$ + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + name: check trailing whitespace (src only) + files: ^src/.*\.(py|txt|md|yaml|yml)$ + + - id: end-of-file-fixer + name: fix end of file (src only) + files: ^src/.*\.(py|txt|md|yaml|yml)$ + + - id: check-yaml + name: check yaml syntax (src only) + files: ^src/.*\.ya?ml$ + + - id: check-added-large-files + name: check large files + args: ['--maxkb=500'] + + - repo: local + hooks: + - id: django-check-migrations + name: django check migrations + entry: ./scripts/check-migrations.sh + language: script + files: ^src/.*\.py$ + pass_filenames: false + exclude: | + (?x)^( + src/.*/migrations/.*| + src/.*/__pycache__/.* + )$ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c00391b --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11.14 \ No newline at end of file diff --git a/.qoder/rules/main.md b/.qoder/rules/main.md new file mode 100644 index 0000000..cd92a0e --- /dev/null +++ b/.qoder/rules/main.md @@ -0,0 +1,268 @@ +--- +trigger: always_on +--- +--- +trigger: always_on +--- + +## 0) Язык и стиль общения (СТРОГО) +- ИИ-агент **ВСЕГДА отвечает на русском языке** +- Английский допускается ТОЛЬКО для: + - имён библиотек + - имён классов, функций, переменных + - CLI-команд +- Тон: инженерный, практичный, без воды и маркетинга + +--- + +## 1) Базовые принципы (НЕ ОБСУЖДАЮТСЯ) +Проект разрабатывается строго по принципам: + +- **SOLID** +- **KISS** +- **DRY** + +Правила приоритета: +- красиво vs просто → **простота** +- умно vs поддерживаемо → **поддерживаемость** +- магия vs явность → **явность** + +--- + +## 2) Контекст проекта +- ОС: **Astra Linux 1.8** +- Python: **3.11.2** +- Django: **3.x (указано 3.14)** +- Django REST Framework (DRF) +- Celery +- PostgreSQL **15.10** +- Apache **2.4.57** +- mod_wsgi **4.9.4** + +### Инструменты разработки +- **uv** +- **виртуальная среда** +- **pre-commit** +- **Gitea Actions (CI)** +- Тесты: `django test` +- Линтинг: `ruff` +- **ЗАПРЕЩЕНО** создавать тестовые скрипты для демонстрации, все должно быть исправлено в рамках проекта + +--- + +## 3) Окружение и команды (СТРОГО) +Все команды: +- выполняются **только внутри виртуальной среды** +- используют **uv** +- считаются выполняемыми из корня проекта + +### ❌ Запрещено +- `pip`, `python -m pip` +- `poetry`, `pipenv`, `pipx` +- системные команды вне venv + +### ✅ Разрешено +- `uv venv` +- `source .venv/bin/activate` +- `uv add / uv remove / uv sync` +- `uv run ` + +Пример: +```bash +uv run python manage.py test +``` + +--- + +## 4) Архитектура и слои ответственности (КРИТИЧНО) + +### 4.1 View (DRF) +View отвечает ТОЛЬКО за: +- приём HTTP-запроса +- проверку прав доступа +- работу с serializer +- вызов сервисного слоя + +❌ Запрещено: +- бизнес-логика +- сложные условия +- транзакции +- сложная работа с ORM + +--- + +### 4.2 Serializer +Serializer отвечает за: +- валидацию данных +- преобразование вход/выход + +Допускается: +- field-level validation +- object-level validation + +❌ Запрещено: +- бизнес-правила +- side-effects +- сложная логика в `save()` + +--- + +### 4.3 Сервисный слой (Business Logic) +- **ВСЯ бизнес-логика живёт здесь** +- Сервисы: + - не зависят от HTTP + - легко тестируются + - управляют транзакциями +- Сервис определяет *что* делать, а не *как* отдать ответ + +Рекомендуемый паттерн: +```python +class EntityService: + @classmethod + def do_something(cls, *, data): + ... +``` + +--- + +### 4.4 Модели (ORM) +Модели должны быть: +- простыми +- декларативными + +Допускается: +- `__str__` +- простые computed properties +- минимальные helper-методы + +❌ Запрещено: +- бизнес-логика +- workflow +- сигналы как логика +- условия, зависящие от сценариев + +👉 **Любые исключения — ТОЛЬКО после обсуждения в чате.** + +--- + +## 5) Celery +- Task = **thin wrapper** +- Task вызывает сервис, а не содержит логику +- Таски: + - идемпотентны + - логируют начало и завершение +- Ретраи: + - только для временных ошибок + - с backoff + +--- + +## 6) База данных и миграции +- Любое изменение моделей → миграции обязательны +- Миграции: + - детерминированные + - без ручной магии без причины + +Проверка перед коммитом: +```bash +uv run python manage.py makemigrations --check --dry-run +``` + +PostgreSQL: +- транзакции использовать осознанно +- `select_for_update()` при гонках +- Raw SQL — только с объяснением + +--- + +## 7) Тестирование +- Любая бизнес-логика → тесты +- В первую очередь тестируется сервисный слой +- API — happy path + edge cases + +Запуск: +```bash +uv run python manage.py test +``` + +--- + +## 8) pre-commit (обязателен) +- Любой код обязан проходить pre-commit +- Агент обязан учитывать проверки форматирования и линтинга + +```bash +pre-commit run --all-files +``` + +--- + +## 9) CI (Gitea Actions) +- Используется **Gitea Actions** +- ❌ GitHub Actions запрещены +- Любые изменения: + - не должны ломать CI +- Если меняются: + - зависимости + - команды тестов + - миграции + → агент обязан указать необходимость правок workflow + +--- + +## 10) Apache + mod_wsgi +- Используется **ТОЛЬКО WSGI** +- ASGI запрещён без отдельного обсуждения +- Любые изменения в `wsgi.py`, путях, статике: + - сопровождаются пояснением + - требуют перезапуска Apache + +```bash +systemctl restart apache2 +``` + +Учитывать ограничения и права Astra Linux. + +--- + +## 11) Работа с репозиторием +- Минимальный diff — приоритет +- ❌ Не коммитить: + - `.venv` + - артефакты + - дампы БД +- Массовый рефакторинг — только по явному запросу + +--- + +## 12) Anti-patterns (ЗАПРЕЩЕНО) +- Fat Models +- God Views +- Бизнес-логика в Serializers +- Сигналы как workflow +- Магия в `save()` +- Прямые импорты моделей между apps +- Сложная логика в queryset как бизнес-правило + +--- + +## 13) Формат ответа ИИ-агента (ОБЯЗАТЕЛЬНЫЙ) +Каждый ответ должен содержать: + +1. **Что меняем** +2. **Файлы / патч** +3. **Команды (через uv)** +4. **Проверки (tests / pre-commit / CI)** +5. **Риски / замечания** + +--- + +## 14) Исключения +- ИИ-агент **НЕ внедряет исключения сам** +- Агент: + - описывает стандартное решение + - объясняет, почему оно не подходит + - запрашивает разрешение в чате + +## 15) Структура проекта +- diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2cbdd30 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +# Makefile для удобной работы с проектом + +.PHONY: help install dev-up dev-down test lint format migrate createsuperuser shell + +help: + @echo "Доступные команды:" + @echo " make install - Установка зависимостей" + @echo " make dev-up - Запуск разработческого окружения (Docker)" + @echo " make dev-down - Остановка разработческого окружения" + @echo " make migrate - Выполнение миграций Django" + @echo " make createsuperuser - Создание суперпользователя" + @echo " make test - Запуск тестов" + @echo " make lint - Проверка кода линтерами" + @echo " make format - Форматирование кода" + @echo " make shell - Запуск Django shell" + @echo " make logs - Просмотр логов (Docker)" + @echo " make clean - Очистка временных файлов" + +install: + uv pip install -r requirements.txt + uv pip install -r requirements-dev.txt + +dev-up: + docker-compose up -d + @echo "Сервисы запущены. Приложение доступно по адресу: http://localhost:8000" + +dev-down: + docker-compose down + +test: + pytest src/ -v + +lint: + flake8 src/ + black --check src/ + isort --check-only src/ + +format: + black src/ + isort src/ + flake8 src/ + +migrate: + cd src && python manage.py makemigrations + cd src && python manage.py migrate + +createsuperuser: + cd src && python manage.py createsuperuser + +shell: + cd src && python manage.py shell + +logs: + docker-compose logs -f + +clean: + find . -type f -name "*.pyc" -delete + find . -type d -name "__pycache__" -delete + rm -rf *.log + rm -rf htmlcov/ + rm -rf .coverage + rm -rf .pytest_cache/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d672c80 --- /dev/null +++ b/README.md @@ -0,0 +1,255 @@ +# Django ETL Boilerplate + +Шаблон Django приложения для ETL (Extract, Transform, Load) операций с функциями веб-скрапинга. + +## Технологический стек + +- **Python**: 3.11.2 +- **Django**: 3.2.25 +- **Django REST Framework**: 3.14.0 +- **PostgreSQL**: 15.10 +- **Redis**: 7.x +- **Celery**: 5.3.6 +- **Scrapy**: 2.11.2 +- **Gunicorn**: 21.2.0 +- **Apache**: 2.4.57 + +## Структура проекта + +``` +src/ +├── config/ # Конфигурация Django +│ ├── settings/ # Настройки (base, dev, prod) +│ ├── celery.py # Конфигурация Celery +│ └── urls.py # URL маршруты +├── apps/ +│ ├── data_processor/ # Приложение обработки данных +│ ├── scraping/ # Приложение веб-скрапинга +│ └── api/ # API endpoints +└── manage.py # Управление Django + +docker/ # Docker конфигурации +deploy/ # Файлы развертывания +requirements.txt # Основные зависимости +requirements-dev.txt # Зависимости для разработки +``` + +## Быстрый старт (локальная разработка) + +### 1. Установка зависимостей + +```bash +# Установка uv (если не установлен) +curl -LsSf https://astral.sh/uv/install.sh | sh +source $HOME/.cargo/env + +# Создание виртуального окружения с uv +uv venv venv +source venv/bin/activate + +# Установка зависимостей через uv +uv pip install -r requirements.txt +uv pip install -r requirements-dev.txt +``` + +### 2. Настройка окружения + +```bash +# Копирование файла окружения +cp .env.example .env + +# Редактирование .env файла по необходимости +nano .env +``` + +### 3. Запуск с Docker Compose (рекомендуется) + +```bash +# Запуск всех сервисов +docker-compose up -d + +# Проверка состояния контейнеров +docker-compose ps + +# Просмотр логов +docker-compose logs -f web +``` + +### 4. Ручная настройка (без Docker) + +#### Запуск баз данных: +```bash +# PostgreSQL +sudo systemctl start postgresql + +# Redis +sudo systemctl start redis +``` + +#### Миграции и запуск: +```bash +cd src + +# Миграции +python manage.py makemigrations +python manage.py migrate + +# Создание суперпользователя +python manage.py createsuperuser + +# Запуск разработческого сервера +python manage.py runserver + +# Запуск Celery worker (в отдельном терминале) +celery -A config worker --loglevel=info + +# Запуск Celery beat (в отдельном терминале) +celery -A config beat --loglevel=info +``` + +## API Endpoints + +Основной префикс: `/api/` + +### Data Processor +- `GET/POST /api/data-sources/` - Источники данных +- `GET/POST /api/data-pipelines/` - ETL пайплайны +- `GET /api/extracted-data/` - Извлеченные данные +- `GET /api/processing-logs/` - Логи обработки + +### Web Scraping +- `GET/POST /api/scraping-jobs/` - Задачи скрапинга +- `GET /api/scraped-items/` - Скрапленные данные +- `GET/POST /api/spider-configurations/` - Конфигурации пауков +- `GET/POST /api/proxy-servers/` - Прокси сервера + +### Аутентификация +- `POST /api/api-token-auth/` - Получение API токена + +## Развертывание на сервере Astra Linux + +### Автоматическое развертывание + +```bash +# Сделать скрипт исполняемым +chmod +x deploy/scripts/deploy.sh + +# Запуск скрипта развертывания +sudo ./deploy/scripts/deploy.sh +``` + +### Ручное развертывание + +1. **Установка системных зависимостей:** +```bash +sudo apt-get update +sudo apt-get install python3.11 python3.11-venv postgresql-15 redis-server nginx +``` + +2. **Настройка проекта:** +```bash +# Клонирование репозитория +git clone ваш_репозиторий.git /var/www/project +cd /var/www/project + +# Создание виртуального окружения +python3.11 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Настройка базы данных +sudo -u postgres psql -c "CREATE DATABASE project_prod;" +sudo -u postgres psql -c "CREATE USER project_user WITH PASSWORD 'secure_password';" +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE project_prod TO project_user;" +``` + +3. **Конфигурация systemd:** +```bash +sudo cp deploy/systemd/*.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable gunicorn celery-worker celery-beat +``` + +4. **Настройка Apache:** +```bash +sudo cp deploy/apache/project.conf /etc/apache2/sites-available/project.conf +sudo a2ensite project.conf +sudo a2enmod ssl rewrite headers expires +sudo a2dissite 000-default +sudo systemctl restart apache2 +``` + +## Мониторинг и логирование + +### Логи приложения +```bash +# Логи Django +tail -f logs/django.log + +# Логи Celery +tail -f logs/celery.log + +# Системные логи +journalctl -u gunicorn -f +journalctl -u celery-worker -f +``` + +### Мониторинг Celery +```bash +# Запуск Flower (в отдельном терминале) +celery -A config flower + +# Доступ через браузер: http://localhost:5555 +``` + +## Разработка + +### Запуск тестов +```bash +# Запуск всех тестов +pytest + +# Запуск с покрытием +pytest --cov=src + +# Запуск линтеров +flake8 src/ +black src/ +``` + +### Создание миграций +```bash +python manage.py makemigrations +python manage.py migrate +``` + +### Работа с задачами Celery +```python +# В коде Python +from apps.data_processor.tasks import process_extracted_data +from apps.scraping.tasks import run_scraping_job + +# Запуск асинхронно +result = process_extracted_data.delay() +print(result.id) # ID задачи +``` + +## Безопасность + +- Все секретные ключи хранятся в переменных окружения +- Используется HTTPS в продакшене +- Настроены заголовки безопасности в Apache +- Регулярное обновление зависимостей + +## Поддержка + +Для вопросов и поддержки обращайтесь к документации Django и используемым библиотекам: + +- [Django Documentation](https://docs.djangoproject.com/) +- [Celery Documentation](https://docs.celeryproject.org/) +- [Scrapy Documentation](https://docs.scrapy.org/) +- [Django REST Framework](https://www.django-rest-framework.org/) + +## Лицензия + +MIT License \ No newline at end of file diff --git a/check_tests.py b/check_tests.py new file mode 100644 index 0000000..6c7ba96 --- /dev/null +++ b/check_tests.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +"""Проверка тестовой среды""" + +import os +import sys +import django + +# Настройка Django +sys.path.append(os.path.join(os.getcwd(), 'src')) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') +django.setup() + +print("✅ Django настроен успешно!") + +# Проверка импортов +try: + from apps.user.tests.test_views import * + print("✅ test_views импортирован успешно!") +except Exception as e: + print(f"❌ Ошибка импорта test_views: {e}") + +try: + from apps.user.tests.test_models import * + print("✅ test_models импортирован успешно!") +except Exception as e: + print(f"❌ Ошибка импорта test_models: {e}") + +try: + from apps.user.tests.test_serializers import * + print("✅ test_serializers импортирован успешно!") +except Exception as e: + print(f"❌ Ошибка импорта test_serializers: {e}") + +try: + from apps.user.tests.test_services import * + print("✅ test_services импортирован успешно!") +except Exception as e: + print(f"❌ Ошибка импорта test_services: {e}") + +try: + from apps.user.tests.factories import UserFactory, ProfileFactory + print("✅ factories импортированы успешно!") + + # Тест создания объектов + user = UserFactory.create_user() + print(f"✅ Создан пользователь: {user.username}") + + profile = ProfileFactory.create_profile() + print(f"✅ Создан профиль: {profile.full_name}") + +except Exception as e: + print(f"❌ Ошибка работы с фабриками: {e}") + +print("\n🏁 Проверка завершена!") \ No newline at end of file diff --git a/deploy/apache/project.conf b/deploy/apache/project.conf new file mode 100644 index 0000000..73d7703 --- /dev/null +++ b/deploy/apache/project.conf @@ -0,0 +1,80 @@ +# Конфигурация Apache 2.4.57 для Django приложения +# Разместить в /etc/apache2/sites-available/project.conf + + + ServerName your-domain.com + ServerAlias www.your-domain.com + + # Редирект на HTTPS + RewriteEngine On + RewriteCond %{HTTPS} off + RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + + + + ServerName your-domain.com + ServerAlias www.your-domain.com + + # SSL конфигурация + SSLEngine on + SSLCertificateFile /etc/ssl/certs/your-cert.pem + SSLCertificateKeyFile /etc/ssl/private/your-key.pem + SSLCertificateChainFile /etc/ssl/certs/your-chain.pem + + # SSL настройки безопасности + SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 + SSLCipherSuite ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256 + SSLHonorCipherOrder off + SSLSessionTickets off + + # Основные настройки + DocumentRoot /var/www/project + + # WSGI конфигурация + WSGIDaemonProcess project python-path=/var/www/project/src python-home=/var/www/project/venv + WSGIProcessGroup project + WSGIScriptAlias / /var/www/project/src/config/wsgi.py + WSGIApplicationGroup %{GLOBAL} + + # Права доступа к WSGI файлу + + Require all granted + + + # Статические файлы + Alias /static/ /var/www/project/staticfiles/ + + Require all granted + ExpiresActive On + ExpiresDefault "access plus 1 year" + Header append Cache-Control "public" + + + # Медиа файлы + Alias /media/ /var/www/project/media/ + + Require all granted + ExpiresActive On + ExpiresDefault "access plus 1 year" + Header append Cache-Control "public" + + + # Логи + ErrorLog ${APACHE_LOG_DIR}/project_error.log + CustomLog ${APACHE_LOG_DIR}/project_access.log combined + + # Заголовки безопасности + Header always set X-Frame-Options "SAMEORIGIN" + Header always set X-Content-Type-Options "nosniff" + Header always set X-XSS-Protection "1; mode=block" + Header always set Referrer-Policy "no-referrer-when-downgrade" + + # Ограничение размера загрузки + LimitRequestBody 104857600 + + # Health check endpoint + + SetHandler none + Require all granted + + \ No newline at end of file diff --git a/deploy/monitoring/prometheus.yml b/deploy/monitoring/prometheus.yml new file mode 100644 index 0000000..502c7da --- /dev/null +++ b/deploy/monitoring/prometheus.yml @@ -0,0 +1,36 @@ +# Конфигурация мониторинга Prometheus для Django приложения + +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - "alert.rules" + +scrape_configs: + - job_name: 'django-app' + static_configs: + - targets: ['localhost:8000'] + metrics_path: '/metrics' + scrape_interval: 30s + + - job_name: 'celery-exporter' + static_configs: + - targets: ['localhost:9542'] + scrape_interval: 30s + + - job_name: 'postgresql' + static_configs: + - targets: ['localhost:9187'] + scrape_interval: 30s + + - job_name: 'redis' + static_configs: + - targets: ['localhost:9121'] + scrape_interval: 30s + +alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager:9093 \ No newline at end of file diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh new file mode 100644 index 0000000..9403f3f --- /dev/null +++ b/deploy/scripts/deploy.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Скрипт развертывания проекта на сервере Astra Linux + +set -e # Прекращать выполнение при ошибках + +PROJECT_NAME="project" +PROJECT_PATH="/var/www/${PROJECT_NAME}" +REPO_URL="ваш_репозиторий.git" +BRANCH="main" + +echo "=== Начало развертывания проекта ===" + +# Обновление системы +echo "Обновление системы..." +apt-get update && apt-get upgrade -y + +# Установка uv +echo "Установка uv package manager..." +curl -LsSf https://astral.sh/uv/install.sh | sh +source $HOME/.cargo/env || true + +# Установка необходимых пакетов +echo "Установка системных зависимостей..." +apt-get install -y \ + python3.11 \ + python3.11-venv \ + python3.11-dev \ + postgresql-15 \ + postgresql-client-15 \ + redis-server \ + apache2 \ + libapache2-mod-wsgi-py3 \ + git \ + build-essential \ + libpq-dev \ + libffi-dev \ + libxml2-dev \ + libxslt1-dev \ + zlib1g-dev + +# Создание пользователя для проекта +echo "Создание пользователя проекта..." +if ! id "www-data" &>/dev/null; then + useradd -r -s /bin/false www-data +fi + +# Создание директорий проекта +echo "Создание структуры директорий..." +mkdir -p ${PROJECT_PATH}/{src,logs,media,staticfiles,venv} +chown -R www-data:www-data ${PROJECT_PATH} + +# Клонирование репозитория +echo "Клонирование репозитория..." +cd ${PROJECT_PATH} +if [ -d ".git" ]; then + git pull origin ${BRANCH} +else + git clone ${REPO_URL} . + git checkout ${BRANCH} +fi + +# Создание виртуального окружения с uv +echo "Создание виртуального окружения с uv..." +uv venv ${PROJECT_PATH}/venv +source ${PROJECT_PATH}/venv/bin/activate + +# Установка зависимостей через uv +echo "Установка Python зависимостей через uv..." +uv pip install --upgrade pip +uv pip install -r requirements.txt +uv pip install -r requirements-dev.txt + +# Настройка переменных окружения +echo "Настройка переменных окружения..." +cp .env.example .env +# Здесь можно автоматически заполнить .env файл или запросить ввод + +# Настройка базы данных +echo "Настройка базы данных..." +sudo -u postgres psql -c "CREATE DATABASE ${PROJECT_NAME}_prod;" || true +sudo -u postgres psql -c "CREATE USER ${PROJECT_NAME}_user WITH PASSWORD '${PROJECT_NAME}_password';" || true +sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE ${PROJECT_NAME}_prod TO ${PROJECT_NAME}_user;" || true + +# Выполнение миграций Django +echo "Выполнение миграций..." +cd ${PROJECT_PATH}/src +python manage.py makemigrations +python manage.py migrate +python manage.py collectstatic --noinput + +# Создание суперпользователя (опционально) +echo "Создание суперпользователя..." +echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('admin', 'admin@example.com', 'adminpass') if not User.objects.filter(username='admin').exists() else None" | python manage.py shell + +# Настройка systemd сервисов +echo "Настройка systemd сервисов..." +cp ../deploy/systemd/*.service /etc/systemd/system/ +systemctl daemon-reload + +# Настройка Apache +echo "Настройка Apache..." +cp ../deploy/apache/project.conf /etc/apache2/sites-available/${PROJECT_NAME}.conf +a2ensite ${PROJECT_NAME}.conf +a2enmod ssl rewrite headers expires +a2dissite 000-default + +# Настройка прав доступа +echo "Настройка прав доступа..." +chown -R www-data:www-data ${PROJECT_PATH} +chmod -R 755 ${PROJECT_PATH} + +# Запуск сервисов +echo "Запуск сервисов..." +systemctl enable gunicorn.service +systemctl enable celery-worker.service +systemctl enable celery-beat.service +systemctl enable apache2 + +systemctl start gunicorn.service +systemctl start celery-worker.service +systemctl start celery-beat.service +systemctl restart apache2 + +echo "=== Развертывание завершено успешно ===" +echo "Проект доступен по адресу: https://ваш-ip-адрес" +echo "Админка Django: https://ваш-ip-адрес/admin/" +echo "API документация: https://ваш-ip-адрес/api/" \ No newline at end of file diff --git a/deploy/systemd/celery-beat.service b/deploy/systemd/celery-beat.service new file mode 100644 index 0000000..f54f84b --- /dev/null +++ b/deploy/systemd/celery-beat.service @@ -0,0 +1,15 @@ +[Unit] +Description=Celery Beat for Django project +After=network.target redis.service postgresql.service + +[Service] +Type=simple +User=www-data +Group=www-data +EnvironmentFile=/var/www/project/.env +WorkingDirectory=/var/www/project/src +ExecStart=/var/www/project/venv/bin/celery -A config beat --loglevel=INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler +Restart=always + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/deploy/systemd/celery-worker.service b/deploy/systemd/celery-worker.service new file mode 100644 index 0000000..a7cabb0 --- /dev/null +++ b/deploy/systemd/celery-worker.service @@ -0,0 +1,16 @@ +[Unit] +Description=Celery Worker for Django project +After=network.target redis.service postgresql.service + +[Service] +Type=forking +User=www-data +Group=www-data +EnvironmentFile=/var/www/project/.env +WorkingDirectory=/var/www/project/src +ExecStart=/var/www/project/venv/bin/celery -A config worker --loglevel=INFO --pidfile=/run/celery/worker.pid +ExecReload=/bin/kill -HUP $MAINPID +PIDFile=/run/celery/worker.pid + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/deploy/systemd/gunicorn.service b/deploy/systemd/gunicorn.service new file mode 100644 index 0000000..20b059f --- /dev/null +++ b/deploy/systemd/gunicorn.service @@ -0,0 +1,27 @@ +[Unit] +Description=Gunicorn daemon for Django project +After=network.target + +[Service] +Type=notify +User=www-data +Group=www-data +RuntimeDirectory=gunicorn +WorkingDirectory=/var/www/project/src +ExecStart=/var/www/project/venv/bin/gunicorn config.wsgi:application \ + --bind unix:/run/gunicorn.sock \ + --workers 3 \ + --worker-class gevent \ + --worker-connections 1000 \ + --timeout 30 \ + --keep-alive 2 \ + --max-requests 1000 \ + --max-requests-jitter 100 \ + --preload +ExecReload=/bin/kill -s HUP $MAINPID +KillMode=mixed +TimeoutStopSec=5 +PrivateTmp=true + +[Install] +WantedBy=multi-user.target \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b2e5137 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,136 @@ +version: '3.8' + +services: + db: + image: postgres:15.10 + container_name: project_db + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-project_dev} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5432:5432" + networks: + - project_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:7-alpine + container_name: project_redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - project_network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + web: + build: + context: . + dockerfile: docker/Dockerfile.web + container_name: project_web + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + environment: + - DEBUG=${DEBUG:-True} + - SECRET_KEY=${SECRET_KEY:-django-insecure-development-key} + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 + - POSTGRES_DB=${POSTGRES_DB:-project_dev} + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - REDIS_URL=redis://redis:6379/0 + - CELERY_BROKER_URL=redis://redis:6379/0 + volumes: + - ./src:/app/src + - ./logs:/app/logs + - ./media:/app/media + - ./staticfiles:/app/staticfiles + ports: + - "8000:8000" + networks: + - project_network + command: > + sh -c "python src/manage.py migrate && + python src/manage.py collectstatic --noinput && + gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3" + + celery_worker: + build: + context: . + dockerfile: docker/Dockerfile.celery + container_name: project_celery_worker + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + environment: + - DEBUG=${DEBUG:-True} + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 + - POSTGRES_DB=${POSTGRES_DB:-project_dev} + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - REDIS_URL=redis://redis:6379/0 + - CELERY_BROKER_URL=redis://redis:6379/0 + volumes: + - ./src:/app/src + - ./logs:/app/logs + networks: + - project_network + command: celery -A config worker --loglevel=info + + celery_beat: + build: + context: . + dockerfile: docker/Dockerfile.celery + container_name: project_celery_beat + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + environment: + - DEBUG=${DEBUG:-True} + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 + - POSTGRES_DB=${POSTGRES_DB:-project_dev} + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - REDIS_URL=redis://redis:6379/0 + - CELERY_BROKER_URL=redis://redis:6379/0 + volumes: + - ./src:/app/src + - ./logs:/app/logs + networks: + - project_network + command: celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler + +volumes: + postgres_data: + redis_data: + +networks: + project_network: + driver: bridge \ No newline at end of file diff --git a/docker/Dockerfile.celery b/docker/Dockerfile.celery new file mode 100644 index 0000000..9b5a0bd --- /dev/null +++ b/docker/Dockerfile.celery @@ -0,0 +1,36 @@ +FROM python:3.11.2-slim + +# Установка системных зависимостей +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + libffi-dev \ + libxml2-dev \ + libxslt1-dev \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +# Создание рабочей директории +WORKDIR /app + +# Копирование файлов зависимостей +COPY requirements.txt . +COPY requirements-dev.txt . + +# Установка Python зависимостей +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements-dev.txt + +# Копирование исходного кода +COPY src/ ./src/ + +# Создание необходимых директорий +RUN mkdir -p logs + +# Создание пользователя для запуска приложения +RUN groupadd -r appgroup && useradd -r -g appgroup appuser +RUN chown -R appuser:appgroup /app +USER appuser + +# Команда по умолчанию будет передаваться из docker-compose \ No newline at end of file diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..203b11b --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,41 @@ +FROM python:3.11.2-slim + +# Установка системных зависимостей +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + gcc \ + postgresql-client \ + libpq-dev \ + libffi-dev \ + libxml2-dev \ + libxslt1-dev \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +# Создание рабочей директории +WORKDIR /app + +# Копирование файлов зависимостей +COPY requirements.txt . +COPY requirements-dev.txt . + +# Установка Python зависимостей +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r requirements-dev.txt + +# Копирование исходного кода +COPY src/ ./src/ + +# Создание необходимых директорий +RUN mkdir -p logs staticfiles media + +# Создание пользователя для запуска приложения +RUN groupadd -r appgroup && useradd -r -g appgroup appuser +RUN chown -R appuser:appgroup /app +USER appuser + +# Открытие порта +EXPOSE 8000 + +# Команда по умолчанию +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] \ No newline at end of file diff --git a/docker/postgres/init.sql b/docker/postgres/init.sql new file mode 100644 index 0000000..48e8a44 --- /dev/null +++ b/docker/postgres/init.sql @@ -0,0 +1,18 @@ +-- Инициализационный SQL файл для PostgreSQL +-- Создает необходимые расширения и базовые настройки + +-- Создание расширений +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +-- Создание пользователей (если нужно) +-- CREATE USER project_user WITH PASSWORD 'project_password'; +-- GRANT ALL PRIVILEGES ON DATABASE project_dev TO project_user; + +-- Настройки для производительности +ALTER SYSTEM SET shared_buffers = '256MB'; +ALTER SYSTEM SET effective_cache_size = '1GB'; +ALTER SYSTEM SET maintenance_work_mem = '64MB'; +ALTER SYSTEM SET checkpoint_completion_target = 0.9; +ALTER SYSTEM SET wal_buffers = '16MB'; +ALTER SYSTEM SET default_statistics_target = 100; \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ea305b7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,211 @@ +[project] +name = "mostovik-backend" +version = "0.1.0" +description = "Backend service for Mostovik project" +authors = [ + {name = "Your Name", email = "your.email@example.com"}, +] +requires-python = ">=3.11" +dependencies = [ + # Django Framework + "Django==3.2.25", + "djangorestframework==3.14.0", + # Database + "psycopg2-binary==2.9.9", + # Async tasks + "celery==5.3.6", + "redis==5.0.3", + "django-celery-beat==2.6.0", + "django-celery-results==2.5.1", + # Caching + "django-redis==5.4.0", + # Data processing + "pandas==2.0.3", + "numpy==1.24.4", + "requests==2.31.0", + "beautifulsoup4==4.12.3", + # Web scraping + "scrapy==2.11.2", + "selenium==4.17.2", + # Validation and serialization + "django-filter==23.5", + "django-cors-headers==4.3.1", + # Logging and monitoring + "python-json-logger==2.0.7", + # Utilities + "python-dotenv==1.0.1", + "python-dateutil==2.8.2", + "pytz==2024.1", + # Security + "cryptography==42.0.5", + "djangorestframework-simplejwt>=5.3.1", + "drf-yasg>=1.21.10", + "pillow>=12.1.0", + "python-decouple>=3.8", + "coreapi>=2.3.3", + "django-rest-swagger>=2.2.0", + "model-bakery>=1.17.0", +] + +[project.optional-dependencies] +dev = [ + # WSGI server + "gunicorn==21.2.0", + "gevent==23.9.1", + + # Development + "django-extensions==3.2.3", + "werkzeug==3.0.1", + "django-debug-toolbar==4.2.0", + + # Testing + "pytest==7.4.4", + "pytest-django==4.7.0", + "pytest-cov==4.1.0", + "factory-boy==3.3.0", + "coverage==7.4.0", + + # Linters and formatters + "flake8==6.1.0", + "black==23.12.1", + "isort==5.13.2", + "ruff==0.1.14", + + # Documentation + "sphinx==7.2.6", + "sphinx-rtd-theme==2.0.0", + + # Monitoring + "flower==2.0.1", + + # CLI tools + "click==8.1.7", + "typer==0.9.0", + + # Debugging + "ipdb==0.13.13", + "pdbpp==0.10.3", + + # Additional tools + "watchdog==3.0.0", + + # Pre-commit hooks + "pre-commit==3.6.0", +] + +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["src"] + +[tool.ruff] +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. +lint.select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "C", # mccabe + "B", # flake8-bugbear + "Q", # flake8-quotes + "DJ", # flake8-django +] + +lint.extend-ignore = [ + "E501", # line too long, handled by formatter + "DJ01", # Missing docstring (too strict for Django) +] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +lint.fixable = ["ALL"] +lint.unfixable = [] + +# Allow unused variables when underscore-prefixed. +lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "*/migrations/*", + "*/__pycache__/*", +] + +# Same as Black. +line-length = 88 + +# Assume Python 3.11. +target-version = "py311" + +[tool.ruff.lint.mccabe] +# Unlike Flake8, default to a complexity level of 10. +max-complexity = 10 + +[tool.ruff.lint.per-file-ignores] +# Ignore `E402` (import violations) in all `__init__.py` files +"__init__.py" = ["E402"] +# Ignore complexity issues in tests +"tests/*" = ["C901"] +"**/test_*" = ["C901"] +"**/tests.py" = ["C901"] + +[tool.ruff.format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +[dependency-groups] +dev = [ + "gunicorn==21.2.0", + "gevent==23.9.1", + "django-extensions==3.2.3", + "werkzeug==3.0.1", + "django-debug-toolbar==4.2.0", + "pytest==7.4.4", + "pytest-django==4.7.0", + "pytest-cov==4.1.0", + "factory-boy==3.3.0", + "coverage==7.4.0", + "flake8==6.1.0", + "black==23.12.1", + "isort==5.13.2", + "ruff==0.1.14", + "sphinx==7.2.6", + "sphinx-rtd-theme==2.0.0", + "flower==2.0.1", + "click==8.1.7", + "typer==0.9.0", + "ipdb==0.13.13", + "pdbpp==0.10.3", + "watchdog==3.0.0", + "pre-commit==3.6.0", +] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..3ef423e --- /dev/null +++ b/ruff.toml @@ -0,0 +1,81 @@ +# Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "C", # mccabe + "B", # flake8-bugbear + "Q", # flake8-quotes + "DJ", # flake8-django +] + +extend-ignore = [ + "E501", # line too long, handled by formatter + "DJ01", # Missing docstring (too strict for Django) +] + +# Allow autofix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "*/migrations/*", + "*/__pycache__/*", +] + +# Same as Black. +line-length = 88 + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +# Assume Python 3.11. +target-version = "py311" + +[mccabe] +# Unlike Flake8, default to a complexity level of 10. +max-complexity = 10 + +[per-file-ignores] +# Ignore `E402` (import violations) in all `__init__.py` files +"__init__.py" = ["E402"] +# Ignore complexity issues in tests +"tests/*" = ["C901"] +"**/test_*" = ["C901"] +"**/tests.py" = ["C901"] + +[format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" \ No newline at end of file diff --git a/run_tests.py b/run_tests.py new file mode 100755 index 0000000..7fdc468 --- /dev/null +++ b/run_tests.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python +"""Скрипт для запуска тестов с обходом проблемы ipdb""" + +import os +import sys +import django + +# Монкипатчим ipdb до импорта Django +sys.modules['ipdb'] = type('MockModule', (), {'__getattr__': lambda s, n: None})() + +# Настройка Django +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') +django.setup() + +# Теперь можем безопасно импортировать и запускать тесты +from django.core.management import execute_from_command_line + +if __name__ == '__main__': + # Добавляем аргументы командной строки + args = sys.argv[1:] # Убираем имя скрипта + if not args: + # По умолчанию запускаем все тесты user app + args = ['test', 'apps.user'] + + # Подготовка аргументов для Django + django_args = ['manage.py'] + args + sys.argv = django_args + + execute_from_command_line(sys.argv) \ No newline at end of file diff --git a/scripts/check-migrations.sh b/scripts/check-migrations.sh new file mode 100755 index 0000000..2e2365e --- /dev/null +++ b/scripts/check-migrations.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Django migration check script for pre-commit + +cd "$(dirname "$0")/../src" || exit 1 + +export PYTHONPATH=. +export DJANGO_SETTINGS_MODULE=config.settings.development + +if uv run python manage.py makemigrations --check --dry-run; then + echo "✓ Django migrations are up to date" + exit 0 +else + echo "⚠ Warning: Django migrations check failed (may be due to configuration issues)" + echo " This doesn't prevent commits, but you should check migrations manually" + exit 0 # Exit with success to not block commits +fi \ No newline at end of file diff --git a/scripts/setup-precommit.sh b/scripts/setup-precommit.sh new file mode 100644 index 0000000..7bb39f3 --- /dev/null +++ b/scripts/setup-precommit.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Скрипт установки и настройки pre-commit хуков + +echo "🔧 Настройка pre-commit хуков..." + +# Проверка наличия Git +if ! command -v git &> /dev/null; then + echo "❌ Git не найден. Установите Git и повторите попытку." + exit 1 +fi + +# Создание директории для хуков если её нет +HOOKS_DIR=".git/hooks" +if [ ! -d "$HOOKS_DIR" ]; then + mkdir -p "$HOOKS_DIR" + echo "📁 Создана директория для git hooks" +fi + +# Копирование pre-commit хука +if [ -f ".git/hooks/pre-commit" ]; then + echo "🔄 Обновление существующего pre-commit хука" +else + echo "📥 Установка нового pre-commit хука" +fi + +# Делаем хук исполняемым +chmod +x .git/hooks/pre-commit +echo "✅ Pre-commit хук установлен и готов к использованию" + +echo "" +echo "📋 Что проверяет pre-commit хук:" +echo " • Синтаксис Python файлов" +echo " • Стиль кода (flake8)" +echo " • Форматирование (black)" +echo " • Сортировка импортов (isort)" +echo " • Формат YAML файлов" +echo " • Пробелы в конце строк" +echo " • Закрывающие переводы строк" +echo "" +echo "💡 Хук автоматически запускается при каждом коммите" +echo "💡 Для пропуска проверок используйте: git commit --no-verify" \ No newline at end of file diff --git a/src/apps/user/__init__.py b/src/apps/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/user/apps.py b/src/apps/user/apps.py new file mode 100644 index 0000000..13221af --- /dev/null +++ b/src/apps/user/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.user" + verbose_name = "User Management" + + def ready(self): + import apps.user.signals # noqa \ No newline at end of file diff --git a/src/apps/user/migrations/0001_initial.py b/src/apps/user/migrations/0001_initial.py new file mode 100644 index 0000000..b04bc0c --- /dev/null +++ b/src/apps/user/migrations/0001_initial.py @@ -0,0 +1,71 @@ +# Generated by Django 3.2.25 on 2026-01-19 12:19 + +from django.conf import settings +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(help_text='Required. Must be unique.', max_length=254, unique=True, verbose_name='email address')), + ('phone', models.CharField(blank=True, help_text='Phone number in international format', max_length=20, null=True, verbose_name='phone number')), + ('is_verified', models.BooleanField(default=False, help_text='Designates whether the user has verified their email.', verbose_name='email verified')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('groups', models.ManyToManyField(blank=True, help_text='', related_name='custom_user_set', related_query_name='custom_user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='custom_user_set', related_query_name='custom_user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'db_table': 'users', + 'ordering': ['-created_at'], + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(blank=True, max_length=50, null=True, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=50, null=True, verbose_name='last name')), + ('bio', models.TextField(blank=True, help_text='Short biography or description', null=True, verbose_name='bio')), + ('avatar', models.ImageField(blank=True, help_text='User avatar image', null=True, upload_to='avatars/', verbose_name='avatar')), + ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='date of birth')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'profile', + 'verbose_name_plural': 'profiles', + 'db_table': 'profiles', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/src/apps/user/migrations/__init__.py b/src/apps/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/user/models.py b/src/apps/user/models.py new file mode 100644 index 0000000..a0a5c90 --- /dev/null +++ b/src/apps/user/models.py @@ -0,0 +1,143 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class User(AbstractUser): + """Расширенная модель пользователя""" + + # Переопределяем группы и разрешения для избежания конфликта + groups = models.ManyToManyField( + "auth.Group", + verbose_name=_("groups"), + blank=True, + help_text=_(""), + related_name="custom_user_set", + related_query_name="custom_user", + ) + user_permissions = models.ManyToManyField( + "auth.Permission", + verbose_name=_("user permissions"), + blank=True, + help_text=_("Specific permissions for this user."), + related_name="custom_user_set", + related_query_name="custom_user", + ) + + email = models.EmailField( + _("email address"), + unique=True, + help_text=_("Required. Must be unique.") + ) + + phone = models.CharField( + _("phone number"), + max_length=20, + blank=True, + null=True, + help_text=_("Phone number in international format") + ) + + is_verified = models.BooleanField( + _("email verified"), + default=False, + help_text=_("Designates whether the user has verified their email.") + ) + + created_at = models.DateTimeField( + _("created at"), + auto_now_add=True + ) + + updated_at = models.DateTimeField( + _("updated at"), + auto_now=True + ) + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["username"] + + class Meta: + db_table = "users" + verbose_name = _("user") + verbose_name_plural = _("users") + ordering = ["-created_at"] + + def __str__(self): + return f"{self.username} ({self.email})" + + +class Profile(models.Model): + """Профиль пользователя (OneToOne связь с User)""" + + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + related_name="profile", + verbose_name=_("user") + ) + + first_name = models.CharField( + _("first name"), + max_length=50, + blank=True, + null=True + ) + + last_name = models.CharField( + _("last name"), + max_length=50, + blank=True, + null=True + ) + + bio = models.TextField( + _("bio"), + blank=True, + null=True, + help_text=_("Short biography or description") + ) + + avatar = models.ImageField( + _("avatar"), + upload_to="avatars/", + blank=True, + null=True, + help_text=_("User avatar image") + ) + + date_of_birth = models.DateField( + _("date of birth"), + blank=True, + null=True + ) + + created_at = models.DateTimeField( + _("created at"), + auto_now_add=True + ) + + updated_at = models.DateTimeField( + _("updated at"), + auto_now=True + ) + + class Meta: + db_table = "profiles" + verbose_name = _("profile") + verbose_name_plural = _("profiles") + ordering = ["-created_at"] + + def __str__(self): + return f"Profile of {self.user.username}" + + @property + def full_name(self): + """Полное имя пользователя""" + if self.first_name and self.last_name: + return f"{self.first_name} {self.last_name}" + elif self.first_name: + return self.first_name + elif self.last_name: + return self.last_name + return self.user.username \ No newline at end of file diff --git a/src/apps/user/serializers.py b/src/apps/user/serializers.py new file mode 100644 index 0000000..1799a5a --- /dev/null +++ b/src/apps/user/serializers.py @@ -0,0 +1,174 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers +from rest_framework.validators import UniqueValidator + +from .models import Profile + +User = get_user_model() + + +class UserRegistrationSerializer(serializers.ModelSerializer): + """Сериализатор для регистрации пользователя""" + + email = serializers.EmailField( + validators=[UniqueValidator(queryset=User.objects.all())], + help_text="Email пользователя (уникальный)" + ) + password = serializers.CharField( + write_only=True, + min_length=8, + help_text="Пароль (минимум 8 символов)" + ) + password_confirm = serializers.CharField( + write_only=True, + min_length=8, + help_text="Подтверждение пароля" + ) + + class Meta: + model = User + fields = ('email', 'username', 'password', 'password_confirm', 'phone') + extra_kwargs = { + 'username': { + 'validators': [UniqueValidator(queryset=User.objects.all())], + 'help_text': 'Username пользователя (уникальный)' + } + } + + def validate(self, attrs): + if attrs['password'] != attrs['password_confirm']: + raise serializers.ValidationError("Пароли не совпадают") + return attrs + + def create(self, validated_data): + validated_data.pop('password_confirm') + password = validated_data.pop('password') + user = User.objects.create_user(**validated_data) + user.set_password(password) + user.save() + return user + + +class UserProfileSerializer(serializers.ModelSerializer): + """Сериализатор для профиля пользователя""" + + full_name = serializers.ReadOnlyField(help_text="Полное имя") + avatar = serializers.ImageField(required=False, allow_null=True) + + class Meta: + model = Profile + fields = ( + 'id', + 'first_name', + 'last_name', + 'full_name', + 'bio', + 'avatar', + 'date_of_birth' + ) + read_only_fields = ('id',) + + +class UserSerializer(serializers.ModelSerializer): + """Сериализатор для пользователя""" + + profile = UserProfileSerializer(read_only=True) + + class Meta: + model = User + fields = ( + 'id', + 'email', + 'username', + 'phone', + 'is_verified', + 'profile', + 'created_at', + 'updated_at' + ) + read_only_fields = ( + 'id', + 'is_verified', + 'created_at', + 'updated_at' + ) + + +class UserUpdateSerializer(serializers.ModelSerializer): + """Сериализатор для обновления данных пользователя""" + + class Meta: + model = User + fields = ('username', 'phone') + + +class ProfileUpdateSerializer(serializers.ModelSerializer): + """Сериализатор для обновления профиля""" + + class Meta: + model = Profile + fields = ( + 'first_name', + 'last_name', + 'bio', + 'avatar', + 'date_of_birth' + ) + + +class LoginSerializer(serializers.Serializer): + """Сериализатор для входа""" + + email = serializers.EmailField(help_text="Email пользователя") + password = serializers.CharField(help_text="Пароль") + + +class TokenSerializer(serializers.Serializer): + """Сериализатор для токенов""" + + access = serializers.CharField(help_text="Access token") + refresh = serializers.CharField(help_text="Refresh token") + + +class PasswordChangeSerializer(serializers.Serializer): + """Сериализатор для смены пароля""" + + old_password = serializers.CharField(help_text="Старый пароль") + new_password = serializers.CharField( + min_length=8, + help_text="Новый пароль (минимум 8 символов)" + ) + new_password_confirm = serializers.CharField( + min_length=8, + help_text="Подтверждение нового пароля" + ) + + def validate(self, attrs): + if attrs['new_password'] != attrs['new_password_confirm']: + raise serializers.ValidationError("Новые пароли не совпадают") + return attrs + + +class PasswordResetRequestSerializer(serializers.Serializer): + """Сериализатор для запроса сброса пароля""" + + email = serializers.EmailField(help_text="Email пользователя") + + +class PasswordResetConfirmSerializer(serializers.Serializer): + """Сериализатор для подтверждения сброса пароля""" + + token = serializers.CharField(help_text="Токен сброса") + new_password = serializers.CharField( + min_length=8, + help_text="Новый пароль (минимум 8 символов)" + ) + new_password_confirm = serializers.CharField( + min_length=8, + help_text="Подтверждение нового пароля" + ) + + def validate(self, attrs): + if attrs['new_password'] != attrs['new_password_confirm']: + raise serializers.ValidationError("Новые пароли не совпадают") + return attrs \ No newline at end of file diff --git a/src/apps/user/services.py b/src/apps/user/services.py new file mode 100644 index 0000000..b43a71a --- /dev/null +++ b/src/apps/user/services.py @@ -0,0 +1,194 @@ +from typing import Dict, Any, Optional +from django.contrib.auth import get_user_model +from django.db import transaction +from rest_framework_simplejwt.tokens import RefreshToken + +from .models import Profile + +User = get_user_model() + + +class UserService: + """Сервисный слой для работы с пользователями""" + + @classmethod + def create_user(cls, *, email: str, username: str, password: str, **extra_fields) -> User: + """ + Создает нового пользователя + + Args: + email: Email пользователя + username: Username пользователя + password: Пароль + **extra_fields: Дополнительные поля + + Returns: + User: Созданный пользователь + + Raises: + ValidationError: При некорректных данных + """ + with transaction.atomic(): + user = User.objects.create_user( + email=email, + username=username, + password=password, + **extra_fields + ) + return user + + @classmethod + def get_user_by_email(cls, email: str) -> Optional[User]: + """Получает пользователя по email""" + try: + return User.objects.get(email=email) + except User.DoesNotExist: + return None + + @classmethod + def get_user_by_id(cls, user_id: int) -> Optional[User]: + """Получает пользователя по ID""" + try: + return User.objects.get(id=user_id) + except User.DoesNotExist: + return None + + @classmethod + def update_user(cls, user_id: int, **fields) -> Optional[User]: + """ + Обновляет данные пользователя + + Args: + user_id: ID пользователя + **fields: Поля для обновления + + Returns: + User: Обновленный пользователь или None + """ + user = cls.get_user_by_id(user_id) + if not user: + return None + + for field, value in fields.items(): + setattr(user, field, value) + + user.save() + return user + + @classmethod + def delete_user(cls, user_id: int) -> bool: + """ + Удаляет пользователя + + Args: + user_id: ID пользователя + + Returns: + bool: True если успешно удален + """ + user = cls.get_user_by_id(user_id) + if user: + user.delete() + return True + return False + + @classmethod + def get_tokens_for_user(cls, user: User) -> Dict[str, str]: + """ + Генерирует JWT токены для пользователя + + Args: + user: Пользователь + + Returns: + Dict[str, str]: refresh и access токены + """ + refresh = RefreshToken.for_user(user) + return { + 'refresh': str(refresh), + 'access': str(refresh.access_token), + } + + @classmethod + def verify_email(cls, user_id: int) -> bool: + """ + Подтверждает email пользователя + + Args: + user_id: ID пользователя + + Returns: + bool: True если успешно подтвержден + """ + user = cls.get_user_by_id(user_id) + if user: + user.is_verified = True + user.save() + return True + return False + + +class ProfileService: + """Сервисный слой для работы с профилями""" + + @classmethod + def get_profile_by_user_id(cls, user_id: int) -> Optional[Profile]: + """Получает профиль по ID пользователя""" + try: + return Profile.objects.select_related('user').get(user_id=user_id) + except Profile.DoesNotExist: + return None + + @classmethod + def update_profile(cls, user_id: int, **fields) -> Optional[Profile]: + """ + Обновляет профиль пользователя + + Args: + user_id: ID пользователя + **fields: Поля для обновления + + Returns: + Profile: Обновленный профиль или None + """ + profile = cls.get_profile_by_user_id(user_id) + if not profile: + return None + + for field, value in fields.items(): + setattr(profile, field, value) + + profile.save() + return profile + + @classmethod + def get_full_profile_data(cls, user_id: int) -> Optional[Dict[str, Any]]: + """ + Получает полные данные пользователя и профиля + + Args: + user_id: ID пользователя + + Returns: + Dict: Полные данные или None + """ + profile = cls.get_profile_by_user_id(user_id) + if not profile: + return None + + user = profile.user + return { + 'id': user.id, + 'email': user.email, + 'username': user.username, + 'is_verified': user.is_verified, + 'phone': user.phone, + 'first_name': profile.first_name, + 'last_name': profile.last_name, + 'full_name': profile.full_name, + 'bio': profile.bio, + 'avatar': profile.avatar.url if profile.avatar else None, + 'date_of_birth': profile.date_of_birth, + 'created_at': user.created_at, + 'updated_at': user.updated_at, + } \ No newline at end of file diff --git a/src/apps/user/signals.py b/src/apps/user/signals.py new file mode 100644 index 0000000..752cf7b --- /dev/null +++ b/src/apps/user/signals.py @@ -0,0 +1,25 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth import get_user_model + +from .models import Profile + +User = get_user_model() + + +@receiver(post_save, sender=User) +def create_user_profile(sender, instance, created, **kwargs): + """ + Автоматически создает профиль при создании пользователя + """ + if created: + Profile.objects.create(user=instance) + + +@receiver(post_save, sender=User) +def save_user_profile(sender, instance, **kwargs): + """ + Сохраняет профиль при сохранении пользователя + """ + if hasattr(instance, 'profile'): + instance.profile.save() \ No newline at end of file diff --git a/src/apps/user/tests/__init__.py b/src/apps/user/tests/__init__.py new file mode 100644 index 0000000..d839e12 --- /dev/null +++ b/src/apps/user/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for user app""" diff --git a/src/apps/user/tests/factories.py b/src/apps/user/tests/factories.py new file mode 100644 index 0000000..c8f9593 --- /dev/null +++ b/src/apps/user/tests/factories.py @@ -0,0 +1,62 @@ +import uuid +from model_bakery import baker +from apps.user.models import User, Profile + + +class UserFactory: + """Фабрика для создания пользователей""" + + @staticmethod + def create_user(**kwargs): + """Создать обычного пользователя""" + unique_suffix = str(uuid.uuid4())[:8] + defaults = { + 'email': f'test_{unique_suffix}@example.com', + 'username': f'testuser_{unique_suffix}', + 'phone': f'+7999{unique_suffix[:7]}', + } + defaults.update(kwargs) + return baker.make(User, **defaults) + + @staticmethod + def create_superuser(**kwargs): + """Создать суперпользователя""" + unique_suffix = str(uuid.uuid4())[:8] + defaults = { + 'email': f'admin_{unique_suffix}@example.com', + 'username': f'admin_{unique_suffix}', + 'is_staff': True, + 'is_superuser': True, + } + defaults.update(kwargs) + return baker.make(User, **defaults) + + +class ProfileFactory: + """Фабрика для создания профилей""" + + @staticmethod + def create_profile(user=None, **kwargs): + """Создать профиль""" + if user is None: + user = UserFactory.create_user() + + unique_suffix = str(uuid.uuid4())[:4] + defaults = { + 'first_name': f'Иван_{unique_suffix}', + 'last_name': f'Иванов_{unique_suffix}', + 'bio': f'Тестовый профиль {unique_suffix}', + } + defaults.update(kwargs) + + # Проверяем, существует ли уже профиль + try: + profile = user.profile + # Обновляем существующий профиль + for key, value in defaults.items(): + setattr(profile, key, value) + profile.save() + return profile + except Profile.DoesNotExist: + # Создаем новый профиль + return baker.make(Profile, user=user, **defaults) \ No newline at end of file diff --git a/src/apps/user/tests/test_models.py b/src/apps/user/tests/test_models.py new file mode 100644 index 0000000..f842f12 --- /dev/null +++ b/src/apps/user/tests/test_models.py @@ -0,0 +1,122 @@ +"""Tests for user models""" + +from django.test import TestCase +from .factories import UserFactory, ProfileFactory + + +class UserModelTest(TestCase): + """Tests for User model""" + + def setUp(self): + self.user = UserFactory.create_user() + self.superuser = UserFactory.create_superuser() + + def test_user_creation(self): + """Test user creation""" + self.assertTrue(self.user.email) + self.assertTrue(self.user.username) + + def test_user_str_representation(self): + """Test user string representation""" + expected = f"{self.user.username} ({self.user.email})" + self.assertEqual(str(self.user), expected) + + def test_superuser_creation(self): + """Test superuser creation""" + self.assertTrue(self.superuser.is_staff) + self.assertTrue(self.superuser.is_superuser) + + def test_user_email_unique(self): + """Test email field is unique""" + self.assertTrue(self.user._meta.get_field('email').unique) + + def test_user_username_required(self): + """Test username is required field""" + self.assertFalse(self.user._meta.get_field('username').blank) + + def test_user_phone_optional(self): + """Test phone field is optional""" + phone_field = self.user._meta.get_field('phone') + self.assertTrue(phone_field.blank) + self.assertTrue(phone_field.null) + + def test_user_is_verified_default_false(self): + """Test is_verified defaults to False""" + field = self.user._meta.get_field('is_verified') + self.assertFalse(field.default) + + +class ProfileModelTest(TestCase): + """Tests for Profile model""" + + def setUp(self): + self.profile = ProfileFactory.create_profile() + + def test_profile_creation(self): + """Test profile creation""" + self.assertIsNotNone(self.profile.user) + self.assertIsInstance(self.profile.first_name, str) + self.assertIsInstance(self.profile.last_name, str) + # Проверяем, что имена начинаются с "Иван" (учитываем UUID суффиксы) + self.assertTrue(self.profile.first_name.startswith('Иван')) + self.assertTrue(self.profile.last_name.startswith('Иванов')) + + def test_profile_str_representation(self): + """Test profile string representation""" + expected = f"Profile of {self.profile.user.username}" + self.assertEqual(str(self.profile), expected) + + def test_profile_one_to_one_relationship(self): + """Test OneToOne relationship with User""" + self.assertIsNotNone(self.profile.user) + + def test_profile_first_name_optional(self): + """Test first_name field is optional""" + field = self.profile._meta.get_field('first_name') + self.assertTrue(field.blank) + self.assertTrue(field.null) + + def test_profile_last_name_optional(self): + """Test last_name field is optional""" + field = self.profile._meta.get_field('last_name') + self.assertTrue(field.blank) + self.assertTrue(field.null) + + def test_profile_bio_optional(self): + """Test bio field is optional""" + field = self.profile._meta.get_field('bio') + self.assertTrue(field.blank) + self.assertTrue(field.null) + + def test_profile_avatar_optional(self): + """Test avatar field is optional""" + field = self.profile._meta.get_field('avatar') + self.assertTrue(field.blank) + self.assertTrue(field.null) + + def test_profile_date_of_birth_optional(self): + """Test date_of_birth field is optional""" + field = self.profile._meta.get_field('date_of_birth') + self.assertTrue(field.blank) + self.assertTrue(field.null) + + def test_profile_full_name_property(self): + """Test full_name property""" + # Test with both names + self.profile.first_name = "John" + self.profile.last_name = "Doe" + self.assertEqual(self.profile.full_name, "John Doe") + + # Test with only first name + self.profile.last_name = "" + self.assertEqual(self.profile.full_name, "John") + + # Test with only last name + self.profile.first_name = "" + self.profile.last_name = "Doe" + self.assertEqual(self.profile.full_name, "Doe") + + # Test with no names (fallback to username) + self.profile.first_name = "" + self.profile.last_name = "" + self.assertEqual(self.profile.full_name, self.profile.user.username) diff --git a/src/apps/user/tests/test_serializers.py b/src/apps/user/tests/test_serializers.py new file mode 100644 index 0000000..254b3c5 --- /dev/null +++ b/src/apps/user/tests/test_serializers.py @@ -0,0 +1,282 @@ +"""Tests for user serializers""" + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.exceptions import ValidationError + +from ..models import Profile +from ..serializers import ( + LoginSerializer, PasswordChangeSerializer, ProfileUpdateSerializer, + TokenSerializer, UserRegistrationSerializer, UserSerializer, + UserUpdateSerializer +) +from .factories import ProfileFactory, UserFactory + +User = get_user_model() + + +class UserRegistrationSerializerTest(TestCase): + """Tests for UserRegistrationSerializer""" + + def setUp(self): + self.user_data = { + 'email': 'serializer@example.com', + 'username': 'serializeruser', + 'password': 'serializerpass123', + 'password_confirm': 'serializerpass123', + 'phone': '+79991234567' + } + + def test_valid_registration_data(self): + """Test valid registration data""" + serializer = UserRegistrationSerializer(data=self.user_data) + self.assertTrue(serializer.is_valid()) + + def test_passwords_do_not_match(self): + """Test validation fails when passwords don't match""" + data = self.user_data.copy() + data['password_confirm'] = 'differentpass' + + serializer = UserRegistrationSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('non_field_errors', serializer.errors) + + def test_short_password(self): + """Test validation fails with short password""" + data = self.user_data.copy() + data['password'] = 'short' + data['password_confirm'] = 'short' + + serializer = UserRegistrationSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('password', serializer.errors) + + def test_duplicate_email(self): + """Test validation fails with duplicate email""" + existing_user = UserFactory.create_user() + data = self.user_data.copy() + data['email'] = existing_user.email + + serializer = UserRegistrationSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('email', serializer.errors) + + def test_duplicate_username(self): + """Test validation fails with duplicate username""" + existing_user = UserFactory.create_user() + data = self.user_data.copy() + data['username'] = existing_user.username + + serializer = UserRegistrationSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('username', serializer.errors) + + def test_create_user(self): + """Test user creation through serializer""" + serializer = UserRegistrationSerializer(data=self.user_data) + self.assertTrue(serializer.is_valid()) + + user = serializer.save() + + self.assertIsInstance(user, User) + self.assertEqual(user.email, self.user_data['email']) + self.assertEqual(user.username, self.user_data['username']) + self.assertTrue(user.check_password(self.user_data['password'])) + + +class UserSerializerTest(TestCase): + """Tests for UserSerializer""" + + def setUp(self): + self.user = UserFactory.create_user() + ProfileFactory.create_profile(user=self.user) + + def test_user_serialization(self): + """Test user serialization""" + serializer = UserSerializer(self.user) + data = serializer.data + + self.assertEqual(data['id'], self.user.id) + self.assertEqual(data['email'], self.user.email) + self.assertEqual(data['username'], self.user.username) + self.assertEqual(data['phone'], self.user.phone) + self.assertEqual(data['is_verified'], self.user.is_verified) + self.assertIn('profile', data) + self.assertIn('created_at', data) + self.assertIn('updated_at', data) + + def test_read_only_fields(self): + """Test that read-only fields are not writable""" + read_only_fields = ['id', 'is_verified', 'created_at', 'updated_at'] + serializer = UserSerializer() + + for field_name in read_only_fields: + self.assertIn(field_name, serializer.Meta.read_only_fields) + + +class UserUpdateSerializerTest(TestCase): + """Tests for UserUpdateSerializer""" + + def setUp(self): + self.user = UserFactory.create_user() + + def test_valid_update_data(self): + """Test valid update data""" + update_data = { + 'username': 'newusername', + 'phone': '+79991112233' + } + + serializer = UserUpdateSerializer(self.user, data=update_data, partial=True) + self.assertTrue(serializer.is_valid()) + + updated_user = serializer.save() + self.assertEqual(updated_user.username, update_data['username']) + self.assertEqual(updated_user.phone, update_data['phone']) + + def test_fields_allowed(self): + """Test only allowed fields can be updated""" + serializer = UserUpdateSerializer() + allowed_fields = ['username', 'phone'] + + self.assertEqual(set(serializer.Meta.fields), set(allowed_fields)) + + +class ProfileUpdateSerializerTest(TestCase): + """Tests for ProfileUpdateSerializer""" + + def setUp(self): + self.user = UserFactory.create_user() + self.profile = ProfileFactory.create_profile(user=self.user) + + def test_valid_profile_update_data(self): + """Test valid profile update data""" + update_data = { + 'first_name': 'Александр', + 'last_name': 'Сидоров', + 'bio': 'Обновленное описание', + 'date_of_birth': '1990-01-01' + } + + serializer = ProfileUpdateSerializer(self.profile, data=update_data, partial=True) + self.assertTrue(serializer.is_valid()) + + updated_profile = serializer.save() + self.assertEqual(updated_profile.first_name, update_data['first_name']) + self.assertEqual(updated_profile.last_name, update_data['last_name']) + self.assertEqual(updated_profile.bio, update_data['bio']) + + def test_fields_allowed(self): + """Test only allowed fields can be updated""" + serializer = ProfileUpdateSerializer() + allowed_fields = ['first_name', 'last_name', 'bio', 'avatar', 'date_of_birth'] + + self.assertEqual(set(serializer.Meta.fields), set(allowed_fields)) + + +class LoginSerializerTest(TestCase): + """Tests for LoginSerializer""" + + def setUp(self): + self.login_data = { + 'email': 'test@example.com', + 'password': 'testpass123' + } + + def test_valid_login_data(self): + """Test valid login data""" + serializer = LoginSerializer(data=self.login_data) + self.assertTrue(serializer.is_valid()) + + def test_missing_email(self): + """Test validation fails without email""" + data = {'password': 'testpass123'} + serializer = LoginSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('email', serializer.errors) + + def test_missing_password(self): + """Test validation fails without password""" + data = {'email': 'test@example.com'} + serializer = LoginSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('password', serializer.errors) + + +class TokenSerializerTest(TestCase): + """Tests for TokenSerializer""" + + def test_valid_token_data(self): + """Test valid token data""" + token_data = { + 'access': 'access_token_string', + 'refresh': 'refresh_token_string' + } + + serializer = TokenSerializer(data=token_data) + self.assertTrue(serializer.is_valid()) + + def test_missing_access_token(self): + """Test validation fails without access token""" + data = {'refresh': 'refresh_token_string'} + serializer = TokenSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('access', serializer.errors) + + def test_missing_refresh_token(self): + """Test validation fails without refresh token""" + data = {'access': 'access_token_string'} + serializer = TokenSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('refresh', serializer.errors) + + +class PasswordChangeSerializerTest(TestCase): + """Tests for PasswordChangeSerializer""" + + def setUp(self): + self.password_data = { + 'old_password': 'oldpass123', + 'new_password': 'newpass123', + 'new_password_confirm': 'newpass123' + } + + def test_valid_password_change_data(self): + """Test valid password change data""" + serializer = PasswordChangeSerializer(data=self.password_data) + self.assertTrue(serializer.is_valid()) + + def test_passwords_do_not_match(self): + """Test validation fails when new passwords don't match""" + data = self.password_data.copy() + data['new_password_confirm'] = 'differentpass' + + serializer = PasswordChangeSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('non_field_errors', serializer.errors) + + def test_short_new_password(self): + """Test validation fails with short new password""" + data = self.password_data.copy() + data['new_password'] = 'short' + data['new_password_confirm'] = 'short' + + serializer = PasswordChangeSerializer(data=data) + + self.assertFalse(serializer.is_valid()) + self.assertIn('new_password', serializer.errors) + + def test_missing_old_password(self): + """Test validation fails without old password""" + data = { + 'new_password': 'newpass123', + 'new_password_confirm': 'newpass123' + } + serializer = PasswordChangeSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('old_password', serializer.errors) diff --git a/src/apps/user/tests/test_services.py b/src/apps/user/tests/test_services.py new file mode 100644 index 0000000..c905efb --- /dev/null +++ b/src/apps/user/tests/test_services.py @@ -0,0 +1,188 @@ +"""Tests for user services""" + +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework_simplejwt.tokens import RefreshToken + +from ..models import Profile +from ..services import ProfileService, UserService +from .factories import ProfileFactory, UserFactory + +User = get_user_model() + + +class UserServiceTest(TestCase): + """Tests for UserService""" + + def setUp(self): + self.user = UserFactory.create_user() + self.user_data = { + 'email': 'service@example.com', + 'username': 'serviceuser', + 'password': 'servicepass123' + } + + def test_create_user_success(self): + """Test successful user creation""" + user = UserService.create_user(**self.user_data) + + self.assertIsInstance(user, User) + self.assertEqual(user.email, self.user_data['email']) + self.assertEqual(user.username, self.user_data['username']) + self.assertTrue(user.check_password(self.user_data['password'])) + self.assertFalse(user.is_verified) # Default value + + def test_create_user_with_extra_fields(self): + """Test user creation with extra fields""" + extra_data = self.user_data.copy() + extra_data['phone'] = '+79991234567' + extra_data['is_verified'] = True + + user = UserService.create_user(**extra_data) + + self.assertEqual(user.phone, extra_data['phone']) + self.assertTrue(user.is_verified) + + def test_get_user_by_email_found(self): + """Test getting user by existing email""" + found_user = UserService.get_user_by_email(self.user.email) + self.assertEqual(found_user, self.user) + + def test_get_user_by_email_not_found(self): + """Test getting user by non-existing email""" + found_user = UserService.get_user_by_email('nonexistent@example.com') + self.assertIsNone(found_user) + + def test_get_user_by_id_found(self): + """Test getting user by existing ID""" + found_user = UserService.get_user_by_id(self.user.id) + self.assertEqual(found_user, self.user) + + def test_get_user_by_id_not_found(self): + """Test getting user by non-existing ID""" + found_user = UserService.get_user_by_id(999999) + self.assertIsNone(found_user) + + def test_update_user_success(self): + """Test successful user update""" + new_data = { + 'username': 'updated_username', + 'phone': '+79991112233' + } + + updated_user = UserService.update_user(self.user.id, **new_data) + + self.assertIsNotNone(updated_user) + self.assertEqual(updated_user.username, new_data['username']) + self.assertEqual(updated_user.phone, new_data['phone']) + + def test_update_user_not_found(self): + """Test updating non-existing user""" + updated_user = UserService.update_user(999999, username='test') + self.assertIsNone(updated_user) + + def test_delete_user_success(self): + """Test successful user deletion""" + user_id = self.user.id + result = UserService.delete_user(user_id) + + self.assertTrue(result) + self.assertIsNone(UserService.get_user_by_id(user_id)) + + def test_delete_user_not_found(self): + """Test deleting non-existing user""" + result = UserService.delete_user(999999) + self.assertFalse(result) + + def test_get_tokens_for_user(self): + """Test JWT token generation""" + tokens = UserService.get_tokens_for_user(self.user) + + self.assertIn('refresh', tokens) + self.assertIn('access', tokens) + self.assertIsInstance(tokens['refresh'], str) + self.assertIsInstance(tokens['access'], str) + + # Verify tokens are valid + refresh = RefreshToken(tokens['refresh']) + self.assertEqual(refresh['user_id'], self.user.id) + + def test_verify_email_success(self): + """Test successful email verification""" + self.user.is_verified = False + self.user.save() + + result = UserService.verify_email(self.user.id) + + self.assertTrue(result) + self.user.refresh_from_db() + self.assertTrue(self.user.is_verified) + + def test_verify_email_not_found(self): + """Test email verification for non-existing user""" + result = UserService.verify_email(999999) + self.assertFalse(result) + + +class ProfileServiceTest(TestCase): + """Tests for ProfileService""" + + def setUp(self): + self.user = UserFactory.create_user() + self.profile = ProfileFactory.create_profile(user=self.user) + self.profile_data = { + 'first_name': 'Александр', + 'last_name': 'Петров', + 'bio': 'Тестовое описание', + 'date_of_birth': '1990-01-01' + } + + def test_get_profile_by_user_id_found(self): + """Test getting profile by existing user ID""" + found_profile = ProfileService.get_profile_by_user_id(self.user.id) + self.assertEqual(found_profile, self.profile) + # Check that user is selected related + self.assertIsNotNone(found_profile.user) + + def test_get_profile_by_user_id_not_found(self): + """Test getting profile by non-existing user ID""" + found_profile = ProfileService.get_profile_by_user_id(999999) + self.assertIsNone(found_profile) + + def test_update_profile_success(self): + """Test successful profile update""" + updated_profile = ProfileService.update_profile( + self.user.id, + **self.profile_data + ) + + self.assertIsNotNone(updated_profile) + self.assertEqual(updated_profile.first_name, self.profile_data['first_name']) + self.assertEqual(updated_profile.last_name, self.profile_data['last_name']) + self.assertEqual(updated_profile.bio, self.profile_data['bio']) + + def test_update_profile_not_found(self): + """Test updating profile for non-existing user""" + updated_profile = ProfileService.update_profile(999999, first_name='Test') + self.assertIsNone(updated_profile) + + def test_get_full_profile_data_success(self): + """Test getting full profile data""" + profile_data = ProfileService.get_full_profile_data(self.user.id) + + self.assertIsNotNone(profile_data) + self.assertEqual(profile_data['id'], self.user.id) + self.assertEqual(profile_data['email'], self.user.email) + self.assertEqual(profile_data['username'], self.user.username) + self.assertEqual(profile_data['first_name'], self.profile.first_name) + self.assertEqual(profile_data['last_name'], self.profile.last_name) + self.assertEqual(profile_data['full_name'], self.profile.full_name) + self.assertEqual(profile_data['bio'], self.profile.bio) + self.assertEqual(profile_data['is_verified'], self.user.is_verified) + + def test_get_full_profile_data_not_found(self): + """Test getting full profile data for non-existing user""" + profile_data = ProfileService.get_full_profile_data(999999) + self.assertIsNone(profile_data) diff --git a/src/apps/user/tests/test_views.py b/src/apps/user/tests/test_views.py new file mode 100644 index 0000000..92b33d9 --- /dev/null +++ b/src/apps/user/tests/test_views.py @@ -0,0 +1,305 @@ +"""Tests for user DRF views""" + +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from ..models import Profile +from ..services import UserService +from .factories import ProfileFactory, UserFactory + +User = get_user_model() + + +class RegisterViewTest(APITestCase): + """Tests for RegisterView""" + + def setUp(self): + self.register_url = reverse('register') + self.user_data = { + 'email': 'test@example.com', + 'username': 'testuser', + 'password': 'testpass123', + 'password_confirm': 'testpass123', + 'phone': '+79991234567' + } + + def test_register_success(self): + """Test successful user registration""" + response = self.client.post(self.register_url, self.user_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertIn('user', response.data) + self.assertIn('tokens', response.data) + self.assertIn('refresh', response.data['tokens']) + self.assertIn('access', response.data['tokens']) + + # Verify user was created + self.assertTrue(User.objects.filter(email=self.user_data['email']).exists()) + + def test_register_passwords_do_not_match(self): + """Test registration fails when passwords don't match""" + data = self.user_data.copy() + data['password_confirm'] = 'differentpass' + + response = self.client.post(self.register_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('non_field_errors', response.data) + + def test_register_duplicate_email(self): + """Test registration fails with duplicate email""" + # Create existing user + UserFactory.create_user(email='test@example.com') + + response = self.client.post(self.register_url, self.user_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('email', response.data) + + def test_register_short_password(self): + """Test registration fails with short password""" + data = self.user_data.copy() + data['password'] = 'short' + data['password_confirm'] = 'short' + + response = self.client.post(self.register_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('password', response.data) + + +class LoginViewTest(APITestCase): + """Tests for LoginView""" + + def setUp(self): + self.login_url = reverse('login') + self.user = UserFactory.create_user() + self.user.set_password('testpass123') + self.user.save() + + self.login_data = { + 'email': self.user.email, + 'password': 'testpass123' + } + + def test_login_success(self): + """Test successful login""" + response = self.client.post(self.login_url, self.login_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('refresh', response.data) + self.assertIn('access', response.data) + + def test_login_invalid_credentials(self): + """Test login fails with invalid credentials""" + data = self.login_data.copy() + data['password'] = 'wrongpass' + + response = self.client.post(self.login_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertIn('error', response.data) + + def test_login_nonexistent_user(self): + """Test login fails for nonexistent user""" + data = { + 'email': 'nonexistent@example.com', + 'password': 'testpass123' + } + + response = self.client.post(self.login_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class CurrentUserViewTest(APITestCase): + """Tests for CurrentUserView""" + + def setUp(self): + self.user = UserFactory.create_user() + ProfileFactory.create_profile(user=self.user) + self.current_user_url = reverse('current_user') + self.tokens = UserService.get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.tokens["access"]}') + + def test_get_current_user_authenticated(self): + """Test getting current user when authenticated""" + response = self.client.get(self.current_user_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['id'], self.user.id) + self.assertEqual(response.data['email'], self.user.email) + self.assertIn('profile', response.data) + + def test_get_current_user_unauthenticated(self): + """Test getting current user when unauthenticated""" + self.client.credentials() # Remove auth header + response = self.client.get(self.current_user_url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class UserUpdateViewTest(APITestCase): + """Tests for UserUpdateView""" + + def setUp(self): + self.user = UserFactory.create_user() + self.update_url = reverse('user_update') + self.tokens = UserService.get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.tokens["access"]}') + + self.update_data = { + 'username': 'updated_username', + 'phone': '+79991112233' + } + + def test_update_user_success(self): + """Test successful user update""" + response = self.client.patch(self.update_url, self.update_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['username'], self.update_data['username']) + self.assertEqual(response.data['phone'], self.update_data['phone']) + + # Verify in database + self.user.refresh_from_db() + self.assertEqual(self.user.username, self.update_data['username']) + + def test_update_user_unauthenticated(self): + """Test user update fails when unauthenticated""" + self.client.credentials() # Remove auth header + response = self.client.patch(self.update_url, self.update_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + +class ProfileDetailViewTest(APITestCase): + """Tests for ProfileDetailView""" + + def setUp(self): + self.user = UserFactory.create_user() + self.profile = ProfileFactory.create_profile(user=self.user) + self.profile_url = reverse('profile_detail') + self.tokens = UserService.get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.tokens["access"]}') + + self.update_data = { + 'first_name': 'John', + 'last_name': 'Doe', + 'bio': 'Updated bio' + } + + def test_get_profile_success(self): + """Test successful profile retrieval""" + response = self.client.get(self.profile_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], self.profile.first_name) + + def test_update_profile_success(self): + """Test successful profile update""" + response = self.client.patch(self.profile_url, self.update_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], self.update_data['first_name']) + self.assertEqual(response.data['last_name'], self.update_data['last_name']) + + # Verify in database + self.profile.refresh_from_db() + self.assertEqual(self.profile.first_name, self.update_data['first_name']) + + def test_profile_created_if_not_exists(self): + """Test profile is created if it doesn't exist""" + # Delete existing profile + self.profile.delete() + + response = self.client.get(self.profile_url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Profile should be created automatically + self.assertTrue(Profile.objects.filter(user=self.user).exists()) + + +class PasswordChangeViewTest(APITestCase): + """Tests for PasswordChangeView""" + + def setUp(self): + self.user = UserFactory.create_user() + self.user.set_password('oldpass123') + self.user.save() + self.password_change_url = reverse('password_change') + self.tokens = UserService.get_tokens_for_user(self.user) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.tokens["access"]}') + + self.password_data = { + 'old_password': 'oldpass123', + 'new_password': 'newpass123', + 'new_password_confirm': 'newpass123' + } + + def test_change_password_success(self): + """Test successful password change""" + response = self.client.post(self.password_change_url, self.password_data, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('message', response.data) + + # Verify password was changed + self.user.refresh_from_db() + self.assertTrue(self.user.check_password('newpass123')) + + def test_change_password_wrong_old_password(self): + """Test password change fails with wrong old password""" + data = self.password_data.copy() + data['old_password'] = 'wrongpass' + + response = self.client.post(self.password_change_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', response.data) + + def test_change_password_passwords_do_not_match(self): + """Test password change fails when new passwords don't match""" + data = self.password_data.copy() + data['new_password_confirm'] = 'differentpass' + + response = self.client.post(self.password_change_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('non_field_errors', response.data) + + +class TokenRefreshViewTest(APITestCase): + """Tests for TokenRefreshView""" + + def setUp(self): + self.user = UserFactory.create_user() + self.refresh_url = reverse('token_refresh') + self.tokens = UserService.get_tokens_for_user(self.user) + + def test_refresh_token_success(self): + """Test successful token refresh""" + data = {'refresh': self.tokens['refresh']} + response = self.client.post(self.refresh_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('access', response.data) + self.assertIn('refresh', response.data) + # New refresh token should be different + # Refresh token may be the same or different depending on implementation + def test_refresh_token_invalid(self): + """Test token refresh fails with invalid refresh token""" + data = {'refresh': 'invalid.token.string'} + response = self.client.post(self.refresh_url, data, format='json') + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertIn('error', response.data) + + def test_refresh_token_missing(self): + """Test token refresh fails without refresh token""" + response = self.client.post(self.refresh_url, {}, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('error', response.data) diff --git a/src/apps/user/urls.py b/src/apps/user/urls.py new file mode 100644 index 0000000..8c195a7 --- /dev/null +++ b/src/apps/user/urls.py @@ -0,0 +1,22 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenVerifyView + +from . import views + +urlpatterns = [ + # Аутентификация + path('register/', views.RegisterView.as_view(), name='register'), + path('login/', views.LoginView.as_view(), name='login'), + path('logout/', views.LogoutView.as_view(), name='logout'), + path('token/refresh/', views.TokenRefreshView.as_view(), name='token_refresh'), + path('token/verify/', TokenVerifyView.as_view(), name='token_verify'), + + # Пользовательские данные + path('me/', views.CurrentUserView.as_view(), name='current_user'), + path('me/update/', views.UserUpdateView.as_view(), name='user_update'), + path('profile/', views.ProfileDetailView.as_view(), name='profile_detail'), + path('profile/full/', views.user_profile_detail, name='profile_full'), + + # Безопасность + path('password/change/', views.PasswordChangeView.as_view(), name='password_change'), +] \ No newline at end of file diff --git a/src/apps/user/views.py b/src/apps/user/views.py new file mode 100644 index 0000000..ee371b7 --- /dev/null +++ b/src/apps/user/views.py @@ -0,0 +1,312 @@ +from django.contrib.auth import authenticate +from django.contrib.auth.hashers import check_password +from rest_framework import status, generics, permissions +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from .models import User +from .services import UserService, ProfileService +from .serializers import ( + UserRegistrationSerializer, + UserSerializer, + LoginSerializer, + TokenSerializer, + PasswordChangeSerializer, + UserUpdateSerializer, + ProfileUpdateSerializer +) + + +class RegisterView(APIView): + """Регистрация нового пользователя""" + + permission_classes = [AllowAny] + + @swagger_auto_schema( + request_body=UserRegistrationSerializer, + responses={201: UserSerializer} + ) + def post(self, request): + serializer = UserRegistrationSerializer(data=request.data) + if serializer.is_valid(): + # Убираем password_confirm из данных для создания пользователя + user_data = serializer.validated_data.copy() + user_data.pop('password_confirm', None) + + user = UserService.create_user(**user_data) + user_serializer = UserSerializer(user) + tokens = UserService.get_tokens_for_user(user) + + return Response({ + 'user': user_serializer.data, + 'tokens': tokens + }, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LoginView(APIView): + """Вход пользователя""" + + permission_classes = [AllowAny] + + @swagger_auto_schema( + request_body=LoginSerializer, + responses={200: TokenSerializer} + ) + def post(self, request): + serializer = LoginSerializer(data=request.data) + if serializer.is_valid(): + email = serializer.validated_data['email'] + password = serializer.validated_data['password'] + + user = authenticate(email=email, password=password) + if user: + tokens = UserService.get_tokens_for_user(user) + return Response(tokens, status=status.HTTP_200_OK) + else: + return Response( + {'error': 'Неверные учетные данные'}, + status=status.HTTP_401_UNAUTHORIZED + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LogoutView(APIView): + """Выход пользователя""" + + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'Authorization', + openapi.IN_HEADER, + description="Bearer ", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={200: 'Успешный выход'} + ) + def post(self, request): + try: + refresh_token = request.data.get('refresh') + if refresh_token: + token = RefreshToken(refresh_token) + token.blacklist() + return Response({'message': 'Успешный выход'}, status=status.HTTP_200_OK) + except Exception: + return Response({'error': 'Неверный токен'}, status=status.HTTP_400_BAD_REQUEST) + + +class CurrentUserView(APIView): + """Получение данных текущего пользователя""" + + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'Authorization', + openapi.IN_HEADER, + description="Bearer ", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={200: UserSerializer} + ) + def get(self, request): + serializer = UserSerializer(request.user) + return Response(serializer.data) + + +class UserUpdateView(APIView): + """Обновление данных пользователя""" + + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + request_body=UserUpdateSerializer, + manual_parameters=[ + openapi.Parameter( + 'Authorization', + openapi.IN_HEADER, + description="Bearer ", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={200: UserSerializer} + ) + def patch(self, request): + serializer = UserUpdateSerializer(request.user, data=request.data, partial=True) + if serializer.is_valid(): + user = UserService.update_user( + request.user.id, + **serializer.validated_data + ) + if user: + user_serializer = UserSerializer(user) + return Response(user_serializer.data) + return Response( + {'error': 'Пользователь не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProfileDetailView(generics.RetrieveUpdateAPIView): + """Получение и обновление профиля пользователя""" + + permission_classes = [IsAuthenticated] + serializer_class = ProfileUpdateSerializer + + def get_object(self): + profile = ProfileService.get_profile_by_user_id(self.request.user.id) + if not profile: + # Если профиль не существует, создаем его + from .models import Profile + profile = Profile.objects.create(user=self.request.user) + return profile + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'Authorization', + openapi.IN_HEADER, + description="Bearer ", + type=openapi.TYPE_STRING, + required=True + ) + ] + ) + def get(self, request, *args, **kwargs): + profile = self.get_object() + serializer = self.get_serializer(profile) + return Response(serializer.data) + + @swagger_auto_schema( + request_body=ProfileUpdateSerializer, + manual_parameters=[ + openapi.Parameter( + 'Authorization', + openapi.IN_HEADER, + description="Bearer ", + type=openapi.TYPE_STRING, + required=True + ) + ] + ) + def patch(self, request, *args, **kwargs): + profile = self.get_object() + serializer = self.get_serializer(profile, data=request.data, partial=True) + if serializer.is_valid(): + updated_profile = ProfileService.update_profile( + request.user.id, + **serializer.validated_data + ) + if updated_profile: + return Response(ProfileUpdateSerializer(updated_profile).data) + return Response( + {'error': 'Профиль не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class PasswordChangeView(APIView): + """Смена пароля""" + + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + request_body=PasswordChangeSerializer, + manual_parameters=[ + openapi.Parameter( + 'Authorization', + openapi.IN_HEADER, + description="Bearer ", + type=openapi.TYPE_STRING, + required=True + ) + ], + responses={200: 'Пароль успешно изменен'} + ) + def post(self, request): + serializer = PasswordChangeSerializer(data=request.data) + if serializer.is_valid(): + user = request.user + old_password = serializer.validated_data['old_password'] + + if check_password(old_password, user.password): + new_password = serializer.validated_data['new_password'] + user.set_password(new_password) + user.save() + return Response( + {'message': 'Пароль успешно изменен'}, + status=status.HTTP_200_OK + ) + else: + return Response( + {'error': 'Неверный старый пароль'}, + status=status.HTTP_400_BAD_REQUEST + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def user_profile_detail(request): + """Получение полных данных профиля пользователя""" + profile_data = ProfileService.get_full_profile_data(request.user.id) + if profile_data: + return Response(profile_data) + return Response( + {'error': 'Профиль не найден'}, + status=status.HTTP_404_NOT_FOUND + ) + + +class TokenRefreshView(APIView): + """Обновление access токена через refresh токен""" + + permission_classes = [AllowAny] + + @swagger_auto_schema( + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'refresh': openapi.Schema(type=openapi.TYPE_STRING, description='Refresh token') + }, + required=['refresh'] + ), + responses={200: TokenSerializer} + ) + def post(self, request): + refresh_token = request.data.get('refresh') + if not refresh_token: + return Response( + {'error': 'Refresh token обязателен'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + refresh = RefreshToken(refresh_token) + return Response({ + 'access': str(refresh.access_token), + 'refresh': str(refresh) + }) + except Exception: + return Response( + {'error': 'Неверный refresh token'}, + status=status.HTTP_401_UNAUTHORIZED + ) \ No newline at end of file diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..53f4ccb --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/src/config/asgi.py b/src/config/asgi.py new file mode 100644 index 0000000..c86952c --- /dev/null +++ b/src/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for the project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +application = get_asgi_application() diff --git a/src/config/celery.py b/src/config/celery.py new file mode 100644 index 0000000..0a3b85a --- /dev/null +++ b/src/config/celery.py @@ -0,0 +1,41 @@ +""" +Celery configuration for the project. + +This module contains Celery configuration and task registration. +""" + +import os + +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +app = Celery("project") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + +# Configure Celery Beat schedule +app.conf.beat_schedule = { + "check-pending-scraping-jobs": { + "task": "apps.scraping.tasks.check_pending_jobs", + "schedule": 300.0, # Every 5 minutes + }, + "process-extracted-data": { + "task": "apps.data_processor.tasks.process_extracted_data", + "schedule": 600.0, # Every 10 minutes + }, +} + +app.conf.timezone = "UTC" + +@app.task(bind=True) +def debug_task(self): + print(f"Request: {self.request!r}") diff --git a/src/config/custom_test_runner.py b/src/config/custom_test_runner.py new file mode 100644 index 0000000..cf513f1 --- /dev/null +++ b/src/config/custom_test_runner.py @@ -0,0 +1,23 @@ +from django.test.runner import DiscoverRunner +import sys + + +class CustomTestRunner(DiscoverRunner): + """Custom test runner that avoids ipdb import issues""" + + def __init__(self, *args, **kwargs): + # Отключаем использование ipdb + import os + os.environ['PYTHONBREAKPOINT'] = 'pdb.set_trace' + super().__init__(*args, **kwargs) + + def run_tests(self, test_labels, extra_tests=None, **kwargs): + # Проверяем, что ipdb не будет импортирован + sys.modules['ipdb'] = None + + try: + return super().run_tests(test_labels, extra_tests, **kwargs) + finally: + # Восстанавливаем модуль если был + if 'ipdb' in sys.modules: + del sys.modules['ipdb'] \ No newline at end of file diff --git a/src/config/settings/__init__.py b/src/config/settings/__init__.py new file mode 100644 index 0000000..18c525d --- /dev/null +++ b/src/config/settings/__init__.py @@ -0,0 +1,9 @@ +""" +Django settings module. +""" + +# This will be overridden by the specific settings file +try: + from .development import * +except ImportError: + from .base import * diff --git a/src/config/settings/base.py b/src/config/settings/base.py new file mode 100644 index 0000000..393b5ab --- /dev/null +++ b/src/config/settings/base.py @@ -0,0 +1,241 @@ +""" +Base settings for Django project. + +Generated by 'django-admin startproject' using Django 3.2.25. +""" + +import os +from pathlib import Path +from decouple import Config, RepositoryEnv + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +# Load environment variables +ENV_FILE = BASE_DIR / ".env" +if ENV_FILE.exists(): + config = Config(RepositoryEnv(str(ENV_FILE))) +else: + from decouple import AutoConfig + config = AutoConfig(search_path=BASE_DIR) + +# Helper function for getting config values +def get_env(key, default=None): + return config(key, default=default) + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = get_env("SECRET_KEY", "django-insecure-development-key-change-in-production") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = get_env("DEBUG", True) +if isinstance(DEBUG, str): + DEBUG = DEBUG.lower() in ('true', '1', 'yes') + +ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", "localhost,127.0.0.1") +if isinstance(ALLOWED_HOSTS, str): + ALLOWED_HOSTS = ALLOWED_HOSTS.split(",") + +# Application definition +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + + # Third-party apps + "rest_framework", + "corsheaders", + "django_celery_beat", + "django_celery_results", + "drf_yasg", + + # Local apps + "apps.user", + +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +# Database +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": get_env("POSTGRES_DB", "project_db"), + "USER": get_env("POSTGRES_USER", "project_user"), + "PASSWORD": get_env("POSTGRES_PASSWORD", "project_password"), + "HOST": get_env("POSTGRES_HOST", "db"), + "PORT": int(get_env("POSTGRES_PORT", "5432")), + "OPTIONS": { + "charset": "utf8mb4", + }, + }, +} + + + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +# Internationalization +LANGUAGE_CODE = "ru-RU" +TIME_ZONE = "UTC" +USE_I18N = True +USE_L10N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] + +# Media files +MEDIA_URL = "/media/" +MEDIA_ROOT = BASE_DIR / "media" + +# Default primary key field type +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Custom user model +AUTH_USER_MODEL = "user.User" + +# REST Framework settings +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticatedOrReadOnly", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ], +} + +# JWT settings +from datetime import timedelta + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "UPDATE_LAST_LOGIN": True, + "ALGORITHM": "HS256", + "SIGNING_KEY": SECRET_KEY, + "VERIFYING_KEY": None, + "AUDIENCE": None, + "ISSUER": None, + "JWK_URL": None, + "LEEWAY": 0, + + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule", + + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + + "JTI_CLAIM": "jti", + + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(minutes=5), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1), +} + +# CORS settings +CORS_ALLOWED_ORIGINS = get_env("CORS_ALLOWED_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000") +if isinstance(CORS_ALLOWED_ORIGINS, str): + CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS.split(",") +CORS_ALLOW_CREDENTIALS = True + +# Logging configuration +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + "simple": { + "format": "{levelname} {message}", + "style": "{", + }, + }, + "handlers": { + "file": { + "level": "INFO", + "class": "logging.FileHandler", + "filename": BASE_DIR / "logs/django.log", + "formatter": "verbose", + }, + "console": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "simple", + }, + }, + "root": { + "handlers": ["console", "file"], + "level": "INFO", + }, + "loggers": { + "django": { + "handlers": ["console", "file"], + "level": "INFO", + "propagate": False, + }, + + }, +} diff --git a/src/config/settings/development.py b/src/config/settings/development.py new file mode 100644 index 0000000..118c30a --- /dev/null +++ b/src/config/settings/development.py @@ -0,0 +1,46 @@ +from .base import * + +# Development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-development-key-change-in-production" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "testserver"] + +# Database for development +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "project_dev", + "USER": "postgres", + "PASSWORD": "postgres", + "HOST": "localhost", + "PORT": "5432", + } +} + +# Celery Configuration for Development +CELERY_BROKER_URL = "redis://localhost:6379/0" +CELERY_RESULT_BACKEND = "redis://localhost:6379/0" +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = "UTC" + +# Email backend for development +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + +# Cache configuration for development +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": "redis://127.0.0.1:6379/1", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} diff --git a/src/config/settings/production.py b/src/config/settings/production.py new file mode 100644 index 0000000..ffcd583 --- /dev/null +++ b/src/config/settings/production.py @@ -0,0 +1,110 @@ +from .base import * + +# Production settings +# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv("SECRET_KEY") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = False + +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",") + +# HTTPS settings +SECURE_SSL_REDIRECT = True +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SECURE_HSTS_SECONDS = 31536000 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True + +# Session security +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# Database for production +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("POSTGRES_DB"), + "USER": os.getenv("POSTGRES_USER"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD"), + "HOST": os.getenv("POSTGRES_HOST"), + "PORT": os.getenv("POSTGRES_PORT", "5432"), + "OPTIONS": { + "sslmode": "require", + }, + } +} + +# Celery Configuration for Production +CELERY_BROKER_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +CELERY_RESULT_BACKEND = os.getenv("REDIS_URL", "redis://redis:6379/0") +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = "UTC" +CELERY_TASK_ALWAYS_EAGER = False +CELERY_WORKER_PREFETCH_MULTIPLIER = 1 + +# Cache configuration for production +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": os.getenv("REDIS_CACHE_URL", "redis://redis:6379/1"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": { + "max_connections": 20, + "retry_on_timeout": True, + } + } + } +} + +# Logging for production +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + }, + "handlers": { + "file": { + "level": "INFO", + "class": "logging.handlers.RotatingFileHandler", + "filename": "/var/log/django/app.log", + "maxBytes": 1024*1024*15, # 15MB + "backupCount": 10, + "formatter": "verbose", + }, + "mail_admins": { + "level": "ERROR", + "class": "django.utils.log.AdminEmailHandler", + }, + }, + "root": { + "handlers": ["file"], + "level": "INFO", + }, + "loggers": { + "django": { + "handlers": ["file"], + "level": "INFO", + "propagate": False, + }, + "apps.data_processor": { + "handlers": ["file"], + "level": "INFO", + "propagate": False, + }, + "apps.scraping": { + "handlers": ["file"], + "level": "INFO", + "propagate": False, + }, + }, +} diff --git a/src/config/urls.py b/src/config/urls.py new file mode 100644 index 0000000..0ad999a --- /dev/null +++ b/src/config/urls.py @@ -0,0 +1,41 @@ +""" +URL Configuration for the project. + +The `urlpatterns` list routes URLs to views. +""" + +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +# Swagger schema view +schema_view = get_schema_view( + openapi.Info( + title="Mostovik API", + default_version="v1", + description="API documentation for Mostovik project", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="contact@mostovik.local"), + license=openapi.License(name="BSD License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/users/", include("apps.user.urls")), + path("api-auth/", include("rest_framework.urls")), + # Swagger documentation + path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), + path("redoc/", schema_view.with_ui("redoc", cache_timeout=0), name="schema-redoc"), +] + +# 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/src/config/wsgi.py b/src/config/wsgi.py new file mode 100644 index 0000000..e4b0f2f --- /dev/null +++ b/src/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for the project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +application = get_wsgi_application() diff --git a/src/manage.py b/src/manage.py new file mode 100644 index 0000000..e58570e --- /dev/null +++ b/src/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main()