diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f5df1e3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +.git +.gitignore +.idea +.vscode +.qoder +.zed +.venv +__pycache__ +*.pyc +*.pyo +.pytest_cache +.ruff_cache +.mypy_cache +.coverage +.coverage.* +htmlcov +dist +build +*.egg-info +logs +media +staticfiles +data +tests +deploy +*.log diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..ed46e27 --- /dev/null +++ b/.env.dev @@ -0,0 +1,26 @@ +# Docker Compose development environment +DJANGO_SETTINGS_MODULE=config.settings.dev + +POSTGRES_HOST=db +POSTGRES_PORT=5432 +POSTGRES_DB=mostovik +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres + +REDIS_HOST=redis +REDIS_CACHE_URL=redis://redis:6379/1 +CELERY_BROKER_URL=redis://redis:6379/0 +CELERY_RESULT_BACKEND=redis://redis:6379/0 + +PORT=8000 +GUNICORN_WORKERS=2 +GUNICORN_TIMEOUT=60 +CELERY_LOG_LEVEL=INFO +CELERY_WORKER_CONCURRENCY=2 + +# Parsers API keys +CHECKO_API_KEY= +ZAKUPKI_TOKEN= + +# 1 to collect static files during migrate service, 0 to skip +COLLECTSTATIC_ON_MIGRATE=0 diff --git a/.env.example b/.env.example deleted file mode 100644 index dfffc7a..0000000 --- a/.env.example +++ /dev/null @@ -1,40 +0,0 @@ -# Файл окружения для разработки -# Скопируйте этот файл в .env и измените значения по необходимости - -# Django Settings -DJANGO_SETTINGS_MODULE=config.settings.production -DEBUG=True -SECRET_KEY=${SECRET_KEY:-django-insecure-dev-key-change-in-production} -ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 - -# Database Settings -POSTGRES_DB=mostovik -POSTGRES_USER=postgres -POSTGRES_PASSWORD=postgres -POSTGRES_HOST=127.0.0.1 -POSTGRES_PORT=5432 -POSTGRES_SSLMODE=disable - -# Redis Settings -REDIS_URL=redis://127.0.0.1:6379/0 -REDIS_CACHE_URL=redis://127.0.0.1:6379/1 - -# Celery Settings -CELERY_BROKER_URL=redis://127.0.0.1:6379/0 -CELERY_RESULT_BACKEND=redis://127.0.0.1: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 - -# Parsers API Tokens -# Токен для zakupki.gov.ru (получить через Госуслуги на https://zakupki.gov.ru/pmd/auth/welcome) -ZAKUPKI_TOKEN= - -# API ключ для checko.ru (информация о юридических лицах) -CHECKO_API_KEY= \ No newline at end of file diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..be3eecf --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,33 @@ +# Docker Compose production example. +# Copy to .env.prod and replace CHANGE_ME values. +DJANGO_SETTINGS_MODULE=config.settings.production +DEBUG=False +SECRET_KEY=CHANGE_ME_PROD_SECRET_KEY +ALLOWED_HOSTS=example.com,api.example.com + +POSTGRES_HOST=CHANGE_ME_POSTGRES_HOST +POSTGRES_PORT=5432 +POSTGRES_DB=mostovik +POSTGRES_USER=postgres +POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD +POSTGRES_SSLMODE=require + +REDIS_CACHE_URL=redis://CHANGE_ME_REDIS_HOST:6379/1 +CELERY_BROKER_URL=redis://CHANGE_ME_REDIS_HOST:6379/0 +CELERY_RESULT_BACKEND=redis://CHANGE_ME_REDIS_HOST:6379/0 + +PORT=8000 +GUNICORN_WORKERS=3 +GUNICORN_TIMEOUT=60 +CELERY_LOG_LEVEL=INFO +CELERY_WORKER_CONCURRENCY=4 + +# Parsers API keys +CHECKO_API_KEY=CHANGE_ME_CHECKO_API_KEY +ZAKUPKI_TOKEN=CHANGE_ME_ZAKUPKI_TOKEN + +# 1 to collect static files during migrate service, 0 to skip +COLLECTSTATIC_ON_MIGRATE=1 + +WEB_IMAGE=registry.example.com/mostovik/web:latest +CELERY_IMAGE=registry.example.com/mostovik/celery:latest diff --git a/.env.test b/.env.test deleted file mode 100644 index 8d1be55..0000000 --- a/.env.test +++ /dev/null @@ -1,25 +0,0 @@ -# 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/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 653e29c..01f73a0 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -61,6 +61,24 @@ jobs: . .venv/bin/activate ruff format src --check + - name: Telegram notify (lint failed) + if: failure() + run: | + set -euo pipefail + if [ -z "${TG_BOT_KEY:-}" ] || [ -z "${TG_CHANNEL:-}" ]; then + echo "TG_BOT_KEY or TG_CHANNEL is not set; skip telegram notification" + exit 0 + fi + + MSG="❌ [mostovik-backend] lint failed + branch=${GITHUB_REF_NAME} + sha=${GITHUB_SHA} + actor=${GITHUB_ACTOR}" + + curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_KEY}/sendMessage" \ + -d "chat_id=${TG_CHANNEL}" \ + --data-urlencode "text=${MSG}" + test: name: Run Tests runs-on: ubuntu-latest @@ -94,7 +112,7 @@ jobs: - name: Run Django tests env: - DJANGO_SETTINGS_MODULE: config.settings.test + DJANGO_SETTINGS_MODULE: settings.test SECRET_KEY: test-secret-key-for-ci run: | set -euo pipefail @@ -102,18 +120,33 @@ jobs: export PYTHONPATH="${PWD}/src:${PYTHONPATH:-}" python src/manage.py test tests --verbosity=2 + - name: Telegram notify (test failed) + if: failure() + run: | + set -euo pipefail + if [ -z "${TG_BOT_KEY:-}" ] || [ -z "${TG_CHANNEL:-}" ]; then + echo "TG_BOT_KEY or TG_CHANNEL is not set; skip telegram notification" + exit 0 + fi + + MSG="❌ [mostovik-backend] test failed + branch=${GITHUB_REF_NAME} + sha=${GITHUB_SHA} + actor=${GITHUB_ACTOR}" + + curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_KEY}/sendMessage" \ + -d "chat_id=${TG_CHANNEL}" \ + --data-urlencode "text=${MSG}" + build_push: name: Build & Push Images runs-on: ubuntu-latest needs: [lint, test] if: | - always() && github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && - (github.ref == 'refs/heads/dev' || ( - (needs.lint.result == 'success' || needs.lint.result == 'skipped') && - (needs.test.result == 'success' || needs.test.result == 'skipped') - )) + (needs.lint.result == 'success' || needs.lint.result == 'skipped') && + (needs.test.result == 'success' || needs.test.result == 'skipped') steps: - name: Checkout code @@ -142,7 +175,11 @@ jobs: echo "${REGISTRY_PASSWORD}" | ./crane auth login --insecure "${REGISTRY_HOST}" -u "${REGISTRY_USER}" --password-stdin - docker build -f ./docker/Dockerfile.web -t "${WEB_IMAGE}:local" . + docker build \ + -f ./docker/Dockerfile \ + --target runtime-web \ + --build-arg INSTALL_DEV=false \ + -t "${WEB_IMAGE}:local" . docker save "${WEB_IMAGE}:local" -o /tmp/web.tar ./crane push --insecure /tmp/web.tar "${REGISTRY}/${WEB_IMAGE}:${BRANCH_TAG}" @@ -151,7 +188,11 @@ jobs: ./crane push --insecure /tmp/web.tar "${REGISTRY}/${WEB_IMAGE}:latest" fi - docker build -f ./docker/Dockerfile.celery -t "${CELERY_IMAGE}:local" . + docker build \ + -f ./docker/Dockerfile \ + --target runtime-celery \ + --build-arg INSTALL_DEV=false \ + -t "${CELERY_IMAGE}:local" . docker save "${CELERY_IMAGE}:local" -o /tmp/celery.tar ./crane push --insecure /tmp/celery.tar "${REGISTRY}/${CELERY_IMAGE}:${BRANCH_TAG}" @@ -165,110 +206,43 @@ jobs: REPO_OWNER="${GITHUB_REPOSITORY%%/*}" echo "Images pushed to ${REGISTRY_HOST}/${REPO_OWNER}/" - deploy_dev: - name: Deploy (dev) - runs-on: ubuntu-latest - needs: [build_push] - if: github.event_name == 'push' && github.ref == 'refs/heads/dev' - concurrency: deploy-dev - environment: dev - - steps: - - name: Checkout code - run: | - REPO_URL=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") - BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" - git clone --depth=1 --branch="${BRANCH}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . - git checkout "${GITHUB_SHA}" - - - name: Deploy via SSH - env: - DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} - DEPLOY_USER: ${{ secrets.DEPLOY_USER }} - DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} - REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} + - name: Telegram notify (build_push failed) + if: failure() run: | set -euo pipefail - BRANCH_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/\//-/g') + if [ -z "${TG_BOT_KEY:-}" ] || [ -z "${TG_CHANNEL:-}" ]; then + echo "TG_BOT_KEY or TG_CHANNEL is not set; skip telegram notification" + exit 0 + fi - mkdir -p ~/.ssh - echo "${DEPLOY_SSH_KEY}" | base64 -d > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - ssh-keyscan -H "${DEPLOY_HOST}" >> ~/.ssh/known_hosts 2>/dev/null + MSG="❌ [mostovik-backend] build_push failed + branch=${GITHUB_REF_NAME} + sha=${GITHUB_SHA} + actor=${GITHUB_ACTOR} + registry=${REGISTRY_HOST}" - ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" "mkdir -p /opt/mostovik-backend" - scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no docker-compose.prod.yml "${DEPLOY_USER}@${DEPLOY_HOST}:/opt/mostovik-backend/" + curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_KEY}/sendMessage" \ + -d "chat_id=${TG_CHANNEL}" \ + --data-urlencode "text=${MSG}" - ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" " - set -e - # Configure insecure registry if not already configured - if ! grep -q '${REGISTRY_HOST}' /etc/docker/daemon.json 2>/dev/null; then - echo '{\"insecure-registries\": [\"${REGISTRY_HOST}\"]}' > /etc/docker/daemon.json - systemctl restart docker - fi - cd /opt/mostovik-backend - echo '${REGISTRY_PASSWORD}' | docker login --username '${REGISTRY_USER}' --password-stdin ${REGISTRY_HOST} - export IMAGE_TAG=${BRANCH_TAG} - docker compose -f docker-compose.prod.yml pull web celery_worker celery_beat - docker compose -f docker-compose.prod.yml down --remove-orphans || true - docker rm -f mostovik_web mostovik_celery_worker mostovik_celery_beat 2>/dev/null || true - docker compose -f docker-compose.prod.yml up -d - docker image prune -f - " - - echo "Deployed ${BRANCH_TAG} to ${DEPLOY_HOST}" - - deploy_prod: - name: Deploy (prod) - runs-on: ubuntu-latest - needs: [build_push] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - concurrency: deploy-prod - environment: prod - - steps: - - name: Checkout code - run: | - REPO_URL=$(echo "${GITHUB_SERVER_URL}" | sed "s|://|://oauth2:${{ gitea.token }}@|") - BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" - git clone --depth=1 --branch="${BRANCH}" "${REPO_URL}/${GITHUB_REPOSITORY}.git" . - git checkout "${GITHUB_SHA}" - - - name: Deploy via SSH + - name: Telegram notify (build_push success) + if: success() env: - DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} - DEPLOY_USER: ${{ secrets.DEPLOY_USER }} - DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} - REGISTRY_USER: ${{ secrets.REGISTRY_USER }} - REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} run: | set -euo pipefail - BRANCH_TAG=$(echo "${GITHUB_REF_NAME}" | sed 's/\//-/g') + if [ -z "${TG_BOT_KEY:-}" ] || [ -z "${TG_CHANNEL:-}" ]; then + echo "TG_BOT_KEY or TG_CHANNEL is not set; skip telegram notification" + exit 0 + fi - mkdir -p ~/.ssh - echo "${DEPLOY_SSH_KEY}" | base64 -d > ~/.ssh/deploy_key - chmod 600 ~/.ssh/deploy_key - ssh-keyscan -H "${DEPLOY_HOST}" >> ~/.ssh/known_hosts 2>/dev/null + MSG="✅ [mostovik-backend] image build & push success + branch=${GITHUB_REF_NAME} + sha=${GITHUB_SHA} + actor=${GITHUB_ACTOR} + registry=${REGISTRY_HOST} + commit=${COMMIT_MESSAGE}" - ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" "mkdir -p /opt/mostovik-backend" - scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no docker-compose.prod.yml "${DEPLOY_USER}@${DEPLOY_HOST}:/opt/mostovik-backend/" - - ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no "${DEPLOY_USER}@${DEPLOY_HOST}" " - set -e - # Configure insecure registry if not already configured - if ! grep -q '${REGISTRY_HOST}' /etc/docker/daemon.json 2>/dev/null; then - echo '{\"insecure-registries\": [\"${REGISTRY_HOST}\"]}' > /etc/docker/daemon.json - systemctl restart docker - fi - cd /opt/mostovik-backend - echo '${REGISTRY_PASSWORD}' | docker login --username '${REGISTRY_USER}' --password-stdin ${REGISTRY_HOST} - export IMAGE_TAG=${BRANCH_TAG} - docker compose -f docker-compose.prod.yml pull web celery_worker celery_beat - docker compose -f docker-compose.prod.yml down --remove-orphans || true - docker rm -f mostovik_web mostovik_celery_worker mostovik_celery_beat 2>/dev/null || true - docker compose -f docker-compose.prod.yml up -d - docker image prune -f - " - - echo "Deployed ${BRANCH_TAG} to ${DEPLOY_HOST}" + curl -fsS -X POST "https://api.telegram.org/bot${TG_BOT_KEY}/sendMessage" \ + -d "chat_id=${TG_CHANNEL}" \ + --data-urlencode "text=${MSG}" diff --git a/.gitignore b/.gitignore index c7f19c6..b35c28c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,10 @@ *.pyc *.pyo -*.pyc __pycache__/ .pytest_cache/ +.ruff_cache/ .coverage +.coverage.* htmlcov/ *.egg-info/ dist/ @@ -19,12 +20,15 @@ venv/ *.sqlite3 media/ staticfiles/ +src/media/ +src/staticfiles/ logs/* !logs/.gitkeep # IDE .vscode/ .idea/ +.qoder/ *.swp *.swo *~ @@ -33,11 +37,9 @@ logs/* .DS_Store Thumbs.db -# Docker -.dockerignore - # Backup files *.bak *.backupdata/ data/ .zed/ +.env.prod diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9594179..a327cb9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,46 +1,64 @@ +default_install_hook_types: [pre-commit, pre-push] +default_stages: [pre-commit] +minimum_pre_commit_version: "3.6.0" + 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$ + name: ruff lint + files: ^(src|tests|scripts)/.*\.py$ args: [--fix, --exit-non-zero-on-fix] exclude: | (?x)^( src/.*/migrations/.*| - src/.*/__pycache__/.* + .*/__pycache__/.* )$ - id: ruff-format - name: ruff format (src only) - files: ^src/.*\.py$ + name: ruff format + files: ^(src|tests|scripts)/.*\.py$ args: [] exclude: | (?x)^( src/.*/migrations/.*| - src/.*/__pycache__/.* + .*/__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)$ + name: check trailing whitespace - id: end-of-file-fixer - name: fix end of file (src only) - files: ^src/.*\.(py|txt|md|yaml|yml)$ + name: fix end of file - id: check-yaml - name: check yaml syntax (src only) - files: ^src/.*\.ya?ml$ + name: check yaml syntax + files: ^(\.gitea/workflows/.*\.ya?ml|docker-compose.*\.ya?ml|.*\.ya?ml)$ - id: check-added-large-files name: check large files args: ["--maxkb=500"] + - id: check-merge-conflict + - id: detect-private-key + + - repo: https://github.com/hadolint/hadolint + rev: v2.12.0 + hooks: + - id: hadolint-docker + name: hadolint dockerfiles + files: ^docker/.*Dockerfile.*$ + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.11.0.1 + hooks: + - id: shellcheck + files: ^(scripts|docker/scripts)/.*\.sh$ + - repo: local hooks: - id: django-check-migrations @@ -54,3 +72,22 @@ repos: src/.*/migrations/.*| src/.*/__pycache__/.* )$ + - id: docker-compose-dev-config + name: docker compose dev config check + entry: docker compose -f docker-compose.dev.yml config -q + language: system + pass_filenames: false + files: ^(docker-compose\.dev\.yml|docker/Dockerfile|docker/scripts/.*\.sh)$ + - id: docker-compose-prod-config + name: docker compose prod config check + entry: docker compose -f docker-compose.prod.yml --env-file .env.prod config -q + language: system + pass_filenames: false + files: ^(docker-compose\.prod\.yml|docker/Dockerfile|docker/scripts/.*\.sh|\.env\.prod)$ + - id: pytest + name: pytest suite + entry: ./scripts/run-tests.sh + language: script + pass_filenames: false + stages: [pre-push] + always_run: true diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d1a88..36941d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ --- +## [0.5.0] - 2026-02-18 + +### Добавлено + +- Fail-fast проверки зависимостей на старте приложения: `apps.core.startup_checks` (DB/Redis с таймаутами и аварийным выходом при недоступности) +- Единый Docker-образ `docker/Dockerfile` и набор entrypoint-скриптов в `docker/scripts/` для web/celery/migrations/check-deps +- Новые шаблоны окружения: `.env.dev`, `.env.prod.example` +- Скрипт `scripts/run-tests.sh` и новая структура директорий `input/` с `.gitkeep` для обработки входящих файлов + +### Изменено + +- Реорганизована структура Django-конфига: удалён `src/config`, добавлены `src/core` и `src/settings`; обновлены импорты и точки входа (`asgi`, `wsgi`, `celery`, `urls`, `manage.py`) +- Обновлены `docker-compose.dev.yml` и `docker-compose.prod.yml` под новую структуру сервисов, env и startup-проверок +- Расширена OpenAPI-документация: + - введены `swagger_tag`, `ErrorResponses` и новые стандартные ответы (`202`, `503`) + - унифицированы и уточнены `responses` в `apps.core.views`, `apps.parsers.views`, `apps.user.views` + - добавлен endpoint-обёртка `TokenVerifySwaggerView` для документации проверки JWT +- Актуализированы CI/CD и инфраструктурные файлы: `.gitea/workflows/ci-cd.yml`, `Makefile`, `.pre-commit-config.yaml`, `pyproject.toml`, `uv.lock`, `README.md`, `tests/README.md` +- Обновлены тесты под новую структуру настроек и API-контракты (`tests/apps/core/*`, `tests/apps/user/*`) + +### Удалено + +- Устаревшие файлы деплоя и окружения: `deploy/*`, `docker/Dockerfile.web`, `docker/Dockerfile.celery`, `.env.example`, `.env.test` +- Устаревшие dependency-файлы и утилиты: `requirements.txt`, `requirements-dev.txt`, `run_tests.py`, `check_tests.py`, `sitecustomize.py` +- Временные/лишние артефакты (включая тестовый xlsx во `src/input/fns/`) + +--- + ## [0.4.1] - 2026-02-02 ### Исправлено diff --git a/CI_CD_SUMMARY.md b/CI_CD_SUMMARY.md deleted file mode 100644 index 64d9abe..0000000 --- a/CI_CD_SUMMARY.md +++ /dev/null @@ -1,52 +0,0 @@ -# CI/CD Summary (Gitea Actions) - -This project uses Gitea Actions for CI/CD, Gitea Registry for images, and SSH + Docker Compose for deploy. - -**Branch Rules** -1. `main` is production releases. Push to `main` builds, pushes images, and deploys to prod. -2. `dev` deploys to the dev stand (host `10.10.0.112`). Push to `dev` builds, pushes images, and deploys to dev. -3. `feature/*` runs CI only. No build, no push, no deploy. -4. Pull requests to `main` and `dev` run CI only. - -**Triggers** -1. `push` to `main`, `dev`, `feature/**`. -2. `pull_request` to `main` and `dev`. - -**Pipeline Jobs** -1. `lint` -2. `test` -3. `build_push` for `main` and `dev` only. -4. `deploy_dev` for `dev` only. -5. `deploy_prod` for `main` only. - -**Python Tooling** -1. Python 3.11. -2. `uv` for dependency sync. -3. Ruff lint and format checks for `src` and `tests`. - -**Image Tags** -1. `${branch}` and `${branch}-${sha7}` for every push to `main` or `dev`. -2. `latest` for `main` only. - -**Registry** -1. Host: `10.10.0.10:3000` (HTTP, insecure). -2. Namespace: ``. -3. Images: `mostovik-web`, `mostovik-celery`. -4. Push uses `crane` with `--insecure`. - -**Deploy** -1. `docker-compose.prod.yml` is copied to `/opt/mostovik-backend/` on the target host. -2. `IMAGE_TAG` is set to the branch name. -3. Docker Compose pulls `web`, `celery_worker`, `celery_beat`, then restarts the stack. -4. Old images are pruned at the end. - -**Secrets** -1. `REGISTRY_USER` -2. `REGISTRY_TOKEN` -3. `DEPLOY_HOST` -4. `DEPLOY_USER` -5. `DEPLOY_SSH_KEY` (base64-encoded private key) - -**Environment-Specific Secrets** -1. Use Gitea environments `dev` and `prod` with the same secret names above. -2. If environment secrets are not available, set repo-level secrets to the correct target before deploying. diff --git a/Makefile b/Makefile index 20281b2..0b92c3f 100644 --- a/Makefile +++ b/Makefile @@ -1,149 +1,103 @@ -# Makefile для удобной работы с проектом +.PHONY: help install setup-dev dev-up dev-down prod-up prod-down logs test test-cov test-fast test-parallel test-failfast lint format type-check security-check pre-commit pre-push migrate createsuperuser shell clean -.PHONY: help install dev-up dev-down test lint format migrate createsuperuser shell +COMPOSE_DEV = docker compose -f docker-compose.dev.yml +COMPOSE_PROD = docker compose -f docker-compose.prod.yml --env-file .env.prod help: - @echo "Доступные команды:" - @echo "" - @echo "🔧 Установка и настройка:" - @echo " make install - Установка зависимостей" - @echo "" - @echo "🐳 Docker управление:" - @echo " make dev-up - Запуск разработческого окружения (Docker)" - @echo " make dev-down - Остановка разработческого окружения" - @echo " make logs - Просмотр логов (Docker)" - @echo "" - @echo "🧪 Тестирование:" - @echo " make test - Запуск тестов (по умолчанию все)" - - @echo " Примеры:" - @echo " make test # Все тесты" - @echo " make test TARGET=user # Только user app" - @echo " make test TARGET=test_models # Только модели" - @echo "" - @echo "🔍 Качество кода:" - @echo " make lint - Проверка кода линтерами" - @echo " make format - Форматирование кода" - @echo " make type-check - Проверка типов" - @echo " make security-check - Проверка безопасности" - @echo " make pre-commit - Запуск pre-commit hooks" - @echo "" - @echo "🗄️ База данных:" - @echo " make migrate - Выполнение миграций Django" - @echo " make createsuperuser - Создание суперпользователя" - @echo "" - @echo "🛠️ Утилиты:" - @echo " make shell - Запуск Django shell" - @echo " make setup-dev - Настройка окружения разработки" - @echo " make clean - Очистка временных файлов" + @echo "Available commands:" + @echo " make install - Install dependencies (uv sync --dev)" + @echo " make setup-dev - Install git hooks (pre-commit + pre-push)" + @echo " make dev-up - Start local stack (dev compose)" + @echo " make dev-down - Stop local stack" + @echo " make prod-up - Start prod compose (without db/redis)" + @echo " make prod-down - Stop prod compose" + @echo " make logs - Follow logs from dev compose" + @echo " make test - Run tests (use TARGET=... to filter)" + @echo " make pre-commit - Run all pre-commit checks" + @echo " make pre-push - Run pre-push checks (tests)" + @echo " make migrate - Run Django migrations" + @echo " make createsuperuser - Create Django superuser" + @echo " make shell - Open Django shell" + @echo " make clean - Cleanup caches and artifacts" install: - uv pip install -r requirements.txt - uv pip install -r requirements-dev.txt + uv sync --dev + +setup-dev: install + ./scripts/setup-precommit.sh dev-up: - docker-compose up -d - @echo "Сервисы запущены. Приложение доступно по адресу: http://localhost:8000" + $(COMPOSE_DEV) up -d dev-down: - docker-compose down + $(COMPOSE_DEV) down -# Универсальная команда для тестирования с поддержкой аргументов -# Использование: -# make test # Все тесты -# make test TARGET=user # Тесты user app -# make test TARGET=models # Тесты моделей -# make test TARGET=views # Тесты представлений -# make test TARGET=serializers # Тесты сериализаторов -# make test TARGET=services # Тесты сервисов -test: - @if [ "$(TARGET)" ]; then \ - echo "🧪 Запуск тестов: $(TARGET)"; \ - python run_tests_simple.py $(TARGET); \ - else \ - echo "🧪 Запуск всех тестов..."; \ - python run_tests_simple.py; \ - fi +prod-up: + $(COMPOSE_PROD) up -d -lint: - @echo "🔍 Проверка кода линтерами..." - ruff check src/ - black --check src/ - isort --check-only src/ - @echo "✅ Линтинг завершен" - -format: - @echo "🎨 Форматирование кода..." - black src/ - isort src/ - ruff check --fix src/ - @echo "✅ Форматирование завершено" - -migrate: - @echo "🗄️ Выполнение миграций..." - cd src && python manage.py makemigrations - cd src && python manage.py migrate - -createsuperuser: - @echo "👤 Создание суперпользователя..." - cd src && python manage.py createsuperuser - -shell: - @echo "🐚 Запуск Django shell..." - cd src && python manage.py shell +prod-down: + $(COMPOSE_PROD) down logs: - @echo "📋 Просмотр логов..." - docker-compose logs -f + $(COMPOSE_DEV) logs -f + +test: + @if [ "$(TARGET)" ]; then \ + echo "Running tests: $(TARGET)"; \ + if [ "$(TARGET)" = "user" ]; then \ + ./scripts/run-tests.sh ../tests/apps/user; \ + elif [ "$(TARGET)" = "models" ] || [ "$(TARGET)" = "views" ] || [ "$(TARGET)" = "serializers" ] || [ "$(TARGET)" = "services" ]; then \ + ./scripts/run-tests.sh ../tests -k "test_$(TARGET)"; \ + elif echo "$(TARGET)" | grep -q '^test_'; then \ + ./scripts/run-tests.sh ../tests -k "$(TARGET)"; \ + else \ + ./scripts/run-tests.sh "$(TARGET)"; \ + fi; \ + else \ + echo "Running full test suite"; \ + ./scripts/run-tests.sh; \ + fi -# Дополнительные команды для тестирования test-cov: - @echo "🧪 Запуск тестов с покрытием..." - python run_tests_simple.py --coverage + ./scripts/run-tests.sh ../tests --cov=src --cov-report=term-missing --cov-report=xml --cov-report=html test-fast: - @echo "🚀 Быстрые тесты (без медленных)..." - python run_tests_simple.py --fast + ./scripts/run-tests.sh ../tests -m "not slow" test-parallel: - @echo "⚡ Параллельный запуск тестов..." - python run_tests_simple.py --parallel=auto + ./scripts/run-tests.sh ../tests -n auto || ./scripts/run-tests.sh ../tests test-failfast: - @echo "❌ Тесты с остановкой при первой ошибке..." - python run_tests_simple.py --failfast - -# Дополнительные команды для качества кода -type-check: - @echo "🔍 Проверка типов с mypy..." - mypy src/ - -security-check: - @echo "🔒 Проверка безопасности..." - bandit -r src/ -f json -o bandit-report.json || bandit -r src/ + ./scripts/run-tests.sh ../tests -x pre-commit: - @echo "🔧 Запуск pre-commit hooks..." - pre-commit run --all-files + uv run pre-commit run --all-files -# Установка и настройка -setup-dev: - @echo "⚙️ Настройка окружения разработки..." - pre-commit install - @echo "✅ Окружение настроено" +pre-push: + uv run pre-commit run --hook-stage pre-push --all-files + +lint: pre-commit + +format: + uv run pre-commit run --all-files ruff-format + uv run pre-commit run --all-files ruff + +type-check: pre-commit + +security-check: pre-commit + +migrate: + cd src && uv run python manage.py makemigrations + cd src && uv run python manage.py migrate + +createsuperuser: + cd src && uv run python manage.py createsuperuser + +shell: + cd src && uv run python manage.py shell clean: - @echo "🧹 Очистка временных файлов..." find . -type f -name "*.pyc" -delete find . -type d -name "__pycache__" -delete - rm -rf *.log - rm -rf htmlcov/ - rm -rf .coverage - rm -rf coverage.xml - rm -rf bandit-report.json - rm -rf .pytest_cache/ - rm -rf .mypy_cache/ - rm -rf tests/__pycache__/ - rm -rf tests/apps/__pycache__/ - rm -rf tests/apps/user/__pycache__/ - @echo "✅ Очистка завершена" + rm -rf *.log htmlcov .coverage coverage.xml bandit-report.json + rm -rf .pytest_cache .mypy_cache .ruff_cache diff --git a/README.md b/README.md index 9a1e509..ea18f0b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ # Через Celery from apps.parsers.tasks import ( parse_industrial_production, - parse_manufactures, + parse_manufactures, parse_inspections, sync_inspections, ) @@ -62,22 +62,20 @@ sync_inspections.delay() ``` mostovik-backend/ ├── src/ # Исходный код Django -│ ├── config/ # Конфигурация Django -│ │ ├── settings/ # Настройки (base, dev, prod, test) -│ │ ├── celery.py # Конфигурация Celery -│ │ └── urls.py # URL маршруты │ ├── apps/ # Django приложения │ │ └── user/ # Приложение пользователей +│ ├── core/ # Runtime-конфигурация проекта (urls/asgi/wsgi/celery) +│ ├── settings/ # Django settings (base, dev, production, test) │ └── manage.py # Управление Django ├── tests/ # Тесты (в корне проекта) │ ├── apps/user/ # Тесты для user app │ ├── conftest.py # Конфигурация pytest │ └── README.md # Документация по тестам -├── docker/ # Docker конфигурации -├── deploy/ # Файлы развертывания +├── docker/ # Docker конфигурации ├── pyproject.toml # Конфигурация проекта и инструментов ├── Makefile # Команды для разработки -└── docker-compose.yml # Docker Compose для разработки +├── docker-compose.dev.yml # Docker Compose для разработки +└── docker-compose.prod.yml # Docker Compose для production ``` ## Быстрый старт (локальная разработка) @@ -106,24 +104,21 @@ make setup-dev ### 2. Настройка окружения ```bash -# Копирование файла окружения -cp .env.example .env - -# Редактирование .env файла по необходимости -nano .env +# Для dev compose уже готов файл .env.dev (можно использовать как есть). +# Для prod compose заполните .env.prod на основе .env.prod.example. ``` ### 3. Запуск с Docker Compose (рекомендуется) ```bash -# Запуск всех сервисов -docker-compose up -d +# Запуск всех dev сервисов (db, redis, migrate, web, celery) +docker compose -f docker-compose.dev.yml up -d # Проверка состояния контейнеров -docker-compose ps +docker compose -f docker-compose.dev.yml ps # Просмотр логов -docker-compose logs -f web +docker compose -f docker-compose.dev.yml logs -f web ``` ### 4. Ручная настройка (без Docker) @@ -152,10 +147,10 @@ python manage.py createsuperuser python manage.py runserver # Запуск Celery worker (в отдельном терминале) -celery -A config worker --loglevel=info +celery -A core worker --loglevel=info # Запуск Celery beat (в отдельном терминале) -celery -A config beat --loglevel=info +celery -A core beat --loglevel=info ``` ## API Endpoints @@ -177,57 +172,18 @@ celery -A config beat --loglevel=info ### Аутентификация - `POST /api/api-token-auth/` - Получение API токена -## Развертывание на сервере Astra Linux +## Развертывание -### Автоматическое развертывание +Используется `docker-compose.prod.yml` и файл окружения `.env.prod`. ```bash -# Сделать скрипт исполняемым -chmod +x deploy/scripts/deploy.sh +# 1) Заполнить .env.prod (можно взять шаблон .env.prod.example) -# Запуск скрипта развертывания -sudo ./deploy/scripts/deploy.sh -``` +# 2) Собрать и запустить сервисы +docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build -### Ручное развертывание - -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 +# 3) Проверить состояние +docker compose -f docker-compose.prod.yml --env-file .env.prod ps ``` ## Мониторинг и логирование @@ -248,7 +204,7 @@ journalctl -u celery-worker -f ### Мониторинг Celery ```bash # Запуск Flower (в отдельном терминале) -celery -A config flower +celery -A core flower # Доступ через браузер: http://localhost:5555 ``` @@ -390,4 +346,4 @@ MIT License - **Парсеры Минпромторга** (сертификаты, производители) - **Модуль apps.parsers** с клиентами, сервисами и задачами Celery - **Django Admin** для управления записями парсеров -- Дедупликация по unique constraints \ No newline at end of file +- Дедупликация по unique constraints diff --git a/check_tests.py b/check_tests.py deleted file mode 100644 index 73b7139..0000000 --- a/check_tests.py +++ /dev/null @@ -1,60 +0,0 @@ -#!/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 ProfileFactory, UserFactory - - 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🏁 Проверка завершена!") diff --git a/deploy/apache/project.conf b/deploy/apache/project.conf deleted file mode 100644 index 73d7703..0000000 --- a/deploy/apache/project.conf +++ /dev/null @@ -1,80 +0,0 @@ -# Конфигурация 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 deleted file mode 100644 index 502c7da..0000000 --- a/deploy/monitoring/prometheus.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Конфигурация мониторинга 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 deleted file mode 100644 index 9403f3f..0000000 --- a/deploy/scripts/deploy.sh +++ /dev/null @@ -1,127 +0,0 @@ -#!/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 deleted file mode 100644 index f54f84b..0000000 --- a/deploy/systemd/celery-beat.service +++ /dev/null @@ -1,15 +0,0 @@ -[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 deleted file mode 100644 index a7cabb0..0000000 --- a/deploy/systemd/celery-worker.service +++ /dev/null @@ -1,16 +0,0 @@ -[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 deleted file mode 100644 index 20b059f..0000000 --- a/deploy/systemd/gunicorn.service +++ /dev/null @@ -1,27 +0,0 @@ -[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.dev.yml b/docker-compose.dev.yml index 0c5378b..4fa0ef6 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,79 +1,113 @@ +x-app-build: &app-build + context: . + dockerfile: docker/Dockerfile + args: + INSTALL_DEV: "true" + services: - web: - build: - dockerfile: ./docker/Dockerfile.web - context: . - container_name: mostovik_web + db: + image: postgres:15.10 + container_name: mostovik_db + restart: unless-stopped + env_file: + - .env.dev + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - ./data/db:/var/lib/postgresql/data + - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 3s + retries: 10 + + redis: + image: redis:7-alpine + container_name: mostovik_redis restart: unless-stopped ports: - - "8000:8000" -# environment: -# - DJANGO_SETTINGS_MODULE=config.settings.dev -# - DEBUG=False -# - SECRET_KEY=${SECRET_KEY:-django-insecure-dev-key-change-in-production} -# - ALLOWED_HOSTS=* -# - POSTGRES_HOST=127.0.0.1 -# - POSTGRES_PORT=5432 -# - POSTGRES_DB=mostovik -# - POSTGRES_USER=postgres -# - POSTGRES_PASSWORD=postgres -# - POSTGRES_SSLMODE=disable -# - REDIS_URL=redis://127.0.0.1:6379/0 -# - CELERY_BROKER_URL=redis://127.0.0.1:6379/0 -# - CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/0 + - "6379:6379" volumes: + - ./data/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 10 + + migrate: + build: + <<: *app-build + target: runtime-web + container_name: mostovik_migrate + env_file: + - .env.dev + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - ./input:/app/input + command: ["/app/docker/scripts/migrate.sh"] + restart: "no" + + web: + build: + <<: *app-build + target: runtime-web + container_name: mostovik_web + restart: unless-stopped + env_file: + - .env.dev + depends_on: + migrate: + condition: service_completed_successfully + ports: + - "8000:8000" + volumes: + - ./src:/app/src - ./logs:/app/logs - ./media:/app/media - ./staticfiles:/app/staticfiles - 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 --timeout 120" + - ./input:/app/input + command: ["/app/docker/scripts/start-web-dev.sh"] celery_worker: build: - dockerfile: ./docker/Dockerfile.celery - context: . + <<: *app-build + target: runtime-celery container_name: mostovik_celery_worker restart: unless-stopped - network_mode: host -# environment: -# - DJANGO_SETTINGS_MODULE=config.settings.dev -# - DEBUG=True -# - SECRET_KEY=${SECRET_KEY:-django-insecure-dev-key-change-in-production} -# - POSTGRES_HOST=127.0.0.1 -# - POSTGRES_PORT=5432 -# - POSTGRES_DB=mostovik -# - POSTGRES_USER=postgres -# - POSTGRES_PASSWORD=postgres -# - POSTGRES_SSLMODE=disable -# - REDIS_URL=redis://127.0.0.1:6379/0 -# - CELERY_BROKER_URL=redis://127.0.0.1:6379/0 -# - CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/0 + env_file: + - .env.dev + depends_on: + migrate: + condition: service_completed_successfully volumes: + - ./src:/app/src - ./logs:/app/logs - command: celery -A config worker + - ./input:/app/input + command: ["/app/docker/scripts/start-celery-worker.sh"] celery_beat: build: - dockerfile: ./docker/Dockerfile.celery - context: . + <<: *app-build + target: runtime-celery container_name: mostovik_celery_beat restart: unless-stopped - network_mode: host -# environment: -# - DJANGO_SETTINGS_MODULE=config.settings.dev -# - DEBUG=False -# - SECRET_KEY=${SECRET_KEY:-django-insecure-dev-key-change-in-production} -# - POSTGRES_HOST=127.0.0.1 -# - POSTGRES_PORT=5432 -# - POSTGRES_DB=mostovik -# - POSTGRES_USER=postgres -# - POSTGRES_PASSWORD=postgres -# - POSTGRES_SSLMODE=disable -# - REDIS_URL=redis://127.0.0.1:6379/0 -# - CELERY_BROKER_URL=redis://127.0.0.1:6379/0 -# - CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/0 + env_file: + - .env.dev + depends_on: + migrate: + condition: service_completed_successfully volumes: + - ./src:/app/src - ./logs:/app/logs - command: celery -A config beat + - ./input:/app/input + command: ["/app/docker/scripts/start-celery-beat.sh"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2c1645d..b2189c9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,73 +1,74 @@ +x-web-build: &web-build + context: . + dockerfile: docker/Dockerfile + target: runtime-web + args: + INSTALL_DEV: "false" + +x-celery-build: &celery-build + context: . + dockerfile: docker/Dockerfile + target: runtime-celery + args: + INSTALL_DEV: "false" + services: + migrate: + build: *web-build + image: ${WEB_IMAGE:-mostovik/web:latest} + container_name: mostovik_migrate + env_file: + - .env.prod + volumes: + - ./input:/app/input + command: ["/app/docker/scripts/migrate.sh"] + restart: "no" + web: - image: 10.10.0.10:3000/avm/mostovik-web:${IMAGE_TAG:-dev} + build: *web-build + image: ${WEB_IMAGE:-mostovik/web:latest} container_name: mostovik_web restart: unless-stopped + env_file: + - .env.prod + depends_on: + migrate: + condition: service_completed_successfully ports: - "8000:8000" -# environment: -# - DJANGO_SETTINGS_MODULE=config.settings.dev -# - DEBUG=False -# - SECRET_KEY=${SECRET_KEY:-django-insecure-dev-key-change-in-production} -# - ALLOWED_HOSTS=* -# - POSTGRES_HOST=127.0.0.1 -# - POSTGRES_PORT=5432 -# - POSTGRES_DB=mostovik -# - POSTGRES_USER=postgres -# - POSTGRES_PASSWORD=postgres -# - POSTGRES_SSLMODE=disable -# - REDIS_URL=redis://127.0.0.1:6379/0 -# - CELERY_BROKER_URL=redis://127.0.0.1:6379/0 -# - CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/0 volumes: - ./logs:/app/logs - ./media:/app/media - ./staticfiles:/app/staticfiles - 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 --timeout 120" + - ./input:/app/input + command: ["/app/docker/scripts/start-web.sh"] celery_worker: - image: 10.10.0.10:3000/avm/mostovik-celery:${IMAGE_TAG:-dev} + build: *celery-build + image: ${CELERY_IMAGE:-mostovik/celery:latest} container_name: mostovik_celery_worker restart: unless-stopped - network_mode: host -# environment: -# - DJANGO_SETTINGS_MODULE=config.settings.dev -# - DEBUG=True -# - SECRET_KEY=${SECRET_KEY:-django-insecure-dev-key-change-in-production} -# - POSTGRES_HOST=127.0.0.1 -# - POSTGRES_PORT=5432 -# - POSTGRES_DB=mostovik -# - POSTGRES_USER=postgres -# - POSTGRES_PASSWORD=postgres -# - POSTGRES_SSLMODE=disable -# - REDIS_URL=redis://127.0.0.1:6379/0 -# - CELERY_BROKER_URL=redis://127.0.0.1:6379/0 -# - CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/0 + env_file: + - .env.prod + depends_on: + migrate: + condition: service_completed_successfully volumes: - ./logs:/app/logs - command: celery -A config worker + - ./input:/app/input + command: ["/app/docker/scripts/start-celery-worker.sh"] celery_beat: - image: 10.10.0.10:3000/avm/mostovik-celery:${IMAGE_TAG:-dev} + build: *celery-build + image: ${CELERY_IMAGE:-mostovik/celery:latest} container_name: mostovik_celery_beat restart: unless-stopped - network_mode: host -# environment: -# - DJANGO_SETTINGS_MODULE=config.settings.dev -# - DEBUG=False -# - SECRET_KEY=${SECRET_KEY:-django-insecure-dev-key-change-in-production} -# - POSTGRES_HOST=127.0.0.1 -# - POSTGRES_PORT=5432 -# - POSTGRES_DB=mostovik -# - POSTGRES_USER=postgres -# - POSTGRES_PASSWORD=postgres -# - POSTGRES_SSLMODE=disable -# - REDIS_URL=redis://127.0.0.1:6379/0 -# - CELERY_BROKER_URL=redis://127.0.0.1:6379/0 -# - CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/0 + env_file: + - .env.prod + depends_on: + migrate: + condition: service_completed_successfully volumes: - ./logs:/app/logs - command: celery -A config beat + - ./input:/app/input + command: ["/app/docker/scripts/start-celery-beat.sh"] diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..129f98e --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,110 @@ +FROM python:3.11-slim-bookworm AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv + +WORKDIR /app + +RUN groupadd -r appgroup && useradd -r -g appgroup -m appuser + +# Install uv binary. +COPY --from=ghcr.io/astral-sh/uv:0.7.2 /uv /uvx /usr/local/bin/ + + +FROM base AS builder + +ARG INSTALL_DEV=false + +# hadolint ignore=DL3008 +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + gcc \ + libpq-dev \ + libffi-dev \ + libxml2-dev \ + libxslt1-dev \ + zlib1g-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml uv.lock ./ + +RUN if [ "${INSTALL_DEV}" = "true" ]; then \ + uv sync --frozen --no-install-project --dev; \ + else \ + uv sync --frozen --no-install-project; \ + fi + + +FROM base AS runtime-base + +# hadolint ignore=DL3008 +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + libpq5 \ + libffi8 \ + libxml2 \ + libxslt1.1 \ + zlib1g \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/.venv /app/.venv +COPY src/ ./src/ +COPY docker/scripts/ ./docker/scripts/ + +RUN mkdir -p logs media staticfiles input/fns input/fns/processed input/fns/failed src/static \ + && chmod +x /app/docker/scripts/*.sh \ + && chown -R appuser:appgroup /app + +ENV PATH="/app/.venv/bin:${PATH}" \ + PYTHONPATH=/app/src + +USER appuser + + +FROM runtime-base AS runtime-web + +EXPOSE 8000 +CMD ["/app/docker/scripts/start-web.sh"] + + +FROM runtime-base AS runtime-celery + +USER root + +# Playwright/Chromium runtime dependencies. +# hadolint ignore=DL3008 +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libnss3 \ + libnspr4 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libdbus-1-3 \ + libxkbcommon0 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxrandr2 \ + libgbm1 \ + libasound2 \ + libpango-1.0-0 \ + libcairo2 \ + libatspi2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright +RUN python -m playwright install chromium \ + && chown -R appuser:appgroup /app + +USER appuser + +CMD ["/app/docker/scripts/start-celery-worker.sh"] diff --git a/docker/Dockerfile.celery b/docker/Dockerfile.celery deleted file mode 100644 index ae0b429..0000000 --- a/docker/Dockerfile.celery +++ /dev/null @@ -1,62 +0,0 @@ -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 \ - # Зависимости для Playwright/Chromium - libnss3 \ - libnspr4 \ - libatk1.0-0 \ - libatk-bridge2.0-0 \ - libcups2 \ - libdrm2 \ - libdbus-1-3 \ - libxkbcommon0 \ - libxcomposite1 \ - libxdamage1 \ - libxfixes3 \ - libxrandr2 \ - libgbm1 \ - libasound2 \ - libpango-1.0-0 \ - libcairo2 \ - libatspi2.0-0 \ - && 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 src/logs - -# PYTHONPATH для доступа к модулям -ENV PYTHONPATH=/app/src -ENV DJANGO_SETTINGS_MODULE=config.settings.dev - -# Создание пользователя для запуска приложения -RUN groupadd -r appgroup && useradd -r -g appgroup appuser - -# Установка Playwright браузеров для appuser -ENV PLAYWRIGHT_BROWSERS_PATH=/app/.playwright -RUN playwright install chromium --with-deps || true -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 deleted file mode 100644 index 29fd0a9..0000000 --- a/docker/Dockerfile.web +++ /dev/null @@ -1,45 +0,0 @@ -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 src/logs src/static src/staticfiles src/media - -# PYTHONPATH для доступа к модулям -ENV PYTHONPATH=/app/src -ENV DJANGO_SETTINGS_MODULE=config.settings.dev - -# Создание пользователя для запуска приложения -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/scripts/check-deps.sh b/docker/scripts/check-deps.sh new file mode 100755 index 0000000..bbdb132 --- /dev/null +++ b/docker/scripts/check-deps.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh +set -eu + +export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-settings.dev}" +export STARTUP_COMPONENT="${STARTUP_COMPONENT:-container}" + +python - <<'PY' +import os + +import django + +django.setup() + +from apps.core.startup_checks import run_startup_checks + +run_startup_checks(component=os.environ["STARTUP_COMPONENT"]) +print(f"[startup:{os.environ['STARTUP_COMPONENT']}] dependency checks passed") +PY diff --git a/docker/scripts/migrate.sh b/docker/scripts/migrate.sh new file mode 100755 index 0000000..c9d3006 --- /dev/null +++ b/docker/scripts/migrate.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env sh +set -eu + +export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-settings.dev}" +export STARTUP_COMPONENT="migrate" + +/app/docker/scripts/check-deps.sh + +python src/manage.py migrate --noinput + +if [ "${COLLECTSTATIC_ON_MIGRATE:-0}" = "1" ]; then + python src/manage.py collectstatic --noinput +fi + +echo "[startup:migrate] migrations completed" diff --git a/docker/scripts/start-celery-beat.sh b/docker/scripts/start-celery-beat.sh new file mode 100755 index 0000000..5b52f6b --- /dev/null +++ b/docker/scripts/start-celery-beat.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-settings.production}" + +exec celery -A core beat \ + --loglevel="${CELERY_LOG_LEVEL:-INFO}" \ + --scheduler django_celery_beat.schedulers:DatabaseScheduler diff --git a/docker/scripts/start-celery-worker.sh b/docker/scripts/start-celery-worker.sh new file mode 100755 index 0000000..367ea24 --- /dev/null +++ b/docker/scripts/start-celery-worker.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env sh +set -eu + +export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-settings.production}" + +exec celery -A core worker \ + --loglevel="${CELERY_LOG_LEVEL:-INFO}" \ + --concurrency="${CELERY_WORKER_CONCURRENCY:-2}" diff --git a/docker/scripts/start-web-dev.sh b/docker/scripts/start-web-dev.sh new file mode 100755 index 0000000..fde9d77 --- /dev/null +++ b/docker/scripts/start-web-dev.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -eu + +export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-settings.dev}" + +exec python src/manage.py runserver "0.0.0.0:${PORT:-8000}" diff --git a/docker/scripts/start-web.sh b/docker/scripts/start-web.sh new file mode 100755 index 0000000..9976da6 --- /dev/null +++ b/docker/scripts/start-web.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +set -eu + +export DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-settings.production}" + +exec gunicorn core.wsgi:application \ + --bind "0.0.0.0:${PORT:-8000}" \ + --workers "${GUNICORN_WORKERS:-3}" \ + --timeout "${GUNICORN_TIMEOUT:-60}" \ + --access-logfile "-" \ + --error-logfile "-" diff --git a/ттт b/input/.gitkeep similarity index 100% rename from ттт rename to input/.gitkeep diff --git a/input/fns/.gitkeep b/input/fns/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/input/fns/failed/.gitkeep b/input/fns/failed/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/input/fns/processed/.gitkeep b/input/fns/processed/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index acef472..c52be62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,11 +104,14 @@ build-backend = "setuptools.build_meta" [tool.setuptools] packages = ["src"] +[tool.uv] +package = false + # ================================================================================== # PYTEST CONFIGURATION # ================================================================================== [tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "config.settings.test" +DJANGO_SETTINGS_MODULE = "settings.test" django_find_project = false testpaths = ["tests"] addopts = [ @@ -123,6 +126,7 @@ addopts = [ markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "e2e: marks end-to-end tests", "integration: marks tests as integration tests", "unit: marks tests as unit tests", "models: marks tests for models", @@ -268,11 +272,10 @@ max-complexity = 10 # Ignore `E402` (import violations) in all `__init__.py` files "__init__.py" = ["E402"] # Ignore star imports and related errors in settings -"src/config/settings/*" = ["F403", "F405", "E402"] -# Ignore star imports in test runner files -"check_tests.py" = ["F403"] -"run_tests.py" = ["F403"] -"run_tests_simple.py" = ["F403"] +"src/settings/*" = ["F403", "F405", "E402"] +# Ignore expected dev/test hardcoded values in non-production settings +"src/settings/dev.py" = ["S105"] +"src/settings/test.py" = ["S105"] # Ignore complexity issues in tests "tests/*" = ["C901", "S101"] "**/test_*" = ["C901", "S101"] @@ -384,7 +387,7 @@ module = "tests.*" disallow_untyped_defs = false [tool.django-stubs] -django_settings_module = "config.settings.development" +django_settings_module = "settings.dev" # ================================================================================== # BANDIT CONFIGURATION (Security) diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index 0659631..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,435 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml --extra dev --output-file=requirements-dev.txt -alabaster==0.7.16 - # via sphinx -amqp==5.3.1 - # via kombu -asgiref==3.11.0 - # via - # django - # django-cors-headers -attrs==25.4.0 - # via - # outcome - # service-identity - # trio - # twisted -automat==25.4.16 - # via twisted -babel==2.17.0 - # via sphinx -beautifulsoup4==4.12.3 - # via mostovik-backend (pyproject.toml) -billiard==4.2.4 - # via celery -black==23.12.1 - # via mostovik-backend (pyproject.toml) -celery==5.3.6 - # via - # mostovik-backend (pyproject.toml) - # django-celery-beat - # django-celery-results - # flower -certifi==2026.1.4 - # via - # requests - # selenium -cffi==2.0.0 - # via cryptography -cfgv==3.5.0 - # via pre-commit -charset-normalizer==3.4.4 - # via requests -click==8.1.7 - # via - # mostovik-backend (pyproject.toml) - # black - # celery - # click-didyoumean - # click-plugins - # click-repl - # typer -click-didyoumean==0.3.1 - # via celery -click-plugins==1.1.1.2 - # via celery -click-repl==0.3.0 - # via celery -constantly==23.10.4 - # via twisted -coreapi==2.3.3 - # via - # mostovik-backend (pyproject.toml) - # django-rest-swagger - # openapi-codec -coreschema==0.0.4 - # via coreapi -coverage==7.4.0 - # via - # mostovik-backend (pyproject.toml) - # pytest-cov -cron-descriptor==2.0.6 - # via django-celery-beat -cryptography==42.0.5 - # via - # mostovik-backend (pyproject.toml) - # pyopenssl - # scrapy - # service-identity -cssselect==1.3.0 - # via - # parsel - # scrapy -defusedxml==0.7.1 - # via scrapy -distlib==0.4.0 - # via virtualenv -django==3.2.25 - # via - # mostovik-backend (pyproject.toml) - # django-celery-beat - # django-celery-results - # django-cors-headers - # django-debug-toolbar - # django-extensions - # django-filter - # django-redis - # django-timezone-field - # djangorestframework - # djangorestframework-simplejwt - # drf-yasg - # model-bakery -django-celery-beat==2.6.0 - # via mostovik-backend (pyproject.toml) -django-celery-results==2.5.1 - # via mostovik-backend (pyproject.toml) -django-cors-headers==4.3.1 - # via mostovik-backend (pyproject.toml) -django-debug-toolbar==4.2.0 - # via mostovik-backend (pyproject.toml) -django-extensions==3.2.3 - # via mostovik-backend (pyproject.toml) -django-filter==23.5 - # via mostovik-backend (pyproject.toml) -django-redis==5.4.0 - # via mostovik-backend (pyproject.toml) -django-rest-swagger==2.2.0 - # via mostovik-backend (pyproject.toml) -django-timezone-field==7.2.1 - # via django-celery-beat -djangorestframework==3.14.0 - # via - # mostovik-backend (pyproject.toml) - # django-rest-swagger - # djangorestframework-simplejwt - # drf-yasg -djangorestframework-simplejwt==5.3.1 - # via mostovik-backend (pyproject.toml) -docutils==0.20.1 - # via - # sphinx - # sphinx-rtd-theme -drf-yasg==1.21.10 - # via mostovik-backend (pyproject.toml) -factory-boy==3.3.0 - # via mostovik-backend (pyproject.toml) -faker==40.1.2 - # via factory-boy -filelock==3.20.3 - # via - # tldextract - # virtualenv -flake8==6.1.0 - # via mostovik-backend (pyproject.toml) -flower==2.0.1 - # via mostovik-backend (pyproject.toml) -gevent==23.9.1 - # via mostovik-backend (pyproject.toml) -greenlet==3.3.0 - # via gevent -gunicorn==21.2.0 - # via mostovik-backend (pyproject.toml) -h11==0.16.0 - # via wsproto -humanize==4.15.0 - # via flower -hyperlink==21.0.0 - # via twisted -identify==2.6.16 - # via pre-commit -idna==3.11 - # via - # hyperlink - # requests - # tldextract - # trio -imagesize==1.4.1 - # via sphinx -incremental==24.11.0 - # via twisted -inflection==0.5.1 - # via drf-yasg -iniconfig==2.3.0 - # via pytest -isort==5.13.2 - # via mostovik-backend (pyproject.toml) -itemadapter==0.13.1 - # via - # itemloaders - # scrapy -itemloaders==1.3.2 - # via scrapy -itypes==1.2.0 - # via coreapi -jinja2==3.1.6 - # via - # coreschema - # sphinx -jmespath==1.0.1 - # via - # itemloaders - # parsel -kombu==5.6.2 - # via celery -lxml==6.0.2 - # via - # parsel - # scrapy -markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -mccabe==0.7.0 - # via flake8 -model-bakery==1.17.0 - # via mostovik-backend (pyproject.toml) -mypy-extensions==1.1.0 - # via black -nodeenv==1.10.0 - # via pre-commit -numpy==1.24.4 - # via - # mostovik-backend (pyproject.toml) - # pandas -openapi-codec==1.3.2 - # via django-rest-swagger -outcome==1.3.0.post0 - # via - # trio - # trio-websocket -packaging==25.0 - # via - # black - # drf-yasg - # gunicorn - # incremental - # kombu - # parsel - # pytest - # scrapy - # sphinx -pandas==2.0.3 - # via mostovik-backend (pyproject.toml) -parsel==1.10.0 - # via - # itemloaders - # scrapy -pathspec==1.0.3 - # via black -pillow==12.1.0 - # via mostovik-backend (pyproject.toml) -platformdirs==4.5.1 - # via - # black - # virtualenv -pluggy==1.6.0 - # via pytest -pre-commit==3.6.0 - # via mostovik-backend (pyproject.toml) -prometheus-client==0.24.1 - # via flower -prompt-toolkit==3.0.52 - # via click-repl -protego==0.5.0 - # via scrapy -psycopg2-binary==2.9.9 - # via mostovik-backend (pyproject.toml) -pyasn1==0.6.2 - # via - # pyasn1-modules - # service-identity -pyasn1-modules==0.4.2 - # via service-identity -pycodestyle==2.11.1 - # via flake8 -pycparser==2.23 - # via cffi -pydispatcher==2.0.7 - # via scrapy -pyflakes==3.1.0 - # via flake8 -pygments==2.19.2 - # via sphinx -pyjwt==2.10.1 - # via djangorestframework-simplejwt -pyopenssl==25.1.0 - # via scrapy -pysocks==1.7.1 - # via urllib3 -pytest==7.4.4 - # via - # mostovik-backend (pyproject.toml) - # pytest-cov - # pytest-django -pytest-cov==4.1.0 - # via mostovik-backend (pyproject.toml) -pytest-django==4.7.0 - # via mostovik-backend (pyproject.toml) -python-crontab==3.3.0 - # via django-celery-beat -python-dateutil==2.8.2 - # via - # mostovik-backend (pyproject.toml) - # celery - # pandas -python-decouple==3.8 - # via mostovik-backend (pyproject.toml) -python-dotenv==1.0.1 - # via mostovik-backend (pyproject.toml) -python-json-logger==2.0.7 - # via mostovik-backend (pyproject.toml) -pytz==2024.1 - # via - # mostovik-backend (pyproject.toml) - # django - # djangorestframework - # drf-yasg - # flower - # pandas -pyyaml==6.0.3 - # via - # drf-yasg - # pre-commit -queuelib==1.8.0 - # via scrapy -redis==5.0.3 - # via - # mostovik-backend (pyproject.toml) - # django-redis -requests==2.31.0 - # via - # mostovik-backend (pyproject.toml) - # coreapi - # requests-file - # sphinx - # tldextract -requests-file==3.0.1 - # via tldextract -ruff==0.1.14 - # via mostovik-backend (pyproject.toml) -scrapy==2.11.2 - # via mostovik-backend (pyproject.toml) -selenium==4.17.2 - # via mostovik-backend (pyproject.toml) -service-identity==24.2.0 - # via scrapy -setuptools==80.9.0 - # via scrapy -simplejson==3.20.2 - # via django-rest-swagger -six==1.17.0 - # via python-dateutil -sniffio==1.3.1 - # via trio -snowballstemmer==3.0.1 - # via sphinx -sortedcontainers==2.4.0 - # via trio -soupsieve==2.8.2 - # via beautifulsoup4 -sphinx==7.2.6 - # via - # mostovik-backend (pyproject.toml) - # sphinx-rtd-theme - # sphinxcontrib-jquery -sphinx-rtd-theme==2.0.0 - # via mostovik-backend (pyproject.toml) -sphinxcontrib-applehelp==2.0.0 - # via sphinx -sphinxcontrib-devhelp==2.0.0 - # via sphinx -sphinxcontrib-htmlhelp==2.1.0 - # via sphinx -sphinxcontrib-jquery==4.1 - # via sphinx-rtd-theme -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==2.0.0 - # via sphinx -sphinxcontrib-serializinghtml==2.0.0 - # via sphinx -sqlparse==0.5.5 - # via - # django - # django-debug-toolbar -tldextract==5.3.1 - # via scrapy -tornado==6.5.4 - # via flower -trio==0.32.0 - # via - # selenium - # trio-websocket -trio-websocket==0.12.2 - # via selenium -twisted==25.5.0 - # via scrapy -typer==0.9.0 - # via mostovik-backend (pyproject.toml) -typing-extensions==4.15.0 - # via - # cron-descriptor - # pyopenssl - # selenium - # twisted - # typer -tzdata==2025.3 - # via - # celery - # django-celery-beat - # kombu - # pandas -uritemplate==4.2.0 - # via - # coreapi - # drf-yasg -urllib3==2.6.3 - # via - # requests - # selenium -vine==5.1.0 - # via - # amqp - # celery - # kombu -virtualenv==20.36.1 - # via pre-commit -w3lib==2.3.1 - # via - # parsel - # scrapy -watchdog==3.0.0 - # via mostovik-backend (pyproject.toml) -wcwidth==0.2.14 - # via prompt-toolkit -werkzeug==3.0.1 - # via mostovik-backend (pyproject.toml) -wsproto==1.3.2 - # via trio-websocket -zope-event==6.1 - # via gevent -zope-interface==8.2 - # via - # gevent - # scrapy - # twisted diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b507573..0000000 --- a/requirements.txt +++ /dev/null @@ -1,342 +0,0 @@ -# This file was autogenerated by uv via the following command: -# uv export --no-hashes --no-dev -amqp==5.3.1 - # via kombu -asgiref==3.11.0 - # via - # django - # django-cors-headers -astroid==4.0.3 - # via pylint -async-timeout==5.0.1 ; python_full_version < '3.11.3' - # via redis -attrs==25.4.0 - # via - # outcome - # service-identity - # trio - # twisted -automat==25.4.16 - # via twisted -beautifulsoup4==4.12.3 - # via mostovik-backend -billiard==4.2.4 - # via celery -celery==5.3.6 - # via - # django-celery-beat - # django-celery-results - # mostovik-backend -certifi==2026.1.4 - # via - # requests - # selenium -cffi==2.0.0 ; (implementation_name != 'pypy' and os_name == 'nt') or platform_python_implementation != 'PyPy' - # via - # cryptography - # trio -charset-normalizer==3.4.4 - # via requests -click==8.1.7 - # via - # celery - # click-didyoumean - # click-plugins - # click-repl -click-didyoumean==0.3.1 - # via celery -click-plugins==1.1.1.2 - # via celery -click-repl==0.3.0 - # via celery -colorama==0.4.6 ; sys_platform == 'win32' - # via - # click - # pylint -constantly==23.10.4 - # via twisted -coreapi==2.3.3 - # via - # django-rest-swagger - # mostovik-backend - # openapi-codec -coreschema==0.0.4 - # via coreapi -cron-descriptor==2.0.6 - # via django-celery-beat -cryptography==42.0.5 - # via - # mostovik-backend - # pyopenssl - # scrapy - # service-identity -cssselect==1.3.0 - # via - # parsel - # scrapy -defusedxml==0.7.1 - # via scrapy -dill==0.4.1 - # via pylint -django==3.2.25 - # via - # django-celery-beat - # django-celery-results - # django-cors-headers - # django-filter - # django-jazzmin - # django-redis - # django-timezone-field - # djangorestframework - # djangorestframework-simplejwt - # drf-yasg - # model-bakery - # mostovik-backend -django-celery-beat==2.6.0 - # via mostovik-backend -django-celery-results==2.5.1 - # via mostovik-backend -django-cors-headers==4.3.1 - # via mostovik-backend -django-filter==23.5 - # via mostovik-backend -django-jazzmin==2.6.2 - # via mostovik-backend -django-redis==5.4.0 - # via mostovik-backend -django-rest-swagger==2.2.0 - # via mostovik-backend -django-timezone-field==7.2.1 - # via django-celery-beat -djangorestframework==3.14.0 - # via - # django-rest-swagger - # djangorestframework-simplejwt - # drf-yasg - # mostovik-backend -djangorestframework-simplejwt==5.3.1 - # via mostovik-backend -drf-yasg==1.21.10 - # via mostovik-backend -et-xmlfile==2.0.0 - # via openpyxl -factory-boy==3.3.0 - # via mostovik-backend -faker==40.1.2 - # via - # factory-boy - # mostovik-backend -filelock==3.20.3 - # via tldextract -greenlet==3.3.0 - # via playwright -h11==0.16.0 - # via wsproto -hyperlink==21.0.0 - # via twisted -idna==3.11 - # via - # hyperlink - # requests - # tldextract - # trio -incremental==24.11.0 - # via twisted -inflection==0.5.1 - # via drf-yasg -isort==5.13.2 - # via pylint -itemadapter==0.13.1 - # via - # itemloaders - # scrapy -itemloaders==1.3.2 - # via scrapy -itypes==1.2.0 - # via coreapi -jinja2==3.1.6 - # via coreschema -jmespath==1.0.1 - # via - # itemloaders - # parsel -kombu==5.6.2 - # via celery -lxml==6.0.2 - # via - # parsel - # scrapy -markupsafe==3.0.3 - # via jinja2 -mccabe==0.7.0 - # via pylint -model-bakery==1.17.0 - # via mostovik-backend -numpy==1.24.4 - # via - # mostovik-backend - # pandas -openapi-codec==1.3.2 - # via django-rest-swagger -openpyxl==3.1.5 - # via mostovik-backend -outcome==1.3.0.post0 - # via - # trio - # trio-websocket -packaging==25.0 - # via - # drf-yasg - # incremental - # kombu - # parsel - # scrapy -pandas==2.0.3 - # via mostovik-backend -parsel==1.10.0 - # via - # itemloaders - # scrapy -pillow==12.1.0 - # via mostovik-backend -platformdirs==4.5.1 - # via pylint -playwright==1.57.0 - # via mostovik-backend -prompt-toolkit==3.0.52 - # via click-repl -protego==0.5.0 - # via scrapy -psycopg2-binary==2.9.9 - # via mostovik-backend -pyasn1==0.6.2 - # via - # pyasn1-modules - # service-identity -pyasn1-modules==0.4.2 - # via service-identity -pycparser==2.23 ; (implementation_name != 'PyPy' and implementation_name != 'pypy' and os_name == 'nt') or (implementation_name != 'PyPy' and platform_python_implementation != 'PyPy') - # via cffi -pydispatcher==2.0.7 ; platform_python_implementation == 'CPython' - # via scrapy -pyee==13.0.0 - # via playwright -pyjwt==2.10.1 - # via djangorestframework-simplejwt -pylint==4.0.4 - # via mostovik-backend -pyopenssl==25.1.0 - # via scrapy -pypydispatcher==2.1.2 ; platform_python_implementation == 'PyPy' - # via scrapy -pysocks==1.7.1 - # via urllib3 -python-crontab==3.3.0 - # via django-celery-beat -python-dateutil==2.8.2 - # via - # celery - # mostovik-backend - # pandas -python-decouple==3.8 - # via mostovik-backend -python-dotenv==1.0.1 - # via mostovik-backend -python-json-logger==2.0.7 - # via mostovik-backend -pytz==2024.1 - # via - # django - # djangorestframework - # drf-yasg - # mostovik-backend - # pandas -pyyaml==6.0.3 - # via drf-yasg -queuelib==1.8.0 - # via scrapy -redis==5.0.3 - # via - # django-redis - # mostovik-backend -requests==2.31.0 - # via - # coreapi - # mostovik-backend - # requests-file - # tldextract -requests-file==3.0.1 - # via tldextract -scrapy==2.11.2 - # via mostovik-backend -selenium==4.17.2 - # via mostovik-backend -service-identity==24.2.0 - # via scrapy -setuptools==80.9.0 - # via scrapy -simplejson==3.20.2 - # via django-rest-swagger -six==1.17.0 - # via python-dateutil -sniffio==1.3.1 - # via trio -sortedcontainers==2.4.0 - # via trio -soupsieve==2.8.2 - # via beautifulsoup4 -sqlparse==0.5.5 - # via django -tldextract==5.3.1 - # via scrapy -tomlkit==0.14.0 - # via pylint -trio==0.32.0 - # via - # selenium - # trio-websocket -trio-websocket==0.12.2 - # via selenium -twisted==25.5.0 - # via scrapy -typing-extensions==4.15.0 - # via - # cron-descriptor - # pyee - # pyopenssl - # selenium - # twisted -tzdata==2025.3 - # via - # celery - # django-celery-beat - # faker - # kombu - # pandas -uritemplate==4.2.0 - # via - # coreapi - # drf-yasg -urllib3==2.6.3 - # via - # requests - # selenium -vine==5.1.0 - # via - # amqp - # celery - # kombu -w3lib==2.3.1 - # via - # parsel - # scrapy -wcwidth==0.2.14 - # via prompt-toolkit -whitenoise==6.11.0 - # via mostovik-backend -wsproto==1.3.2 - # via trio-websocket -zope-interface==8.2 - # via - # scrapy - # twisted diff --git a/run_tests.py b/run_tests.py deleted file mode 100644 index 16d18ad..0000000 --- a/run_tests.py +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env python -""" -Простой скрипт для запуска тестов, обходящий проблемы с pytest и pdbpp -Использует стандартный Django test runner с улучшенными возможностями -Поддерживает coverage и дополнительные опции -""" - -import argparse -import os -import sys - -import django - - -def setup_django(): - """Настройка Django окружения""" - # Монкипатчим проблематичные модули - sys.modules["ipdb"] = type("MockModule", (), {"__getattr__": lambda s, n: None})() - - # Добавляем src в PYTHONPATH - src_path = os.path.join(os.path.dirname(__file__), "src") - if src_path not in sys.path: - sys.path.insert(0, src_path) - - # Устанавливаем настройки Django (принудительно для тестов) - os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.test" - - # Инициализируем Django - django.setup() - - -def run_tests_with_args(test_args, options): - """Запуск тестов с заданными аргументами""" - from django.conf import settings - from django.test.utils import get_runner - - # Получаем test runner - TestRunner = get_runner(settings) - - # Настройки для test runner - runner_kwargs = { - "verbosity": options.verbose, - "interactive": False, - "keepdb": options.keepdb, - "failfast": options.failfast, - } - - # Добавляем parallel если указано - if options.parallel: - runner_kwargs["parallel"] = options.parallel - - test_runner = TestRunner(**runner_kwargs) - - # Запускаем тесты - failures = test_runner.run_tests(test_args) - return failures - - -def parse_arguments(): - """Парсинг аргументов командной строки""" - parser = argparse.ArgumentParser( - description="Запуск Django тестов с дополнительными возможностями" - ) - - parser.add_argument( - "targets", - nargs="*", - help="Цели тестирования (по умолчанию: все тесты)", - default=["tests"], - ) - - parser.add_argument( - "--coverage", - "--cov", - action="store_true", - help="Запуск тестов с измерением покрытия кода", - ) - - parser.add_argument( - "--fast", - action="store_true", - help="Запуск только быстрых тестов (исключает медленные)", - ) - - parser.add_argument( - "--failfast", action="store_true", help="Остановка при первой ошибке" - ) - - parser.add_argument( - "--verbose", "-v", action="count", default=2, help="Уровень детализации вывода" - ) - - parser.add_argument( - "--keepdb", action="store_true", help="Сохранить тестовую базу данных" - ) - - parser.add_argument( - "--parallel", - type=int, - metavar="N", - help="Запуск тестов в N параллельных процессах", - ) - - args = parser.parse_args() - - # Преобразуем пути для удобства использования - test_targets = [] - - for target in args.targets: - # Преобразование путей файлов в модули Django - if target.endswith(".py"): - # Убираем расширение .py - target = target[:-3] - - # Заменяем слеши на точки для модульных путей - if "/" in target: - target = target.replace("/", ".") - - # Добавляем префикс tests если его нет - if not target.startswith("tests"): - if target == "user": - # Если просто "user", запускаем все тесты user app - target = "tests.apps.user" - elif target in ["models", "views", "serializers", "services"]: - # Если это простые ключевые слова, добавляем test_ префикс - target = f"tests.apps.user.test_{target}" - elif ( - "test_" in target - or "models" in target - or "views" in target - or "serializers" in target - or "services" in target - ): - # Если это конкретный файл тестов с префиксом или содержит ключевые слова - if not target.startswith("test_"): - target = f"tests.apps.user.test_{target}" - else: - target = f"tests.apps.user.{target}" - else: - # Общий случай - target = f"tests.{target}" - - test_targets.append(target) - - args.targets = test_targets if test_targets else ["tests"] - return args - - -def print_test_info(test_targets, options): - """Вывод информации о запуске тестов""" - print("🧪 Запуск тестов (Django test runner)...") - - if test_targets == ["tests"]: - print("📁 Цель: Все тесты в проекте") - else: - print(f"📁 Цели: {', '.join(test_targets)}") - - print(f"⚙️ Настройки Django: {os.environ.get('DJANGO_SETTINGS_MODULE')}") - print(f"📦 Путь к исходникам: {os.path.join(os.path.dirname(__file__), 'src')}") - - # Дополнительные опции - options_info = [] - if options.coverage: - options_info.append("📊 Измерение покрытия") - if options.fast: - options_info.append("🚀 Только быстрые тесты") - if options.failfast: - options_info.append("❌ Остановка при первой ошибке") - if options.keepdb: - options_info.append("💾 Сохранение тестовой БД") - if options.parallel: - options_info.append(f"⚡ Параллельность: {options.parallel}") - - if options_info: - print("🔧 Опции:", " | ".join(options_info)) - - print("-" * 60) - - -def setup_coverage(): - """Настройка coverage""" - try: - import coverage - - cov = coverage.Coverage(config_file="pyproject.toml") - cov.start() - return cov - except ImportError: - print("⚠️ Модуль coverage не установлен. Измерение покрытия недоступно.") - return None - - -def finalize_coverage(cov): - """Завершение измерения покрытия""" - if cov: - cov.stop() - cov.save() - - print("\n📊 Отчет о покрытии кода:") - print("-" * 40) - cov.report() - - # Создание HTML отчета - try: - cov.html_report() - print("\n📄 HTML отчет создан в директории: htmlcov/") - except Exception as e: - print(f"⚠️ Не удалось создать HTML отчет: {e}") - - -def main(): - """Основная функция""" - cov = None - try: - # Парсинг аргументов - options = parse_arguments() - - # Настройка coverage если нужно - if options.coverage: - cov = setup_coverage() - - # Настройка Django - setup_django() - - # Настройка фильтрации тестов - if options.fast: - os.environ["PYTEST_CURRENT_TEST_FILTER"] = "not slow" - - # Вывод информации - print_test_info(options.targets, options) - - # Запуск тестов - failures = run_tests_with_args(options.targets, options) - - # Завершение coverage - if cov: - finalize_coverage(cov) - - # Результат - if failures: - print(f"\n❌ Тесты завершились с ошибками: {failures} неудачных тестов") - sys.exit(1) - else: - print("\n✅ Все тесты прошли успешно!") - if cov: - print("📊 Отчет о покрытии сохранен") - sys.exit(0) - - except KeyboardInterrupt: - print("\n❌ Тесты прерваны пользователем") - if cov: - cov.stop() - sys.exit(1) - except Exception as e: - print(f"\n❌ Ошибка при запуске тестов: {e}") - if cov: - cov.stop() - import traceback - - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/scripts/check-migrations.sh b/scripts/check-migrations.sh index 2e2365e..c68654c 100755 --- a/scripts/check-migrations.sh +++ b/scripts/check-migrations.sh @@ -4,13 +4,7 @@ cd "$(dirname "$0")/../src" || exit 1 export PYTHONPATH=. -export DJANGO_SETTINGS_MODULE=config.settings.development +export DJANGO_SETTINGS_MODULE=settings.test -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 +uv run python manage.py makemigrations --check --dry-run +echo "✓ Django migrations are up to date" diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh new file mode 100755 index 0000000..a0928c5 --- /dev/null +++ b/scripts/run-tests.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Django test runner script for pre-push + +set -euo pipefail + +cd "$(dirname "$0")/../src" || exit 1 + +export PYTHONPATH=. +export DJANGO_SETTINGS_MODULE=settings.test + +if [ "$#" -eq 0 ]; then + uv run pytest ../tests +else + uv run pytest "$@" +fi diff --git a/scripts/setup-precommit.sh b/scripts/setup-precommit.sh old mode 100644 new mode 100755 index 7bb39f3..4fd3af4 --- a/scripts/setup-precommit.sh +++ b/scripts/setup-precommit.sh @@ -1,41 +1,8 @@ #!/bin/bash -# Скрипт установки и настройки pre-commit хуков +# Script to install git hooks managed by pre-commit -echo "🔧 Настройка pre-commit хуков..." +set -euo pipefail -# Проверка наличия 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 +echo "Installing pre-commit hooks..." +uv run pre-commit install --hook-type pre-commit --hook-type pre-push +echo "Hooks installed: pre-commit, pre-push" diff --git a/sitecustomize.py b/sitecustomize.py deleted file mode 100644 index 53a550d..0000000 --- a/sitecustomize.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Ensure src/ is on sys.path for tooling like pytest-django.""" - -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parent -SRC = ROOT / "src" - -if str(SRC) not in sys.path: - sys.path.insert(0, str(SRC)) diff --git a/src/apps/core/openapi.py b/src/apps/core/openapi.py index 59055b1..4561139 100644 --- a/src/apps/core/openapi.py +++ b/src/apps/core/openapi.py @@ -7,6 +7,7 @@ from typing import Any +from django.conf import settings from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -85,11 +86,20 @@ def api_docs( ) +def swagger_tag(ru: str, en: str | None = None) -> str: + """Возвращает тег для Swagger в зависимости от текущих настроек.""" + use_english = getattr(settings, "OPENAPI_USE_ENGLISH_TAGS", False) + if use_english and en: + return en + return ru + + def _get_status_description(status_code: int) -> str: """Возвращает описание HTTP статуса на русском.""" descriptions = { 200: "Успешный запрос", 201: "Ресурс создан", + 202: "Запрос принят в обработку", 204: "Успешно, без содержимого", 400: "Некорректный запрос", 401: "Не авторизован", @@ -99,6 +109,7 @@ def _get_status_description(status_code: int) -> str: 422: "Ошибка валидации", 429: "Слишком много запросов", 500: "Внутренняя ошибка сервера", + 503: "Сервис временно недоступен", } return descriptions.get(status_code, f"HTTP {status_code}") @@ -273,6 +284,72 @@ class CommonResponses: ), ) + SERVICE_UNAVAILABLE = openapi.Response( + description="Сервис временно недоступен", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "success": openapi.Schema(type=openapi.TYPE_BOOLEAN, default=False), + "errors": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "code": openapi.Schema( + type=openapi.TYPE_STRING, + default="service_unavailable", + ), + "message": openapi.Schema( + type=openapi.TYPE_STRING, + default="Сервис временно недоступен", + ), + }, + ), + ), + }, + ), + ) + + +class ErrorResponses: + """Переиспользуемые наборы ошибок для OpenAPI responses.""" + + PUBLIC = { + 429: CommonResponses.RATE_LIMITED, + 500: CommonResponses.SERVER_ERROR, + } + + AUTHENTICATED = { + 401: CommonResponses.UNAUTHORIZED, + **PUBLIC, + } + + AUTHENTICATED_VALIDATION = { + 400: CommonResponses.BAD_REQUEST, + **AUTHENTICATED, + } + + AUTHENTICATED_NOT_FOUND = { + 404: CommonResponses.NOT_FOUND, + **AUTHENTICATED, + } + + AUTHENTICATED_VALIDATION_NOT_FOUND = { + 400: CommonResponses.BAD_REQUEST, + **AUTHENTICATED_NOT_FOUND, + } + + ADMIN = { + 401: CommonResponses.UNAUTHORIZED, + 403: CommonResponses.FORBIDDEN, + **PUBLIC, + } + + ADMIN_NOT_FOUND = { + 404: CommonResponses.NOT_FOUND, + **ADMIN, + } + # Параметры запроса class CommonParameters: diff --git a/src/apps/core/startup_checks.py b/src/apps/core/startup_checks.py new file mode 100644 index 0000000..4053990 --- /dev/null +++ b/src/apps/core/startup_checks.py @@ -0,0 +1,91 @@ +""" +Startup dependency checks for DB and Redis. + +Fail-fast checks used by long-running entrypoints (web/celery) to avoid +silent hangs on connection issues. +""" + +from __future__ import annotations + +import sys +from urllib.parse import urlparse + +import psycopg2 +import redis +from django.conf import settings + + +def _log(message: str) -> None: + """Log to stderr to be visible early in startup.""" + print(message, file=sys.stderr) + + +def _check_db(timeout_seconds: int) -> tuple[bool, str]: + db = settings.DATABASES["default"] + params = { + "dbname": db.get("NAME"), + "user": db.get("USER"), + "password": db.get("PASSWORD"), + "host": db.get("HOST"), + "port": db.get("PORT"), + "connect_timeout": timeout_seconds, + } + + options = db.get("OPTIONS", {}) + if options.get("sslmode"): + params["sslmode"] = options["sslmode"] + + conn = None + try: + conn = psycopg2.connect(**params) + with conn.cursor() as cursor: + cursor.execute("SELECT 1") + cursor.fetchone() + return True, "OK" + except Exception as exc: # noqa: BLE001 + target = f"{params['host']}:{params['port']}/{params['dbname']}" + return False, f"{target} ({exc})" + finally: + if conn is not None: + conn.close() + + +def _check_redis(timeout_seconds: int) -> tuple[bool, str]: + redis_url = settings.CACHES["default"]["LOCATION"] + try: + client = redis.Redis.from_url( + redis_url, + socket_connect_timeout=timeout_seconds, + socket_timeout=timeout_seconds, + ) + client.ping() + return True, "OK" + except Exception as exc: # noqa: BLE001 + parsed = urlparse(redis_url) + target = f"{parsed.hostname}:{parsed.port}{parsed.path or ''}" + return False, f"{target} ({exc})" + + +def run_startup_checks(*, component: str = "app") -> None: + """Run startup checks and exit process on failure.""" + if not getattr(settings, "STARTUP_CHECKS_ENABLED", True): + return + + db_timeout = int(getattr(settings, "STARTUP_DB_TIMEOUT_SECONDS", 3)) + redis_timeout = int(getattr(settings, "STARTUP_REDIS_TIMEOUT_SECONDS", 3)) + + db_ok, db_message = _check_db(db_timeout) + if not db_ok: + _log( + f"[startup:{component}] DB check failed " + f"(timeout={db_timeout}s): {db_message}" + ) + raise SystemExit(1) + + redis_ok, redis_message = _check_redis(redis_timeout) + if not redis_ok: + _log( + f"[startup:{component}] Redis check failed " + f"(timeout={redis_timeout}s): {redis_message}" + ) + raise SystemExit(1) diff --git a/src/apps/core/tasks.py b/src/apps/core/tasks.py index faedaa9..ded4f36 100644 --- a/src/apps/core/tasks.py +++ b/src/apps/core/tasks.py @@ -26,7 +26,7 @@ class BaseTask(Task): - Логирование исключений Пример использования: - from config.celery import app + from core.celery import app @app.task(base=BaseTask, bind=True) def my_task(self, arg1, arg2): diff --git a/src/apps/core/views.py b/src/apps/core/views.py index 16bc377..2071f96 100644 --- a/src/apps/core/views.py +++ b/src/apps/core/views.py @@ -11,6 +11,8 @@ import logging import time from typing import Any +from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag +from apps.core.serializers import BackgroundJobListSerializer, BackgroundJobSerializer from django.conf import settings from django.db import connection from drf_yasg.utils import swagger_auto_schema @@ -23,8 +25,8 @@ from rest_framework.views import APIView logger = logging.getLogger(__name__) # Swagger теги -HEALTH_TAG = "Мониторинг" -JOBS_TAG = "Фоновые задачи" +HEALTH_TAG = swagger_tag("Мониторинг", "monitoring") +JOBS_TAG = swagger_tag("Фоновые задачи", "background_jobs") class HealthCheckView(APIView): @@ -44,6 +46,11 @@ class HealthCheckView(APIView): "Комплексная проверка всех зависимостей системы.\n" "Возвращает статус: healthy, degraded или unhealthy." ), + responses={ + 200: "Сервис работает в режиме healthy/degraded", + 503: CommonResponses.SERVICE_UNAVAILABLE, + **ErrorResponses.PUBLIC, + }, ) def get(self, request: Request) -> Response: """Run all health checks and return status.""" @@ -117,7 +124,7 @@ class HealthCheckView(APIView): def _check_celery(self) -> dict[str, Any]: """Check Celery worker availability.""" try: - from config.celery import app as celery_app + from core.celery import app as celery_app inspector = celery_app.control.inspect(timeout=2.0) active = inspector.active() @@ -144,6 +151,10 @@ class LivenessView(APIView): tags=[HEALTH_TAG], operation_summary="Liveness probe", operation_description="Возвращает 200 если приложение запущено.", + responses={ + 200: "Приложение запущено", + **ErrorResponses.PUBLIC, + }, ) def get(self, request: Request) -> Response: """Simple liveness check.""" @@ -166,6 +177,11 @@ class ReadinessView(APIView): operation_description=( "Возвращает 200 если приложение готово обрабатывать запросы." ), + responses={ + 200: "Приложение готово обрабатывать запросы", + 503: CommonResponses.SERVICE_UNAVAILABLE, + **ErrorResponses.PUBLIC, + }, ) def get(self, request: Request) -> Response: """Check if app is ready to serve traffic.""" @@ -202,10 +218,15 @@ class BackgroundJobStatusView(APIView): "Возвращает статус конкретной фоновой задачи.\n" "Доступно только владельцу задачи или администратору." ), + responses={ + 200: BackgroundJobSerializer, + 403: CommonResponses.FORBIDDEN, + 404: CommonResponses.NOT_FOUND, + **ErrorResponses.AUTHENTICATED, + }, ) def get(self, request: Request, task_id: str) -> Response: """Получить статус задачи по task_id.""" - from apps.core.serializers import BackgroundJobSerializer from apps.core.services import BackgroundJobService job = BackgroundJobService.get_by_task_id(task_id) @@ -239,10 +260,13 @@ class BackgroundJobListView(APIView): "Возвращает список фоновых задач текущего пользователя.\n" "Поддерживает фильтрацию по статусу (status) и лимит (limit)." ), + responses={ + 200: BackgroundJobListSerializer(many=True), + **ErrorResponses.AUTHENTICATED, + }, ) def get(self, request: Request) -> Response: """Получить список задач пользователя.""" - from apps.core.serializers import BackgroundJobListSerializer from apps.core.services import BackgroundJobService status_filter = request.query_params.get("status") diff --git a/src/apps/parsers/tests/run_checko_e2e.py b/src/apps/parsers/tests/run_checko_e2e.py index 6972f86..32a778a 100644 --- a/src/apps/parsers/tests/run_checko_e2e.py +++ b/src/apps/parsers/tests/run_checko_e2e.py @@ -26,7 +26,7 @@ from django.conf import settings sys.path.insert(0, ".") # Setup Django settings -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev") django.setup() diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py index 5f666c1..72becd6 100644 --- a/src/apps/parsers/views.py +++ b/src/apps/parsers/views.py @@ -9,6 +9,7 @@ import hashlib import time from pathlib import Path +from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag from apps.parsers.models import ( FinancialReport, IndustrialCertificateRecord, @@ -44,11 +45,11 @@ from rest_framework.viewsets import ReadOnlyModelViewSet # Swagger Tags (для группировки в документации) # ============================================================================= -MINPROMTORG_TAG = "Минпромторг" -PROVERKI_TAG = "Единый реестр проверок" -ZAKUPKI_TAG = "Государственные закупки" -FNS_TAG = "ФНС - Бухгалтерская отчетность" -SYSTEM_TAG = "Системные" +MINPROMTORG_TAG = swagger_tag("Минпромторг", "minpromtorg") +PROVERKI_TAG = swagger_tag("Единый реестр проверок", "inspections_registry") +ZAKUPKI_TAG = swagger_tag("Государственные закупки", "public_procurements") +FNS_TAG = swagger_tag("ФНС - Бухгалтерская отчетность", "fns_financial_reports") +SYSTEM_TAG = swagger_tag("Системные", "system") # ============================================================================= @@ -78,6 +79,10 @@ class IndustrialCertificateViewSet(ReadOnlyModelViewSet): "Поддерживает фильтрацию по: inn, ogrn, certificate_number, load_batch.\n" "Поддерживает поиск по: organisation_name, certificate_number, inn, ogrn." ), + responses={ + 200: IndustrialCertificateSerializer(many=True), + **ErrorResponses.AUTHENTICATED, + }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @@ -86,6 +91,10 @@ class IndustrialCertificateViewSet(ReadOnlyModelViewSet): tags=[MINPROMTORG_TAG], operation_summary="Детали сертификата", operation_description="Возвращает информацию о конкретном сертификате.", + responses={ + 200: IndustrialCertificateSerializer, + **ErrorResponses.AUTHENTICATED_NOT_FOUND, + }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) @@ -118,6 +127,10 @@ class ManufacturerViewSet(ReadOnlyModelViewSet): "Поддерживает фильтрацию по: inn, ogrn, load_batch.\n" "Поддерживает поиск по: full_legal_name, inn, ogrn, address." ), + responses={ + 200: ManufacturerSerializer(many=True), + **ErrorResponses.AUTHENTICATED, + }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @@ -126,6 +139,10 @@ class ManufacturerViewSet(ReadOnlyModelViewSet): tags=[MINPROMTORG_TAG], operation_summary="Детали производителя", operation_description="Возвращает информацию о конкретном производителе.", + responses={ + 200: ManufacturerSerializer, + **ErrorResponses.AUTHENTICATED_NOT_FOUND, + }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) @@ -175,6 +192,10 @@ class InspectionViewSet(ReadOnlyModelViewSet): "Поддерживает поиск по: organisation_name, registration_number, " "inn, ogrn, control_authority." ), + responses={ + 200: InspectionSerializer(many=True), + **ErrorResponses.AUTHENTICATED, + }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @@ -183,6 +204,10 @@ class InspectionViewSet(ReadOnlyModelViewSet): tags=[PROVERKI_TAG], operation_summary="Детали проверки", operation_description="Возвращает информацию о конкретной проверке.", + responses={ + 200: InspectionSerializer, + **ErrorResponses.AUTHENTICATED_NOT_FOUND, + }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) @@ -235,6 +260,10 @@ class ProcurementViewSet(ReadOnlyModelViewSet): "Поддерживает поиск по: purchase_name, purchase_number, " "customer_name, customer_inn, customer_ogrn." ), + responses={ + 200: ProcurementSerializer(many=True), + **ErrorResponses.AUTHENTICATED, + }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @@ -243,6 +272,10 @@ class ProcurementViewSet(ReadOnlyModelViewSet): tags=[ZAKUPKI_TAG], operation_summary="Детали закупки", operation_description="Возвращает информацию о конкретной закупке.", + responses={ + 200: ProcurementSerializer, + **ErrorResponses.AUTHENTICATED_NOT_FOUND, + }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) @@ -280,6 +313,10 @@ class FinancialReportViewSet(ReadOnlyModelViewSet): "source, load_batch.\n" "Поддерживает поиск по: ogrn, external_id, file_name." ), + responses={ + 200: FinancialReportSerializer(many=True), + **ErrorResponses.AUTHENTICATED, + }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @@ -291,6 +328,10 @@ class FinancialReportViewSet(ReadOnlyModelViewSet): "Возвращает детальную информацию об отчете, " "включая все строки бухгалтерской отчетности." ), + responses={ + 200: FinancialReportDetailSerializer, + **ErrorResponses.AUTHENTICATED_NOT_FOUND, + }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) @@ -350,7 +391,8 @@ class FNSReportUploadView(APIView): }, ), ), - 400: "Ошибка валидации файлов", + 400: CommonResponses.BAD_REQUEST, + **ErrorResponses.AUTHENTICATED, }, ) def post(self, request): # noqa @@ -462,6 +504,10 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet): "Доступно только администраторам.\n" "Поддерживает фильтрацию по: source, status, batch_id." ), + responses={ + 200: ParserLoadLogSerializer(many=True), + **ErrorResponses.ADMIN, + }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @@ -470,6 +516,10 @@ class ParserLoadLogViewSet(ReadOnlyModelViewSet): tags=[SYSTEM_TAG], operation_summary="Детали загрузки", operation_description="Возвращает информацию о конкретной загрузке.", + responses={ + 200: ParserLoadLogSerializer, + **ErrorResponses.ADMIN_NOT_FOUND, + }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) @@ -496,6 +546,10 @@ class ProxyViewSet(ReadOnlyModelViewSet): "Доступно только администраторам.\n" "Поддерживает фильтрацию по: is_active." ), + responses={ + 200: ProxySerializer(many=True), + **ErrorResponses.ADMIN, + }, ) def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) @@ -504,6 +558,10 @@ class ProxyViewSet(ReadOnlyModelViewSet): tags=[SYSTEM_TAG], operation_summary="Детали прокси", operation_description="Возвращает информацию о конкретном прокси.", + responses={ + 200: ProxySerializer, + **ErrorResponses.ADMIN_NOT_FOUND, + }, ) def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) diff --git a/src/apps/user/urls.py b/src/apps/user/urls.py index 8de1a0a..819f45e 100644 --- a/src/apps/user/urls.py +++ b/src/apps/user/urls.py @@ -1,5 +1,4 @@ from django.urls import path -from rest_framework_simplejwt.views import TokenVerifyView from . import views @@ -11,7 +10,7 @@ urlpatterns = [ 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("token/verify/", views.TokenVerifySwaggerView.as_view(), name="token_verify"), # Пользовательские данные path("me/", views.CurrentUserView.as_view(), name="current_user"), path("me/update/", views.UserUpdateView.as_view(), name="user_update"), diff --git a/src/apps/user/views.py b/src/apps/user/views.py index 6ce5ec8..50ec604 100644 --- a/src/apps/user/views.py +++ b/src/apps/user/views.py @@ -1,3 +1,4 @@ +from apps.core.openapi import CommonResponses, ErrorResponses, swagger_tag from django.contrib.auth import authenticate from django.contrib.auth.hashers import check_password from drf_yasg import openapi @@ -8,6 +9,7 @@ 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 rest_framework_simplejwt.views import TokenVerifyView as SimpleJWTTokenVerifyView from .serializers import ( LoginSerializer, @@ -21,8 +23,8 @@ from .serializers import ( from .services import ProfileService, UserService # Swagger теги для группировки -AUTH_TAG = "Аутентификация" -USER_TAG = "Пользователь" +AUTH_TAG = swagger_tag("Аутентификация", "authentication") +USER_TAG = swagger_tag("Пользователь", "user") class RegisterView(APIView): @@ -39,7 +41,11 @@ class RegisterView(APIView): operation_summary="Регистрация", operation_description="Создание новой учётной записи пользователя.", request_body=UserRegistrationSerializer, - responses={201: UserSerializer}, + responses={ + 201: UserSerializer, + 400: CommonResponses.BAD_REQUEST, + **ErrorResponses.PUBLIC, + }, ) def post(self, request): serializer = UserRegistrationSerializer(data=request.data) @@ -74,7 +80,12 @@ class LoginView(APIView): operation_summary="Вход", operation_description="Аутентификация по email и паролю. Возвращает JWT токены.", request_body=LoginSerializer, - responses={200: TokenSerializer}, + responses={ + 200: TokenSerializer, + 400: CommonResponses.BAD_REQUEST, + 401: CommonResponses.UNAUTHORIZED, + **ErrorResponses.PUBLIC, + }, ) def post(self, request): serializer = LoginSerializer(data=request.data) @@ -108,7 +119,10 @@ class LogoutView(APIView): tags=[AUTH_TAG], operation_summary="Выход", operation_description="Выход из системы (удаление токенов на клиенте).", - responses={200: "Успешный выход"}, + responses={ + 200: "Успешный выход", + **ErrorResponses.AUTHENTICATED, + }, ) def post(self, request): # Для JWT логаут означает удаление токенов на клиенте. @@ -125,7 +139,10 @@ class CurrentUserView(APIView): tags=[USER_TAG], operation_summary="Текущий пользователь", operation_description="Возвращает данные авторизованного пользователя.", - responses={200: UserSerializer}, + responses={ + 200: UserSerializer, + **ErrorResponses.AUTHENTICATED, + }, ) def get(self, request): serializer = UserSerializer(request.user) @@ -142,7 +159,10 @@ class UserUpdateView(APIView): operation_summary="Обновить данные", operation_description="Частичное обновление данных пользователя.", request_body=UserUpdateSerializer, - responses={200: UserSerializer}, + responses={ + 200: UserSerializer, + **ErrorResponses.AUTHENTICATED_VALIDATION, + }, ) def patch(self, request): serializer = UserUpdateSerializer(request.user, data=request.data, partial=True) @@ -158,6 +178,7 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView): permission_classes = [IsAuthenticated] serializer_class = ProfileUpdateSerializer + http_method_names = ["get", "patch", "head", "options"] def get_object(self): profile = ProfileService.get_profile_by_user_id_or_none(self.request.user.id) @@ -172,6 +193,10 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView): tags=[USER_TAG], operation_summary="Получить профиль", operation_description="Возвращает профиль текущего пользователя.", + responses={ + 200: ProfileUpdateSerializer, + **ErrorResponses.AUTHENTICATED, + }, ) def get(self, request, *args, **kwargs): profile = self.get_object() @@ -183,6 +208,10 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView): operation_summary="Обновить профиль", operation_description="Частичное обновление профиля пользователя.", request_body=ProfileUpdateSerializer, + responses={ + 200: ProfileUpdateSerializer, + **ErrorResponses.AUTHENTICATED_VALIDATION, + }, ) def patch(self, request, *args, **kwargs): profile = self.get_object() @@ -205,7 +234,10 @@ class PasswordChangeView(APIView): operation_summary="Сменить пароль", operation_description="Смена пароля. Требуется текущий пароль для подтверждения.", request_body=PasswordChangeSerializer, - responses={200: "Пароль успешно изменен"}, + responses={ + 200: "Пароль успешно изменен", + **ErrorResponses.AUTHENTICATED_VALIDATION, + }, ) def post(self, request): serializer = PasswordChangeSerializer(data=request.data) @@ -234,6 +266,10 @@ class PasswordChangeView(APIView): tags=[USER_TAG], operation_summary="Полный профиль", operation_description="Расширенная информация о пользователе и профиле.", + responses={ + 200: ProfileUpdateSerializer, + **ErrorResponses.AUTHENTICATED, + }, ) @api_view(["GET"]) @permission_classes([IsAuthenticated]) @@ -261,7 +297,12 @@ class TokenRefreshView(APIView): }, required=["refresh"], ), - responses={200: TokenSerializer}, + responses={ + 200: TokenSerializer, + 400: CommonResponses.BAD_REQUEST, + 401: CommonResponses.UNAUTHORIZED, + **ErrorResponses.PUBLIC, + }, ) def post(self, request): refresh_token = request.data.get("refresh") @@ -280,3 +321,21 @@ class TokenRefreshView(APIView): return Response( {"error": "Неверный refresh token"}, status=status.HTTP_401_UNAUTHORIZED ) + + +class TokenVerifySwaggerView(SimpleJWTTokenVerifyView): + """Проверка валидности access токена.""" + + @swagger_auto_schema( + tags=[AUTH_TAG], + operation_summary="Проверить токен", + operation_description="Проверяет валидность JWT токена.", + responses={ + 200: "Токен валиден", + 400: CommonResponses.BAD_REQUEST, + 401: CommonResponses.UNAUTHORIZED, + **ErrorResponses.PUBLIC, + }, + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) diff --git a/src/config/settings/dev.py b/src/config/settings/dev.py deleted file mode 100644 index 9eea021..0000000 --- a/src/config/settings/dev.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Development settings - закрытый контур, без переменных окружения. -""" - -from .base import * - -SECRET_KEY = "django-insecure-development-key-mostovik-2024" -DEBUG = True -ALLOWED_HOSTS = ["*"] - -# JWT -SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY - -# Database -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": "mostovik", - "USER": "postgres", - "PASSWORD": "postgres", - "HOST": "10.10.0.112", - "PORT": "5432", - } -} - -# Celery -CELERY_BROKER_URL = "redis://10.10.0.112:6379/0" -CELERY_RESULT_BACKEND = "redis://10.10.0.112:6379/0" -CELERY_ACCEPT_CONTENT = ["json"] -CELERY_TASK_SERIALIZER = "json" -CELERY_RESULT_SERIALIZER = "json" -CELERY_TIMEZONE = "Europe/Moscow" - -# Cache -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://10.10.0.112:6379/1", - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, - } -} - -# Email -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/src/config/settings/development.py b/src/config/settings/development.py deleted file mode 100644 index 8929b30..0000000 --- a/src/config/settings/development.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Local development settings - использует локальные сервисы. -""" -import os -from .base import * - -SECRET_KEY = "django-insecure-local-development-key-mostovik-2024" -DEBUG = True -ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0"] - -# JWT -SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY - -# Database - используем локальный PostgreSQL -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.environ.get("POSTGRES_DB", "mostovik_dev"), - "USER": os.environ.get("POSTGRES_USER", "postgres"), - "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "postgres"), - "HOST": os.environ.get("POSTGRES_HOST", "localhost"), - "PORT": os.environ.get("POSTGRES_PORT", "5432"), - } -} - -# Celery - используем локальный Redis -CELERY_BROKER_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") -CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL", "redis://localhost:6379/0") -CELERY_ACCEPT_CONTENT = ["json"] -CELERY_TASK_SERIALIZER = "json" -CELERY_RESULT_SERIALIZER = "json" -CELERY_TIMEZONE = "Europe/Moscow" - -# Cache - используем локальный Redis -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": os.environ.get("REDIS_URL", "redis://localhost:6379/1"), - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - }, - } -} - -# Email -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" - -# Static files -STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" \ No newline at end of file diff --git a/src/config/__init__.py b/src/core/__init__.py similarity index 100% rename from src/config/__init__.py rename to src/core/__init__.py diff --git a/src/config/api_v1_urls.py b/src/core/api_v1_urls.py similarity index 100% rename from src/config/api_v1_urls.py rename to src/core/api_v1_urls.py diff --git a/src/config/asgi.py b/src/core/asgi.py similarity index 66% rename from src/config/asgi.py rename to src/core/asgi.py index c86952c..0ee4e62 100644 --- a/src/config/asgi.py +++ b/src/core/asgi.py @@ -9,8 +9,10 @@ https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ import os +from apps.core.startup_checks import run_startup_checks from django.core.asgi import get_asgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.production") +run_startup_checks(component="asgi") application = get_asgi_application() diff --git a/src/config/celery.py b/src/core/celery.py similarity index 79% rename from src/config/celery.py rename to src/core/celery.py index 1f2664e..cfc7379 100644 --- a/src/config/celery.py +++ b/src/core/celery.py @@ -5,7 +5,9 @@ This module contains Celery configuration and task registration. """ import os +import sys +from apps.core.startup_checks import run_startup_checks from celery import Celery # Set the Django settings module for the 'celery' program. @@ -13,9 +15,21 @@ if "DJANGO_SETTINGS_MODULE" not in os.environ: raise RuntimeError( "DJANGO_SETTINGS_MODULE is not set. " "Export it explicitly before starting Celery " - "(e.g., config.settings.production or config.settings.development)." + "(e.g., settings.production or settings.dev)." ) + +def _is_celery_runtime() -> bool: + """True when current process is an actual Celery runtime command.""" + argv = " ".join(sys.argv).lower() + return "celery" in argv and ( + " worker" in argv or " beat" in argv or " flower" in argv + ) + + +if _is_celery_runtime(): + run_startup_checks(component="celery") + app = Celery("project") # Using a string here means the worker doesn't have to serialize diff --git a/src/config/urls.py b/src/core/urls.py similarity index 96% rename from src/config/urls.py rename to src/core/urls.py index d6d698d..e896b97 100644 --- a/src/config/urls.py +++ b/src/core/urls.py @@ -47,7 +47,7 @@ urlpatterns = [ ), path("admin/", admin.site.urls), path("health/", include("apps.core.urls")), - path("api/v1/", include("config.api_v1_urls", namespace="api_v1")), + path("api/v1/", include("core.api_v1_urls", namespace="api_v1")), path("auth/", include("rest_framework.urls")), ] diff --git a/src/config/wsgi.py b/src/core/wsgi.py similarity index 67% rename from src/config/wsgi.py rename to src/core/wsgi.py index 430b30f..513d1c1 100644 --- a/src/config/wsgi.py +++ b/src/core/wsgi.py @@ -9,8 +9,10 @@ https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ import os +from apps.core.startup_checks import run_startup_checks from django.core.wsgi import get_wsgi_application -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev") +run_startup_checks(component="wsgi") application = get_wsgi_application() diff --git a/src/input/fns/fin_0000605_1027700169089.xlsx b/src/input/fns/fin_0000605_1027700169089.xlsx deleted file mode 100644 index 2df2a0d..0000000 Binary files a/src/input/fns/fin_0000605_1027700169089.xlsx and /dev/null differ diff --git a/src/manage.py b/src/manage.py index 236b961..b29b5c5 100644 --- a/src/manage.py +++ b/src/manage.py @@ -3,10 +3,12 @@ import os import sys +STARTUP_CHECK_COMMANDS = {"runserver", "runserver_plus"} + def main(): """Run administrative tasks.""" - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -15,9 +17,15 @@ def main(): "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc + + command = sys.argv[1] if len(sys.argv) > 1 else "" + if command in STARTUP_CHECK_COMMANDS: + from apps.core.startup_checks import run_startup_checks + + run_startup_checks(component=command) + execute_from_command_line(sys.argv) -# test_comment if __name__ == "__main__": main() diff --git a/src/config/settings/__init__.py b/src/settings/__init__.py similarity index 100% rename from src/config/settings/__init__.py rename to src/settings/__init__.py diff --git a/src/config/settings/base.py b/src/settings/base.py similarity index 91% rename from src/config/settings/base.py rename to src/settings/base.py index eade4ff..331af90 100644 --- a/src/config/settings/base.py +++ b/src/settings/base.py @@ -2,12 +2,28 @@ Base settings for Django project. """ +import os +import warnings from datetime import timedelta from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent.parent.parent +BASE_DIR = Path(__file__).resolve().parent.parent +# Repository root (one level above src/) +PROJECT_ROOT = BASE_DIR.parent APP_VERSION = "1.0.0" +OPENAPI_USE_ENGLISH_TAGS = False +STARTUP_CHECKS_ENABLED = True +STARTUP_DB_TIMEOUT_SECONDS = 3 +STARTUP_REDIS_TIMEOUT_SECONDS = 3 + +# Suppress noisy warning from legacy coreapi used by drf-yasg. +warnings.filterwarnings( + "ignore", + message="pkg_resources is deprecated as an API.*", + category=UserWarning, + module="coreapi.utils", +) # SECRET_KEY, DEBUG, ALLOWED_HOSTS определяются в dev.py / production.py @@ -143,7 +159,7 @@ MIDDLEWARE = [ "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = "config.urls" +ROOT_URLCONF = "core.urls" TEMPLATES = [ { @@ -161,7 +177,7 @@ TEMPLATES = [ }, ] -WSGI_APPLICATION = "config.wsgi.application" +WSGI_APPLICATION = "core.wsgi.application" # Database и Cache определяются в dev.py / production.py @@ -169,7 +185,7 @@ WSGI_APPLICATION = "config.wsgi.application" # PARSERS SETTINGS # ============================================================================= -ZAKUPKI_TOKEN = "019c03d7-e1f6-7091-b296-8c88b4c585dd" +ZAKUPKI_TOKEN = os.getenv("ZAKUPKI_TOKEN", "") FNS_LOCK_TTL_SECONDS = 3600 PARSER_PROXIES = [] @@ -199,12 +215,12 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) STATIC_URL = "/static/" -STATIC_ROOT = BASE_DIR / "staticfiles" +STATIC_ROOT = PROJECT_ROOT / "staticfiles" STATICFILES_DIRS = [BASE_DIR / "static"] # Media files MEDIA_URL = "/media/" -MEDIA_ROOT = BASE_DIR / "media" +MEDIA_ROOT = PROJECT_ROOT / "media" # Default primary key field type DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" @@ -219,7 +235,6 @@ LOGIN_URL = "/auth/login/" REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", - "rest_framework.authentication.SessionAuthentication", ], "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions.IsAuthenticatedOrReadOnly", @@ -314,7 +329,7 @@ LOGGING = { "file": { "level": "INFO", "class": "logging.FileHandler", - "filename": BASE_DIR / "logs/django.log", + "filename": PROJECT_ROOT / "logs/django.log", "formatter": "verbose", }, "console": { @@ -341,16 +356,16 @@ LOGGING = { # ============================================================================= # Directory for watching incoming FNS files -FNS_WATCH_DIRECTORY = BASE_DIR / "input" / "fns" +FNS_WATCH_DIRECTORY = PROJECT_ROOT / "input" / "fns" # Directory for processed files (moved after successful processing) -FNS_PROCESSED_DIRECTORY = BASE_DIR / "input" / "fns" / "processed" +FNS_PROCESSED_DIRECTORY = PROJECT_ROOT / "input" / "fns" / "processed" # Directory for failed files (moved after failed processing) -FNS_FAILED_DIRECTORY = BASE_DIR / "input" / "fns" / "failed" +FNS_FAILED_DIRECTORY = PROJECT_ROOT / "input" / "fns" / "failed" # ============================================================================= # Checko API Settings (checko.ru) # ============================================================================= -CHECKO_API_KEY = "pRiEnJuD1tclsLCb" +CHECKO_API_KEY = os.getenv("CHECKO_API_KEY", "") diff --git a/src/settings/dev.py b/src/settings/dev.py new file mode 100644 index 0000000..f09282c --- /dev/null +++ b/src/settings/dev.py @@ -0,0 +1,71 @@ +""" +Development settings - закрытый контур, без переменных окружения. +""" + +import os + +from .base import * + +SECRET_KEY = "django-insecure-development-key-mostovik-2024" +DEBUG = True +ALLOWED_HOSTS = ["*"] +OPENAPI_USE_ENGLISH_TAGS = True + +# JWT +SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY + + +def _normalize_local_host(host: str) -> str: + """Нормализует host для локального запуска backend на хосте.""" + if host == "host.docker.internal": + return "127.0.0.1" + return host + + +_postgres_host = _normalize_local_host(os.getenv("POSTGRES_HOST", "127.0.0.1")) +_redis_host = _normalize_local_host(os.getenv("REDIS_HOST", "127.0.0.1")) + +# Database +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("POSTGRES_DB", "mostovik"), + "USER": os.getenv("POSTGRES_USER", "postgres"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD", "postgres"), + # При локальном запуске backend (не в контейнере) подключаемся к портам, + # проброшенным docker-compose.service.yml + "HOST": _postgres_host, + "PORT": os.getenv("POSTGRES_PORT", "5432"), + } +} + +# Celery +_default_redis_broker = f"redis://{_redis_host}:6379/0" +_default_redis_cache = f"redis://{_redis_host}:6379/1" + +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", _default_redis_broker).replace( + "host.docker.internal", "127.0.0.1" +) +CELERY_RESULT_BACKEND = os.getenv( + "CELERY_RESULT_BACKEND", _default_redis_broker +).replace("host.docker.internal", "127.0.0.1") +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" +CELERY_TIMEZONE = "Europe/Moscow" + +# Cache +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": os.getenv("REDIS_CACHE_URL", _default_redis_cache).replace( + "host.docker.internal", "127.0.0.1" + ), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} + +# Email +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/src/config/settings/production.py b/src/settings/production.py similarity index 70% rename from src/config/settings/production.py rename to src/settings/production.py index 02e5b06..66bf2eb 100644 --- a/src/config/settings/production.py +++ b/src/settings/production.py @@ -3,12 +3,13 @@ Production settings - закрытый контур, все настройки Docker Compose сеть - используются имена сервисов (db, redis). """ +import os + from .base import * -# TODO: сменить на безопасный ключ перед деплоем -SECRET_KEY = "production-secret-key-mostovik-change-me-2024" -DEBUG = False -ALLOWED_HOSTS = ["*"] # TODO: указать конкретные хосты +SECRET_KEY = os.getenv("SECRET_KEY", "production-secret-key-mostovik-change-me-2024") +DEBUG = os.getenv("DEBUG", "False").lower() == "true" +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "*").split(",") # JWT SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY @@ -26,17 +27,20 @@ SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", - "NAME": "mostovik", - "USER": "postgres", - "PASSWORD": "postgres", # TODO: сменить пароль - "HOST": "db", - "PORT": "5432", + "NAME": os.getenv("POSTGRES_DB", "mostovik"), + "USER": os.getenv("POSTGRES_USER", "postgres"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD", "postgres"), + "HOST": os.getenv("POSTGRES_HOST", "db"), + "PORT": os.getenv("POSTGRES_PORT", "5432"), + "OPTIONS": { + "sslmode": os.getenv("POSTGRES_SSLMODE", "disable"), + }, } } # Celery -CELERY_BROKER_URL = "redis://redis:6379/0" -CELERY_RESULT_BACKEND = "redis://redis:6379/0" +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis:6379/0") +CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://redis:6379/0") CELERY_ACCEPT_CONTENT = ["json"] CELERY_TASK_SERIALIZER = "json" CELERY_RESULT_SERIALIZER = "json" @@ -48,7 +52,7 @@ CELERY_WORKER_PREFETCH_MULTIPLIER = 1 CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://redis:6379/1", + "LOCATION": os.getenv("REDIS_CACHE_URL", "redis://redis:6379/1"), "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", "CONNECTION_POOL_KWARGS": { diff --git a/src/config/settings/test.py b/src/settings/test.py similarity index 97% rename from src/config/settings/test.py rename to src/settings/test.py index a3f0374..7023c3c 100644 --- a/src/config/settings/test.py +++ b/src/settings/test.py @@ -6,6 +6,7 @@ from .base import * SECRET_KEY = "django-insecure-test-key-only-for-testing" DEBUG = True +STARTUP_CHECKS_ENABLED = False # JWT SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY @@ -91,7 +92,6 @@ REST_FRAMEWORK = { **globals().get("REST_FRAMEWORK", {}), "DEFAULT_AUTHENTICATION_CLASSES": [ "rest_framework_simplejwt.authentication.JWTAuthentication", - "rest_framework.authentication.SessionAuthentication", ], "TEST_REQUEST_DEFAULT_FORMAT": "json", # Disable throttling for tests diff --git a/tests/README.md b/tests/README.md index 39c80d1..d1d6aea 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,9 +33,9 @@ make test TARGET=user # Все тесты user app make test TARGET=models # Только тесты моделей make test TARGET=views # Только тесты представлений -# Или напрямую через скрипт -python run_tests_simple.py -python run_tests_simple.py user +# Или напрямую через pytest +uv run pytest tests +uv run pytest tests/apps/user ``` ### Различные способы запуска @@ -58,48 +58,25 @@ make test TARGET=test_models # То же что и models make test TARGET=test_views # То же что и views ``` -#### 2. Через улучшенный Django runner +#### 2. Через pytest +```bash ```bash # Все тесты -python run_tests_simple.py +uv run pytest tests # Конкретное приложение -python run_tests_simple.py user +uv run pytest tests/apps/user # Конкретные группы тестов -python run_tests_simple.py models -python run_tests_simple.py views -python run_tests_simple.py serializers -python run_tests_simple.py services - -# Полные имена файлов -python run_tests_simple.py test_models -python run_tests_simple.py test_views -``` - -#### 3. Через стандартный Django test runner - -```bash -# Все тесты -python run_tests.py - -# Конкретное приложение -python run_tests.py test tests.apps.user - -# Конкретный класс тестов -python run_tests.py test tests.apps.user.test_models.UserModelTest -``` - -#### 4. Через pytest (возможны проблемы с pdbpp) - -```bash -# Через скрипт-обертку -python run_pytest.py +uv run pytest tests -k test_models +uv run pytest tests -k test_views +uv run pytest tests -k test_serializers +uv run pytest tests -k test_services # Или напрямую, если настроен PYTHONPATH export PYTHONPATH=src:$PYTHONPATH -export DJANGO_SETTINGS_MODULE=config.settings.test +export DJANGO_SETTINGS_MODULE=settings.test pytest tests/ ``` @@ -107,7 +84,7 @@ pytest tests/ ### Настройки тестов -Тесты используют специальные настройки Django из `src/config/settings/test.py`: +Тесты используют специальные настройки Django из `src/settings/test.py`: - **База данных**: SQLite в памяти для быстрого выполнения - **Кэш**: Local memory cache вместо Redis @@ -132,10 +109,10 @@ pytest tests/ make test TARGET=models # Запуск конкретного файла напрямую -python run_tests_simple.py test_models +uv run pytest tests/apps/user/test_models.py # Все тесты с подробным выводом -python run_tests_simple.py +uv run pytest tests -v ``` ## 🏭 Фабрики тестовых данных @@ -225,13 +202,13 @@ def test_heavy_operation(): ```bash # Только юнит тесты -python run_pytest.py -m "unit" +uv run pytest -m "unit" # Исключить медленные тесты -python run_pytest.py -m "not slow" +uv run pytest -m "not slow" # Тесты моделей -python run_pytest.py -m "models" +uv run pytest -m "models" ``` ## 🔍 Отладка тестов @@ -240,13 +217,13 @@ python run_pytest.py -m "models" ```bash # Показать print statements -python run_pytest.py -s +uv run pytest -s # Подробные ошибки -python run_pytest.py --tb=long +uv run pytest --tb=long # Показать локальные переменные при ошибке -python run_pytest.py --tb=long --showlocals +uv run pytest --tb=long --showlocals ``` ### Использование pdb @@ -259,7 +236,7 @@ def test_something(): ```bash # Запуск с автоматическим pdb при ошибках -python run_pytest.py --pdb +uv run pytest --pdb ``` ## 📈 Покрытие кода @@ -268,10 +245,10 @@ python run_pytest.py --pdb ```bash # HTML отчет -make test-coverage +make test-cov # Или напрямую -python run_pytest.py --cov=src --cov-report=html:htmlcov +uv run pytest --cov=src --cov-report=html:htmlcov # Открыть отчет в браузере open htmlcov/index.html @@ -280,7 +257,7 @@ open htmlcov/index.html ### Просмотр в терминале ```bash -python run_pytest.py --cov=src --cov-report=term-missing +uv run pytest --cov=src --cov-report=term-missing ``` ## 🔧 Добавление новых тестов @@ -311,10 +288,10 @@ class NewModuleTest(TestCase): """Test description""" # Arrange expected_value = "test" - + # Act result = some_function() - + # Assert self.assertEqual(result, expected_value) ``` @@ -323,7 +300,7 @@ class NewModuleTest(TestCase): ### Частые ошибки -1. **Ошибка импорта**: Проверьте, что `PYTHONPATH` включает папку `src` +1. **Ошибка импорта**: Запускайте через `uv run pytest ...`, чтобы окружение и пути подхватывались корректно 2. **База данных**: Убедитесь, что используются тестовые настройки 3. **Миграции**: В тестах миграции отключены, но модели должны быть синхронизированы @@ -335,7 +312,7 @@ make clean # Пересоздание тестовой базы данных rm -f test_db.sqlite3 -python run_pytest.py --create-db +uv run pytest --create-db ``` ## 📚 Полезные ссылки @@ -365,4 +342,4 @@ make test TARGET=services # Сервисы (18 тестов) 3. **Изоляция**: Каждый тест должен быть независимым 4. **Покрытие**: Стремитесь к покрытию не менее 80% 5. **Быстрота**: Избегайте медленных операций в юнит тестах -6. **Читаемость**: Тесты должны быть понятными и хорошо документированными \ No newline at end of file +6. **Читаемость**: Тесты должны быть понятными и хорошо документированными diff --git a/tests/apps/core/test_admin.py b/tests/apps/core/test_admin.py index 1b03e83..d09fa1b 100644 --- a/tests/apps/core/test_admin.py +++ b/tests/apps/core/test_admin.py @@ -1,6 +1,7 @@ """Tests for core admin configurations.""" from datetime import timedelta +from unittest.mock import patch from django.contrib.admin.sites import AdminSite from django.contrib.messages.storage.fallback import FallbackStorage @@ -88,6 +89,8 @@ class CoreAdminTest(TestCase): ) request = self._request() qs = BackgroundJob.objects.all() - self.admin.revoke_jobs(request, qs) + with patch("celery.current_app.control.revoke") as revoke_mock: + self.admin.revoke_jobs(request, qs) + revoke_mock.assert_called_once_with(job.task_id, terminate=True) job.refresh_from_db() self.assertEqual(job.status, "revoked") diff --git a/tests/apps/core/test_openapi.py b/tests/apps/core/test_openapi.py index 766e007..9ec175f 100644 --- a/tests/apps/core/test_openapi.py +++ b/tests/apps/core/test_openapi.py @@ -2,11 +2,11 @@ from __future__ import annotations -from django.test import SimpleTestCase +from django.test import SimpleTestCase, override_settings from drf_yasg import openapi from rest_framework import serializers -from apps.core.openapi import _get_status_description, api_docs +from apps.core.openapi import _get_status_description, api_docs, swagger_tag class DummySerializer(serializers.Serializer): @@ -34,3 +34,10 @@ class OpenAPIDocsTest(SimpleTestCase): decorated = decorator(view) self.assertTrue(callable(decorated)) + + def test_swagger_tag_default_russian(self): + self.assertEqual(swagger_tag("Пользователь", "User"), "Пользователь") + + @override_settings(OPENAPI_USE_ENGLISH_TAGS=True) + def test_swagger_tag_english_in_dev_mode(self): + self.assertEqual(swagger_tag("Пользователь", "User"), "User") diff --git a/tests/apps/core/test_tasks.py b/tests/apps/core/test_tasks.py index 761a5fb..ba784e6 100644 --- a/tests/apps/core/test_tasks.py +++ b/tests/apps/core/test_tasks.py @@ -11,7 +11,7 @@ from apps.core.tasks import ( TransactionalTask, ) from celery import Task -from config.celery import app as celery_app +from core.celery import app as celery_app from django.test import TestCase diff --git a/tests/apps/user/test_views.py b/tests/apps/user/test_views.py index 4e3aa44..387545c 100644 --- a/tests/apps/user/test_views.py +++ b/tests/apps/user/test_views.py @@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model from django.urls import reverse from faker import Faker from rest_framework import status -from rest_framework.test import APITestCase +from rest_framework.test import APIClient, APITestCase from .factories import ProfileFactory, UserFactory @@ -310,3 +310,33 @@ class TokenRefreshViewTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertIn("error", response.data) + + +class ApiJwtOnlyAuthenticationTest(APITestCase): + """Tests that API auth flow is JWT-only and not session-cookie based.""" + + def setUp(self): + self.user = UserFactory.create_user() + self.tokens = UserService.get_tokens_for_user(self.user) + self.update_url = reverse("api_v1:user:user_update") + self.patch_data = {"username": fake.unique.user_name()} + + # Explicitly enable CSRF checks to catch accidental SessionAuthentication usage. + self.client = APIClient(enforce_csrf_checks=True) + self.client.cookies["sessionid"] = "fake-admin-session" + self.client.cookies["csrftoken"] = "fake-csrf-token" + + def test_patch_with_bearer_and_session_cookies_returns_200(self): + """Bearer JWT should authenticate even if session cookies are present.""" + self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.tokens['access']}") + + response = self.client.patch(self.update_url, self.patch_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], self.user.id) + + def test_patch_with_only_session_cookies_returns_401_not_403(self): + """Session cookies without JWT should not trigger CSRF 403 for API auth.""" + response = self.client.patch(self.update_url, self.patch_data, format="json") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/uv.lock b/uv.lock index 94a1f85..851707c 100644 --- a/uv.lock +++ b/uv.lock @@ -1364,7 +1364,7 @@ wheels = [ [[package]] name = "mostovik-backend" version = "0.1.0" -source = { editable = "." } +source = { virtual = "." } dependencies = [ { name = "beautifulsoup4" }, { name = "celery" },