diff --git a/.env.example b/.env.example index f3fe2f2..a0409f5 100644 --- a/.env.example +++ b/.env.example @@ -28,4 +28,11 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 LOG_LEVEL=INFO # Scrapy Settings -SCRAPY_LOG_LEVEL=INFO \ No newline at end of file +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/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 971d6f7..0681012 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -2,9 +2,12 @@ name: CI/CD Pipeline on: push: - branches: [ main, develop ] + branches: [ main, develop, dev ] pull_request: - branches: [ main, develop ] + branches: [ main, develop, dev ] + +env: + PYTHON_VERSION: "3.11" jobs: lint: @@ -13,110 +16,72 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install uv run: | + REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . + git checkout ${GITHUB_SHA} + + - name: Install Python and uv + run: | + apt-get update && apt-get install -y software-properties-common + add-apt-repository -y ppa:deadsnakes/ppa + apt-get update && apt-get install -y python3.11 python3.11-venv curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> $GITHUB_PATH + export PATH="$HOME/.local/bin:$PATH" - - name: Create virtual environment - run: uv venv - - - name: Activate virtual environment and install dependencies + - name: Create virtual environment and install dependencies run: | + export PATH="$HOME/.local/bin:$PATH" + uv venv --python python3.11 source .venv/bin/activate uv sync --dev - name: Run Ruff linting run: | + export PATH="$HOME/.local/bin:$PATH" source .venv/bin/activate - ruff check . + ruff check src/ - name: Run Ruff formatting check run: | + export PATH="$HOME/.local/bin:$PATH" source .venv/bin/activate - ruff format . --check + ruff format src/ --check test: name: Run Tests runs-on: ubuntu-latest - services: - postgres: - image: postgres:15.10 - env: - POSTGRES_DB: test_db - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - redis: - image: redis:7-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 6379:6379 steps: - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install uv run: | + REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . + git checkout ${GITHUB_SHA} + + - name: Install Python and uv + run: | + apt-get update && apt-get install -y software-properties-common + add-apt-repository -y ppa:deadsnakes/ppa + apt-get update && apt-get install -y python3.11 python3.11-venv curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.local/bin" >> $GITHUB_PATH + export PATH="$HOME/.local/bin:$PATH" - - name: Create virtual environment - run: uv venv - - - name: Activate virtual environment and install dependencies + - name: Create virtual environment and install dependencies run: | + export PATH="$HOME/.local/bin:$PATH" + uv venv --python python3.11 source .venv/bin/activate uv sync --dev - - name: Wait for services to be ready - run: | - # Wait for PostgreSQL - until pg_isready -h localhost -p 5432 -U postgres; do - echo "Waiting for PostgreSQL..." - sleep 2 - done - - # Wait for Redis - until redis-cli -h localhost -p 6379 ping; do - echo "Waiting for Redis..." - sleep 2 - done - - name: Run Django tests run: | + export PATH="$HOME/.local/bin:$PATH" source .venv/bin/activate - cd src - python manage.py test --verbosity=2 + export PYTHONPATH="${PWD}/src:${PYTHONPATH}" + python src/manage.py test tests --verbosity=2 env: - DJANGO_SETTINGS_MODULE: config.settings.development - DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db - REDIS_URL: redis://localhost:6379/0 - CELERY_BROKER_URL: redis://localhost:6379/0 + DJANGO_SETTINGS_MODULE: config.settings.test SECRET_KEY: test-secret-key-for-ci build: @@ -126,122 +91,135 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Extract metadata for web image - id: meta-web - uses: docker/metadata-action@v5 - with: - images: | - ${{ github.repository_owner }}/mostovik-web - tags: | - type=ref,event=branch - type=ref,event=pr - type=sha,prefix={{branch}}- - - - name: Extract metadata for celery image - id: meta-celery - uses: docker/metadata-action@v5 - with: - images: | - ${{ github.repository_owner }}/mostovik-celery - tags: | - type=ref,event=branch - type=ref,event=pr - type=sha,prefix={{branch}}- + run: | + REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . + git checkout ${GITHUB_SHA} - name: Build web image - uses: docker/build-push-action@v5 - with: - context: . - file: ./docker/Dockerfile.web - push: false - load: true - tags: ${{ steps.meta-web.outputs.tags }} - labels: ${{ steps.meta-web.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + run: | + BRANCH_TAG=$(echo ${GITHUB_REF_NAME} | sed 's/\//-/g') + SHA_SHORT=$(echo ${GITHUB_SHA} | cut -c1-7) + docker build -f ./docker/Dockerfile.web -t state_corp-web:${BRANCH_TAG} -t state_corp-web:${BRANCH_TAG}-${SHA_SHORT} . - name: Build celery image - uses: docker/build-push-action@v5 - with: - context: . - file: ./docker/Dockerfile.celery - push: false - load: true - tags: ${{ steps.meta-celery.outputs.tags }} - labels: ${{ steps.meta-celery.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max + run: | + BRANCH_TAG=$(echo ${GITHUB_REF_NAME} | sed 's/\//-/g') + SHA_SHORT=$(echo ${GITHUB_SHA} | cut -c1-7) + docker build -f ./docker/Dockerfile.celery -t state_corp-celery:${BRANCH_TAG} -t state_corp-celery:${BRANCH_TAG}-${SHA_SHORT} . push: name: Push to Gitea Registry runs-on: ubuntu-latest needs: [build] - if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/dev' steps: - name: Checkout code - uses: actions/checkout@v4 - - - name: Login to Gitea Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ vars.GITEA_REGISTRY_URL }} - username: ${{ secrets.GITEA_USERNAME }} - password: ${{ secrets.GITEA_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Extract metadata for web image - id: meta-web - uses: docker/metadata-action@v5 - with: - images: | - ${{ vars.GITEA_REGISTRY_URL }}/${{ github.repository_owner }}/mostovik-web - tags: | - type=ref,event=branch - type=sha,prefix={{branch}}- - type=raw,value=latest,enable={{is_default_branch}} - - - name: Extract metadata for celery image - id: meta-celery - uses: docker/metadata-action@v5 - with: - images: | - ${{ vars.GITEA_REGISTRY_URL }}/${{ github.repository_owner }}/mostovik-celery - tags: | - type=ref,event=branch - type=sha,prefix={{branch}}- - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push web image - uses: docker/build-push-action@v5 - with: - context: . - file: ./docker/Dockerfile.web - push: true - tags: ${{ steps.meta-web.outputs.tags }} - labels: ${{ steps.meta-web.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Build and push celery image - uses: docker/build-push-action@v5 - with: - context: . - file: ./docker/Dockerfile.celery - push: true - tags: ${{ steps.meta-celery.outputs.tags }} - labels: ${{ steps.meta-celery.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Image digest run: | - echo "Web image digest: ${{ steps.docker_build_web.outputs.digest }}" - echo "Celery image digest: ${{ steps.docker_build_celery.outputs.digest }}" + REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . + git checkout ${GITHUB_SHA} + + - name: Build and push images + run: | + # Install crane for pushing to Gitea container registry + curl -sL https://github.com/google/go-containerregistry/releases/download/v0.19.0/go-containerregistry_Linux_x86_64.tar.gz | tar xz crane + chmod +x crane + + BRANCH_TAG=$(echo ${GITHUB_REF_NAME} | sed 's/\//-/g') + SHA_SHORT=$(echo ${GITHUB_SHA} | cut -c1-7) + REGISTRY_HOST="10.10.0.10:3000" + REGISTRY="${REGISTRY_HOST}/${{ github.repository_owner }}" + + # Debug + echo "Registry: ${REGISTRY_HOST}" + echo "Actor: ${GITHUB_ACTOR}" + + # Login to Gitea container registry (HTTP, requires --insecure) + echo "${REGISTRY_PASSWORD}" | ./crane auth login --insecure ${REGISTRY_HOST} -u "${REGISTRY_USER}" --password-stdin + + # Build and push web image + docker build -f ./docker/Dockerfile.web -t state_corp-web:local . + docker save state_corp-web:local -o /tmp/web.tar + + ./crane push --insecure /tmp/web.tar ${REGISTRY}/state_corp-web:${BRANCH_TAG} + ./crane push --insecure /tmp/web.tar ${REGISTRY}/state_corp-web:${BRANCH_TAG}-${SHA_SHORT} + + if [ "${GITHUB_REF_NAME}" = "main" ]; then + ./crane push --insecure /tmp/web.tar ${REGISTRY}/state_corp-web:latest + fi + + # Build and push celery image + docker build -f ./docker/Dockerfile.celery -t state_corp-celery:local . + docker save state_corp-celery:local -o /tmp/celery.tar + + ./crane push --insecure /tmp/celery.tar ${REGISTRY}/state_corp-celery:${BRANCH_TAG} + ./crane push --insecure /tmp/celery.tar ${REGISTRY}/state_corp-celery:${BRANCH_TAG}-${SHA_SHORT} + + if [ "${GITHUB_REF_NAME}" = "main" ]; then + ./crane push --insecure /tmp/celery.tar ${REGISTRY}/state_corp-celery:latest + fi + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }} + + - name: Image summary + run: | + echo "Images pushed to 10.10.0.10:3000/${{ github.repository_owner }}/" + + deploy: + name: Deploy to Server + runs-on: ubuntu-latest + needs: [push] + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || github.ref == 'refs/heads/dev' + + steps: + - name: Checkout code + run: | + REPO_URL=$(echo ${GITHUB_SERVER_URL} | sed "s|://|://oauth2:${{ gitea.token }}@|") + git clone --depth=1 --branch=${GITHUB_REF_NAME} ${REPO_URL}/${GITHUB_REPOSITORY}.git . + git checkout ${GITHUB_SHA} + + - name: Deploy via SSH + run: | + BRANCH_TAG=$(echo ${GITHUB_REF_NAME} | sed 's/\//-/g') + + # Setup SSH (decode base64 key) + 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 + + # Copy docker-compose.prod.yml to server + scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no docker-compose.prod.yml ${DEPLOY_USER}@${DEPLOY_HOST}:/opt/state-corp-backend/ + + # Deploy commands + ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} " + cd /opt/state-corp-backend + + # Login to registry (HTTP on internal IP) + echo '${REGISTRY_PASSWORD}' | docker login --username '${REGISTRY_USER}' --password-stdin 10.10.0.10:3000 + + # Pull new images + export IMAGE_TAG=${BRANCH_TAG} + docker compose -f docker-compose.prod.yml pull web celery_worker celery_beat + + # Stop and remove all project containers + docker compose -f docker-compose.prod.yml down --remove-orphans || true + docker rm -f state_corp_db state_corp_redis state_corp_web state_corp_celery_worker state_corp_celery_beat 2>/dev/null || true + + # Start services + docker compose -f docker-compose.prod.yml up -d + + # Cleanup old images + docker image prune -f + " + + echo "Deployed ${BRANCH_TAG} to ${DEPLOY_HOST}" + 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 }} diff --git a/.gitignore b/.gitignore index a5bd201..40f5676 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,6 @@ Thumbs.db # Backup files *.bak -*.backup \ No newline at end of file +*.backupdata/ +data/ +.zed/ diff --git a/.qoder/rules/main.md b/.qoder/rules/main.md deleted file mode 100644 index de8d1c1..0000000 --- a/.qoder/rules/main.md +++ /dev/null @@ -1,422 +0,0 @@ ---- -trigger: always_on ---- - -## 0) Язык и стиль общения (СТРОГО) -- ИИ-агент **ВСЕГДА отвечает на русском языке** -- Английский допускается ТОЛЬКО для: - - имён библиотек - - имён классов, функций, переменных - - CLI-команд -- Тон: инженерный, практичный, без воды и маркетинга - ---- - -## 1) Базовые принципы (НЕ ОБСУЖДАЮТСЯ) -Проект разрабатывается строго по принципам: - -- **SOLID** -- **KISS** -- **DRY** - -Правила приоритета: -- красиво vs просто → **простота** -- умно vs поддерживаемо → **поддерживаемость** -- магия vs явность → **явность** - ---- - -## 2) Контекст проекта -- ОС: **Astra Linux 1.8** -- Python: **3.11.2** -- Django: **3.x (указано 3.14)** -- Django REST Framework (DRF) -- Celery -- PostgreSQL **15.10** -- Apache **2.4.57** -- mod_wsgi **4.9.4** - -### Инструменты разработки -- **uv** -- **виртуальная среда** -- **pre-commit** -- **Gitea Actions (CI)** -- Тесты: `django test` -- Линтинг: `ruff` -- **ЗАПРЕЩЕНО** создавать тестовые скрипты для демонстрации, все должно быть исправлено в рамках проекта - ---- - -## 3) Окружение и команды (СТРОГО) -Все команды: -- выполняются **только внутри виртуальной среды** -- используют **uv** -- считаются выполняемыми из корня проекта - -### ❌ Запрещено -- `pip`, `python -m pip` -- `poetry`, `pipenv`, `pipx` -- системные команды вне venv - -### ✅ Разрешено -- `uv venv` -- `source .venv/bin/activate` -- `uv add / uv remove / uv sync` -- `uv run ` - -Пример: -```bash -uv run python manage.py test -``` - ---- - -## 4) Архитектура и слои ответственности (КРИТИЧНО) - -### 4.1 View (DRF) -View отвечает ТОЛЬКО за: -- приём HTTP-запроса -- проверку прав доступа -- работу с serializer -- вызов сервисного слоя - -❌ Запрещено: -- бизнес-логика -- сложные условия -- транзакции -- сложная работа с ORM - ---- - -### 4.2 Serializer -Serializer отвечает за: -- валидацию данных -- преобразование вход/выход - -Допускается: -- field-level validation -- object-level validation - -❌ Запрещено: -- бизнес-правила -- side-effects -- сложная логика в `save()` - ---- - -### 4.3 Сервисный слой (Business Logic) -- **ВСЯ бизнес-логика живёт здесь** -- Сервисы: - - не зависят от HTTP - - легко тестируются - - управляют транзакциями -- Сервис определяет *что* делать, а не *как* отдать ответ - -Рекомендуемый паттерн: -```python -class EntityService: - @classmethod - def do_something(cls, *, data): - ... -``` - ---- - -### 4.4 Модели (ORM) -Модели должны быть: -- простыми -- декларативными - -Допускается: -- `__str__` -- простые computed properties -- минимальные helper-методы - -❌ Запрещено: -- бизнес-логика -- workflow -- сигналы как логика -- условия, зависящие от сценариев - -👉 **Любые исключения — ТОЛЬКО после обсуждения в чате.** - ---- - -## 5) Celery -- Task = **thin wrapper** -- Task вызывает сервис, а не содержит логику -- Таски: - - идемпотентны - - логируют начало и завершение -- Ретраи: - - только для временных ошибок - - с backoff - ---- - -## 6) База данных и миграции -- Любое изменение моделей → миграции обязательны -- Миграции: - - детерминированные - - без ручной магии без причины - -Проверка перед коммитом: -```bash -uv run python manage.py makemigrations --check --dry-run -``` - -PostgreSQL: -- транзакции использовать осознанно -- `select_for_update()` при гонках -- Raw SQL — только с объяснением - ---- - -## 7) Тестирование -- Любая бизнес-логика → тесты -- В первую очередь тестируется сервисный слой -- API — happy path + edge cases - -Запуск: -```bash -uv run python manage.py test -``` - ---- - -## 8) pre-commit (обязателен) -- Любой код обязан проходить pre-commit -- Агент обязан учитывать проверки форматирования и линтинга - -```bash -pre-commit run --all-files -``` - ---- - -## 9) CI (Gitea Actions) -- Используется **Gitea Actions** -- ❌ GitHub Actions запрещены -- Любые изменения: - - не должны ломать CI -- Если меняются: - - зависимости - - команды тестов - - миграции - → агент обязан указать необходимость правок workflow - ---- - -## 10) Apache + mod_wsgi -- Используется **ТОЛЬКО WSGI** -- ASGI запрещён без отдельного обсуждения -- Любые изменения в `wsgi.py`, путях, статике: - - сопровождаются пояснением - - требуют перезапуска Apache - -```bash -systemctl restart apache2 -``` - -Учитывать ограничения и права Astra Linux. - ---- - -## 11) Работа с репозиторием -- Минимальный diff — приоритет -- ❌ Не коммитить: - - `.venv` - - артефакты - - дампы БД -- Массовый рефакторинг — только по явному запросу - ---- - -## 12) Anti-patterns (ЗАПРЕЩЕНО) -- Fat Models -- God Views -- Бизнес-логика в Serializers -- Сигналы как workflow -- Магия в `save()` -- Прямые импорты моделей между apps -- Сложная логика в queryset как бизнес-правило - ---- - -## 13) Формат ответа ИИ-агента (ОБЯЗАТЕЛЬНЫЙ) -Каждый ответ должен содержать: - -1. **Что меняем** -2. **Файлы / патч** -3. **Команды (через uv)** -4. **Проверки (tests / pre-commit / CI)** -5. **Риски / замечания** - ---- - -## 14) Исключения -- ИИ-агент **НЕ внедряет исключения сам** -- Агент: - - описывает стандартное решение - - объясняет, почему оно не подходит - - запрашивает разрешение в чате - -## 15) Структура проекта и миксины (ОБЯЗАТЕЛЬНО) - -### 15.0 Правило Core-First (КРИТИЧНО) -**ПЕРЕД созданием любого нового компонента** агент ОБЯЗАН проверить модуль `apps.core`: - -``` -src/apps/core/ -├── mixins.py # Model mixins (TimestampMixin, SoftDeleteMixin, etc.) -├── services.py # BaseService, BackgroundJobService -├── views.py # Health checks, BackgroundJob API -├── viewsets.py # BaseViewSet, ReadOnlyViewSet -├── exceptions.py # APIError, NotFoundError, ValidationError -├── permissions.py # IsOwner, IsAdminOrReadOnly, etc. -├── pagination.py # CursorPagination -├── filters.py # BaseFilterSet -├── cache.py # cache_result, invalidate_cache -├── tasks.py # BaseTask для Celery -├── logging.py # StructuredLogger -├── middleware.py # RequestIDMiddleware -├── signals.py # SignalDispatcher -├── responses.py # APIResponse wrapper -├── openapi.py # api_docs decorator -└── management/commands/base.py # BaseAppCommand -``` - -**Порядок действий:** -1. Проверить `apps.core` на наличие нужного базового класса/миксина -2. Наследоваться от существующего, а не создавать с нуля -3. Если нужного нет — обсудить добавление в core - -❌ **ЗАПРЕЩЕНО:** создавать дублирующую функциональность в app-модулях - ---- - -### 15.1 Model Mixins -При создании моделей **ОБЯЗАТЕЛЬНО** использовать миксины из `apps.core.mixins`: - -| Миксин | Когда использовать | Поля | -|--------|-------------------|------| -| `TimestampMixin` | **ВСЕГДА** для любой модели | `created_at`, `updated_at` | -| `UUIDPrimaryKeyMixin` | Когда нужен UUID вместо int ID | `id` (UUID) | -| `SoftDeleteMixin` | Когда нельзя физически удалять | `is_deleted`, `deleted_at` | -| `AuditMixin` | Когда нужно знать кто создал/изменил | `created_by`, `updated_by` | -| `OrderableMixin` | Для сортируемых списков | `order` | -| `StatusMixin` | Для моделей со статусами | `status` | -| `SlugMixin` | Для URL-friendly идентификаторов | `slug` | - -**Пример правильного использования:** -```python -from apps.core.mixins import TimestampMixin, SoftDeleteMixin, AuditMixin - -class Document(TimestampMixin, SoftDeleteMixin, AuditMixin, models.Model): - """Документ с историей и мягким удалением.""" - title = models.CharField(max_length=200) - - class Meta: - ordering = ['-created_at'] -``` - -**Порядок наследования миксинов:** -1. `UUIDPrimaryKeyMixin` (если нужен) -2. `TimestampMixin` -3. `SoftDeleteMixin` (если нужен) -4. `AuditMixin` (если нужен) -5. `OrderableMixin` / `StatusMixin` / `SlugMixin` -6. `models.Model` (последним) - ---- - -### 15.2 Management Commands -Все management commands наследуются от `BaseAppCommand`: - -```python -from apps.core.management.commands.base import BaseAppCommand - -class Command(BaseAppCommand): - help = 'Описание команды' - use_transaction = True # Обернуть в транзакцию - - def add_arguments(self, parser): - super().add_arguments(parser) # Добавляет --dry-run, --silent - parser.add_argument('--my-arg', type=str) - - def execute_command(self, *args, **options): - items = MyModel.objects.all() - - for item in self.progress_iter(items, desc="Обработка"): - if not self.dry_run: - self.process(item) - - return "Обработано успешно" -``` - -**Возможности BaseAppCommand:** -- `--dry-run` — тестовый запуск без изменений -- `--silent` — минимальный вывод -- `self.progress_iter()` — прогресс-бар -- `self.timed_operation()` — измерение времени -- `self.confirm()` — подтверждение -- `self.log_info/success/warning/error()` — логирование - ---- - -### 15.3 Background Jobs (Celery) -Для отслеживания статуса фоновых задач использовать `BackgroundJob`: - -```python -# В сервисе при запуске задачи -from apps.core.services import BackgroundJobService - -job = BackgroundJobService.create_job( - task_id=task.id, - task_name="apps.myapp.tasks.process_data", - user_id=request.user.id, -) - -# В Celery таске -from apps.core.models import BackgroundJob - -@shared_task(bind=True) -def my_task(self, data): - job = BackgroundJob.objects.get(task_id=self.request.id) - job.mark_started() - - for i, item in enumerate(items): - process(item) - job.update_progress(i * 100 // len(items), "Обработка...") - - job.complete(result={"processed": len(items)}) -``` - -**API эндпоинты:** -- `GET /api/v1/jobs/` — список задач пользователя -- `GET /api/v1/jobs/{task_id}/` — статус конкретной задачи - ---- - -### 15.4 Factories (тестирование) -Все фабрики используют `factory_boy` + `faker`: - -```python -import factory -from faker import Faker - -fake = Faker("ru_RU") - -class MyModelFactory(factory.django.DjangoModelFactory): - class Meta: - model = MyModel - - name = factory.LazyAttribute(lambda _: fake.word()) - email = factory.LazyAttribute(lambda _: fake.unique.email()) -``` - -**Правила:** -- Никакого хардкода в тестах (`"test@example.com"` → `fake.email()`) -- Использовать `fake.unique.*` для уникальных полей -- Локаль: `Faker("ru_RU")` для русских данных - diff --git a/CHANGELOG.md b/CHANGELOG.md index 5717a2b..f6d1a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,120 @@ --- +## [0.4.1] - 2026-02-02 + +### Исправлено + +#### CI/CD Pipeline +- Удалена зависимость от GitHub Actions (сеть блокирует доступ к GitHub) +- `actions/checkout@v4` → `git clone` с переменными Gitea +- `actions/setup-python@v4` → установка через `apt-get` +- `docker/build-push-action@v5` → чистые `docker build/push` команды +- Тесты используют `config.settings.test` (SQLite in-memory) вместо PostgreSQL service + +#### Code Quality +- Исправлены ошибки ruff lint: + - Сортировка импортов (I001) + - Удалены неиспользуемые импорты и переменные (F401, F841) + - Добавлены noqa для тестового кода (S106, S314) + +--- + +## [0.4.0] - 2026-01-28 + +### Добавлено + +#### Парсер ФНС бухгалтерской отчетности (`apps.parsers.clients.fns`) +- **FNSExcelParser** — парсер Excel файлов бухгалтерской отчетности: + - Формат файла: `fin_{external_id}_{ogrn}.xlsx` + - Поддержка форм: №1 (Баланс), №2 (Прибыль/Убыток), №3 (Капитал), №4 (Денежные потоки), №6 (Целевое использование) + - Автоматическое определение года и формы по структуре листа + - Извлечение значений period_start/period_end для каждой строки + +- **Модели** (`models.py`): + - `FinancialReport` — отчет с метаданными (external_id, ogrn, file_hash, status, source) + - `FinancialReportLine` — строки отчета (form_code, line_code, year, period_start, period_end) + - SHA256 хэш файла для дедупликации + - Индексы: ogrn, year, form_code+line_code + +- **Сервисный слой** (`services.py`): + - `FNSReportService` — сохранение отчетов, проверка дубликатов по хешу + - Поиск по ОГРН + - Bulk-сохранение строк отчета + +- **Celery задачи** (`tasks.py`): + - `scan_fns_directory` — периодическое сканирование папки каждые 5 минут + - `process_fns_file` — обработка одного файла + - `process_fns_files_batch` — пакетная обработка через API + - Перемещение файлов в `processed/` или `failed/` + +- **API endpoints** (`views.py`, `urls.py`): + - `POST /api/v1/fns/upload/` — пакетная загрузка файлов + - `GET /api/v1/fns/reports/` — список отчетов с фильтрацией + - `GET /api/v1/fns/reports/{id}/` — детали отчета со строками + - Swagger теги для группировки в документации + +- **Админка** (`admin.py`): + - `FinancialReportAdmin` с inline для строк + - Цветовая индикация статусов + - Фильтры: status, source, ogrn + +#### Тестирование +- 25 unit-тестов для парсера, схем и сервиса +- Покрытие: валидация имени файла, парсинг значений, сохранение отчетов + +### Конфигурация +- `FNS_WATCH_DIRECTORY` — папка для мониторинга (`/src/input/fns`) +- `FNS_PROCESSED_DIRECTORY` — папка обработанных файлов +- `FNS_FAILED_DIRECTORY` — папка с ошибками +- Celery Beat: `scan-fns-directory` каждые 5 минут + +--- + +## [0.3.0] - 2026-01-27 + +### Добавлено + +#### Парсер zakupki.gov.ru (`apps.parsers.clients.zakupki`) +- **ZakupkiClient** — клиент для получения данных о закупках: + - Интеграция через SOAP API (FTP закрыт с 01.01.2025) + - Методы: `getDocsByOrgRegionRequest`, `getDocsByReestrNumberRequest` + - Парсинг XML/ZIP архивов с поддержкой множественных кодировок (UTF-8, Windows-1251) + - Поддержка прокси-серверов + - Маппинг 80+ регионов РФ + +- **Модель ProcurementRecord** (`models.py`): + - 18 полей: номер закупки, ИНН/КПП/ОГРН заказчика, НМЦ, тип закона (44-ФЗ/223-ФЗ), статус + - Поля региона, года, месяца для фильтрации + - `load_batch` для отслеживания пакетной загрузки + - 3 индекса для оптимизации запросов + +- **Сервисный слой** (`services.py`): + - `ProcurementService` — сохранение, поиск, отслеживание загрузок + - `ParserLoadLogService` — логирование результатов парсинга + - Bulk-операции с chunking и обработкой дубликатов + +- **Celery задачи** (`tasks.py`): + - `parse_procurements` — загрузка по региону/году/месяцу с BackgroundJob tracking + - `sync_procurements` — синхронизация помесячно с автопродолжением + +- **Админка** (`admin.py`): + - Цветовая индикация статусов + - Поиск по номеру закупки, ИНН, ОГРН, названию заказчика + - Фильтры: тип закона, статус, регион, batch, дата создания + - Read-only режим + +#### Тестирование +- 71 тест (66 unit + 5 E2E) +- `ProcurementRecordFactory` с Faker("ru_RU") +- E2E тесты с реальными HTTP-запросами (активация: `RUN_E2E_TESTS=1`) +- Покрытие: клиент, сервисы, задачи + +### Требования для работы +- Токен SOAP API (получается через Госуслуги на `https://zakupki.gov.ru/pmd/auth/welcome`) + +--- + ## [0.2.0] - 2026-01-21 ### Добавлено diff --git a/README.md b/README.md index 0e00569..67e1a7d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# State-Corp / Отчётность Организаций +# Django ETL Boilerplate -Backend для системы Отчётность Организаций (State-Corp). +Шаблон Django приложения для ETL (Extract, Transform, Load) операций с функциями веб-скрапинга. + +Название проекта: State Corp Backend ## Технологический стек @@ -10,10 +12,53 @@ Backend для системы Отчётность Организаций (State - **PostgreSQL**: 15.10 - **Redis**: 7.x - **Celery**: 5.3.6 -- **Scrapy**: 2.11.2 +- **Playwright**: 1.52+ (browser automation) - **Gunicorn**: 21.2.0 - **Apache**: 2.4.57 +## Парсеры данных + +Проект включает парсеры для загрузки данных из государственных источников: + +### Минпромторг (minpromtorg.gov.ru) +- **Сертификаты промышленного производства** - `parse_industrial_production` +- **Реестр производителей** - `parse_manufactures` + +### Единый реестр проверок (proverki.gov.ru) +- **Проверки по ФЗ-294** - традиционные проверки +- **Проверки по ФЗ-248** - новые проверки с 2021 года +- **Автоматическая синхронизация** - `sync_inspections` + +### Запуск парсеров + +```python +# Через Celery +from apps.parsers.tasks import ( + parse_industrial_production, + parse_manufactures, + parse_inspections, + sync_inspections, +) + +# Парсинг сертификатов +parse_industrial_production.delay() + +# Парсинг производителей +parse_manufactures.delay() + +# Парсинг проверок за конкретный месяц +parse_inspections.delay(year=2025, month=10, is_federal_law_248=False) + +# Автоматическая синхронизация (с 01.01.2025 до текущего месяца) +sync_inspections.delay() +``` + +### Особенности парсера proverki.gov.ru +- Использует **Playwright** для JS-рендеринга +- Поддержка **потокового парсинга** для больших файлов (>50 МБ) +- Автоматическое определение последнего загруженного периода +- Раздельная загрузка ФЗ-294 и ФЗ-248 + ## Структура проекта ``` @@ -313,4 +358,38 @@ make clean # Очистка временных файлов ## Лицензия -MIT License \ No newline at end of file +MIT License + +--- + +## Changelog + +### 2026-01-21 +#### Добавлено +- **Задача `sync_inspections`** - автоматическая синхронизация проверок с proverki.gov.ru + - Инкрементальная загрузка с последнего сохранённого периода + - Начало с 01.01.2025 если БД пуста + - Раздельная загрузка ФЗ-294 и ФЗ-248 + - Автоматическая остановка при отсутствии данных (2 пустых месяца) +- **Поля в модели InspectionRecord**: + - `is_federal_law_248` - признак проверки по ФЗ-248 + - `data_year` - год загруженных данных + - `data_month` - месяц загруженных данных +- **Потоковый парсинг XML** для файлов >50 МБ (iterparse) +- **Методы в InspectionService**: + - `get_last_loaded_period()` - получение последнего загруженного периода + - `has_data_for_period()` - проверка наличия данных за период + +### 2026-01-20 +#### Добавлено +- **Парсер proverki.gov.ru** с поддержкой Playwright +- Навигация по порталу (клик на вкладку "Скачать") +- Парсинг XML с namespaces +- Извлечение данных из атрибутов и вложенных элементов + +### 2026-01-19 +#### Добавлено +- **Парсеры Минпромторга** (сертификаты, производители) +- **Модуль apps.parsers** с клиентами, сервисами и задачами Celery +- **Django Admin** для управления записями парсеров +- Дедупликация по unique constraints \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 329db80..386cf29 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,14 @@ -version: '3.8' - services: db: image: postgres:15.10 container_name: state_corp_db restart: unless-stopped environment: - POSTGRES_DB: ${POSTGRES_DB:-project_dev} + POSTGRES_DB: ${POSTGRES_DB:-state_corp_dev} POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} volumes: - - postgres_data:/var/lib/postgresql/data + - ./data/db:/var/lib/postgresql/data - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql ports: - "5432:5432" @@ -29,7 +27,7 @@ services: ports: - "6379:6379" volumes: - - redis_data:/data + - ./data/redis:/data networks: - state_corp_network healthcheck: @@ -54,11 +52,12 @@ services: - SECRET_KEY=${SECRET_KEY:-django-insecure-development-key} - POSTGRES_HOST=db - POSTGRES_PORT=5432 - - POSTGRES_DB=${POSTGRES_DB:-project_dev} + - POSTGRES_DB=${POSTGRES_DB:-state_corp_dev} - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - REDIS_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 volumes: - ./src:/app/src - ./logs:/app/logs @@ -88,11 +87,12 @@ services: - DEBUG=${DEBUG:-True} - POSTGRES_HOST=db - POSTGRES_PORT=5432 - - POSTGRES_DB=${POSTGRES_DB:-project_dev} + - POSTGRES_DB=${POSTGRES_DB:-state_corp_dev} - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - REDIS_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 volumes: - ./src:/app/src - ./logs:/app/logs @@ -115,11 +115,12 @@ services: - DEBUG=${DEBUG:-True} - POSTGRES_HOST=db - POSTGRES_PORT=5432 - - POSTGRES_DB=${POSTGRES_DB:-project_dev} + - POSTGRES_DB=${POSTGRES_DB:-state_corp_dev} - POSTGRES_USER=${POSTGRES_USER:-postgres} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - REDIS_URL=redis://redis:6379/0 - CELERY_BROKER_URL=redis://redis:6379/0 + - CELERY_RESULT_BACKEND=redis://redis:6379/0 volumes: - ./src:/app/src - ./logs:/app/logs @@ -127,10 +128,6 @@ services: - state_corp_network command: celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler -volumes: - postgres_data: - redis_data: - networks: state_corp_network: - driver: bridge \ No newline at end of file + driver: bridge diff --git a/docker/Dockerfile.celery b/docker/Dockerfile.celery index 9b5a0bd..b4c56cb 100644 --- a/docker/Dockerfile.celery +++ b/docker/Dockerfile.celery @@ -3,12 +3,30 @@ 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 \ + 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/* # Создание рабочей директории @@ -26,10 +44,17 @@ RUN pip install --no-cache-dir -r requirements-dev.txt COPY src/ ./src/ # Создание необходимых директорий -RUN mkdir -p logs +RUN mkdir -p logs src/logs + +# PYTHONPATH для доступа к модулям +ENV PYTHONPATH=/app/src # Создание пользователя для запуска приложения 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 diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web index 203b11b..d196530 100644 --- a/docker/Dockerfile.web +++ b/docker/Dockerfile.web @@ -3,13 +3,13 @@ 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 \ + gcc \ + postgresql-client \ + libpq-dev \ + libffi-dev \ + libxml2-dev \ + libxslt1-dev \ + zlib1g-dev \ && rm -rf /var/lib/apt/lists/* # Создание рабочей директории @@ -27,7 +27,10 @@ RUN pip install --no-cache-dir -r requirements-dev.txt COPY src/ ./src/ # Создание необходимых директорий -RUN mkdir -p logs staticfiles media +RUN mkdir -p logs staticfiles media src/logs src/static src/staticfiles src/media + +# PYTHONPATH для доступа к модулям +ENV PYTHONPATH=/app/src # Создание пользователя для запуска приложения RUN groupadd -r appgroup && useradd -r -g appgroup appuser diff --git a/pyproject.toml b/pyproject.toml index 54aeb36..c2aa59b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,8 @@ [project] name = "state-corp-backend" version = "0.1.0" -description = "Backend для системы Отчётность Организаций (State-Corp)" -authors = [ - {name = "Your Name", email = "your.email@example.com"}, -] +description = "Backend service for State Corp project" +authors = [{ name = "Your Name", email = "your.email@example.com" }] requires-python = ">=3.11" dependencies = [ # Django Framework @@ -47,6 +45,11 @@ dependencies = [ "model-bakery>=1.17.0", "faker>=40.1.2", "factory-boy>=3.3.0", + "openpyxl>=3.1.5", + "django-jazzmin>=2.6.2", + "playwright>=1.57.0", + "pylint>=3.0", + "whitenoise>=6.11.0", ] [project.optional-dependencies] @@ -103,7 +106,7 @@ packages = ["src"] # ================================================================================== [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "config.settings.test" -python_paths = ["src"] +django_find_project = false testpaths = ["tests"] addopts = [ "--verbose", @@ -222,29 +225,29 @@ exclude = [ [tool.ruff.lint] # Enable pycodestyle (`E`) and Pyflakes (`F`) codes by default. select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "C", # mccabe - "B", # flake8-bugbear - "Q", # flake8-quotes - "DJ", # flake8-django - "UP", # pyupgrade - "S", # bandit security - "T20", # flake8-print - "SIM", # flake8-simplify + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "C", # mccabe + "B", # flake8-bugbear + "Q", # flake8-quotes + "DJ", # flake8-django + "UP", # pyupgrade + "S", # bandit security + "T20", # flake8-print + "SIM", # flake8-simplify ] extend-ignore = [ - "E501", # line too long, handled by formatter - "DJ01", # Missing docstring (too strict for Django) - "DJ001", # null=True on string fields (architectural decision) - "F403", # star imports (common in Django settings) - "F405", # name may be undefined from star imports (Django settings) - "E402", # module level import not at top (Django settings) - "S101", # Use of assert (common in tests) - "T201", # print statements (useful for debugging) + "E501", # line too long, handled by formatter + "DJ01", # Missing docstring (too strict for Django) + "DJ001", # null=True on string fields (architectural decision) + "F403", # star imports (common in Django settings) + "F405", # name may be undefined from star imports (Django settings) + "E402", # module level import not at top (Django settings) + "S101", # Use of assert (common in tests) + "T201", # print statements (useful for debugging) ] # Allow autofix for all enabled rules (when `--fix`) is provided. @@ -393,13 +396,13 @@ skips = ["B101", "B601"] # ================================================================================== [tool.pylint.messages_control] disable = [ - "C0114", # missing-module-docstring - "C0115", # missing-class-docstring - "C0116", # missing-function-docstring - "R0903", # too-few-public-methods (Django models) - "R0901", # too-many-ancestors (Django views) - "W0613", # unused-argument (Django views) - "C0103", # invalid-name (Django field names) + "C0114", # missing-module-docstring + "C0115", # missing-class-docstring + "C0116", # missing-function-docstring + "R0903", # too-few-public-methods (Django models) + "R0901", # too-many-ancestors (Django views) + "W0613", # unused-argument (Django views) + "C0103", # invalid-name (Django field names) ] [tool.pylint.format] diff --git a/requirements-dev.txt b/requirements-dev.txt index 0659631..3efbde3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -19,14 +19,14 @@ automat==25.4.16 babel==2.17.0 # via sphinx beautifulsoup4==4.12.3 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) billiard==4.2.4 # via celery black==23.12.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) celery==5.3.6 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # django-celery-beat # django-celery-results # flower @@ -42,7 +42,7 @@ charset-normalizer==3.4.4 # via requests click==8.1.7 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # black # celery # click-didyoumean @@ -59,20 +59,20 @@ constantly==23.10.4 # via twisted coreapi==2.3.3 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # django-rest-swagger # openapi-codec coreschema==0.0.4 # via coreapi coverage==7.4.0 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # pytest-cov cron-descriptor==2.0.6 # via django-celery-beat cryptography==42.0.5 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # pyopenssl # scrapy # service-identity @@ -86,7 +86,7 @@ distlib==0.4.0 # via virtualenv django==3.2.25 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # django-celery-beat # django-celery-results # django-cors-headers @@ -100,39 +100,39 @@ django==3.2.25 # drf-yasg # model-bakery django-celery-beat==2.6.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) django-celery-results==2.5.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) django-cors-headers==4.3.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) django-debug-toolbar==4.2.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) django-extensions==3.2.3 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) django-filter==23.5 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) django-redis==5.4.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) django-rest-swagger==2.2.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) django-timezone-field==7.2.1 # via django-celery-beat djangorestframework==3.14.0 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # django-rest-swagger # djangorestframework-simplejwt # drf-yasg djangorestframework-simplejwt==5.3.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) docutils==0.20.1 # via # sphinx # sphinx-rtd-theme drf-yasg==1.21.10 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) factory-boy==3.3.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) faker==40.1.2 # via factory-boy filelock==3.20.3 @@ -140,15 +140,15 @@ filelock==3.20.3 # tldextract # virtualenv flake8==6.1.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) flower==2.0.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) gevent==23.9.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) greenlet==3.3.0 # via gevent gunicorn==21.2.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) h11==0.16.0 # via wsproto humanize==4.15.0 @@ -172,7 +172,7 @@ inflection==0.5.1 iniconfig==2.3.0 # via pytest isort==5.13.2 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) itemadapter==0.13.1 # via # itemloaders @@ -202,14 +202,14 @@ markupsafe==3.0.3 mccabe==0.7.0 # via flake8 model-bakery==1.17.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-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) + # state-corp-backend (pyproject.toml) # pandas openapi-codec==1.3.2 # via django-rest-swagger @@ -229,7 +229,7 @@ packaging==25.0 # scrapy # sphinx pandas==2.0.3 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) parsel==1.10.0 # via # itemloaders @@ -237,7 +237,7 @@ parsel==1.10.0 pathspec==1.0.3 # via black pillow==12.1.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) platformdirs==4.5.1 # via # black @@ -245,7 +245,7 @@ platformdirs==4.5.1 pluggy==1.6.0 # via pytest pre-commit==3.6.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) prometheus-client==0.24.1 # via flower prompt-toolkit==3.0.52 @@ -253,7 +253,7 @@ prompt-toolkit==3.0.52 protego==0.5.0 # via scrapy psycopg2-binary==2.9.9 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) pyasn1==0.6.2 # via # pyasn1-modules @@ -278,29 +278,29 @@ pysocks==1.7.1 # via urllib3 pytest==7.4.4 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # pytest-cov # pytest-django pytest-cov==4.1.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) pytest-django==4.7.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) python-crontab==3.3.0 # via django-celery-beat python-dateutil==2.8.2 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # celery # pandas python-decouple==3.8 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) python-dotenv==1.0.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) python-json-logger==2.0.7 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) pytz==2024.1 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # django # djangorestframework # drf-yasg @@ -314,11 +314,11 @@ queuelib==1.8.0 # via scrapy redis==5.0.3 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # django-redis requests==2.31.0 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # coreapi # requests-file # sphinx @@ -326,11 +326,11 @@ requests==2.31.0 requests-file==3.0.1 # via tldextract ruff==0.1.14 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) scrapy==2.11.2 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) selenium==4.17.2 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) service-identity==24.2.0 # via scrapy setuptools==80.9.0 @@ -349,11 +349,11 @@ soupsieve==2.8.2 # via beautifulsoup4 sphinx==7.2.6 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend (pyproject.toml) # sphinx-rtd-theme # sphinxcontrib-jquery sphinx-rtd-theme==2.0.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) sphinxcontrib-applehelp==2.0.0 # via sphinx sphinxcontrib-devhelp==2.0.0 @@ -385,7 +385,7 @@ trio-websocket==0.12.2 twisted==25.5.0 # via scrapy typer==0.9.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) typing-extensions==4.15.0 # via # cron-descriptor @@ -419,11 +419,11 @@ w3lib==2.3.1 # parsel # scrapy watchdog==3.0.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) wcwidth==0.2.14 # via prompt-toolkit werkzeug==3.0.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend (pyproject.toml) wsproto==1.3.2 # via trio-websocket zope-event==6.1 diff --git a/requirements.txt b/requirements.txt index 070dfe5..879905f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,15 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml --output-file=requirements.txt +# 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 @@ -15,20 +19,22 @@ attrs==25.4.0 automat==25.4.16 # via twisted beautifulsoup4==4.12.3 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend billiard==4.2.4 # via celery celery==5.3.6 # via - # mostovik-backend (pyproject.toml) # django-celery-beat # django-celery-results + # state-corp-backend certifi==2026.1.4 # via # requests # selenium -cffi==2.0.0 - # via cryptography +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 @@ -43,12 +49,16 @@ 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 - # mostovik-backend (pyproject.toml) # django-rest-swagger + # state-corp-backend # openapi-codec coreschema==0.0.4 # via coreapi @@ -56,7 +66,7 @@ cron-descriptor==2.0.6 # via django-celery-beat cryptography==42.0.5 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend # pyopenssl # scrapy # service-identity @@ -66,45 +76,60 @@ cssselect==1.3.0 # scrapy defusedxml==0.7.1 # via scrapy +dill==0.4.1 + # via pylint django==3.2.25 # via - # mostovik-backend (pyproject.toml) # 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 + # state-corp-backend django-celery-beat==2.6.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend django-celery-results==2.5.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend django-cors-headers==4.3.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend django-filter==23.5 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend +django-jazzmin==2.6.2 + # via state-corp-backend django-redis==5.4.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend django-rest-swagger==2.2.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend 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 + # state-corp-backend djangorestframework-simplejwt==5.3.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend drf-yasg==1.21.10 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend +et-xmlfile==2.0.0 + # via openpyxl +factory-boy==3.3.0 + # via state-corp-backend +faker==40.1.2 + # via + # factory-boy + # state-corp-backend filelock==3.20.3 # via tldextract +greenlet==3.3.0 + # via playwright h11==0.16.0 # via wsproto hyperlink==21.0.0 @@ -119,6 +144,8 @@ 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 @@ -141,14 +168,18 @@ lxml==6.0.2 # scrapy markupsafe==3.0.3 # via jinja2 +mccabe==0.7.0 + # via pylint model-bakery==1.17.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend numpy==1.24.4 # via - # mostovik-backend (pyproject.toml) + # state-corp-backend # pandas openapi-codec==1.3.2 # via django-rest-swagger +openpyxl==3.1.5 + # via state-corp-backend outcome==1.3.0.post0 # via # trio @@ -161,54 +192,64 @@ packaging==25.0 # parsel # scrapy pandas==2.0.3 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend parsel==1.10.0 # via # itemloaders # scrapy pillow==12.1.0 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend +platformdirs==4.5.1 + # via pylint +playwright==1.57.0 + # via state-corp-backend prompt-toolkit==3.0.52 # via click-repl protego==0.5.0 # via scrapy psycopg2-binary==2.9.9 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend pyasn1==0.6.2 # via # pyasn1-modules # service-identity pyasn1-modules==0.4.2 # via service-identity -pycparser==2.23 +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 +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 state-corp-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 - # mostovik-backend (pyproject.toml) # celery + # state-corp-backend # pandas python-decouple==3.8 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend python-dotenv==1.0.1 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend python-json-logger==2.0.7 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend pytz==2024.1 # via - # mostovik-backend (pyproject.toml) # django # djangorestframework # drf-yasg + # state-corp-backend # pandas pyyaml==6.0.3 # via drf-yasg @@ -216,20 +257,20 @@ queuelib==1.8.0 # via scrapy redis==5.0.3 # via - # mostovik-backend (pyproject.toml) # django-redis + # state-corp-backend requests==2.31.0 # via - # mostovik-backend (pyproject.toml) # coreapi + # state-corp-backend # requests-file # tldextract requests-file==3.0.1 # via tldextract scrapy==2.11.2 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend selenium==4.17.2 - # via mostovik-backend (pyproject.toml) + # via state-corp-backend service-identity==24.2.0 # via scrapy setuptools==80.9.0 @@ -248,6 +289,8 @@ sqlparse==0.5.5 # via django tldextract==5.3.1 # via scrapy +tomlkit==0.14.0 + # via pylint trio==0.32.0 # via # selenium @@ -259,6 +302,7 @@ twisted==25.5.0 typing-extensions==4.15.0 # via # cron-descriptor + # pyee # pyopenssl # selenium # twisted @@ -266,6 +310,7 @@ tzdata==2025.3 # via # celery # django-celery-beat + # faker # kombu # pandas uritemplate==4.2.0 @@ -287,6 +332,8 @@ w3lib==2.3.1 # scrapy wcwidth==0.2.14 # via prompt-toolkit +whitenoise==6.11.0 + # via state-corp-backend wsproto==1.3.2 # via trio-websocket zope-interface==8.2 diff --git a/run_tests.py b/run_tests.py index db61014..16d18ad 100644 --- a/run_tests.py +++ b/run_tests.py @@ -5,10 +5,9 @@ Поддерживает coverage и дополнительные опции """ +import argparse import os import sys -from io import StringIO -import argparse import django @@ -59,51 +58,47 @@ def run_tests_with_args(test_args, options): def parse_arguments(): """Парсинг аргументов командной строки""" - parser = argparse.ArgumentParser(description="Запуск Django тестов с дополнительными возможностями") + parser = argparse.ArgumentParser( + description="Запуск Django тестов с дополнительными возможностями" + ) parser.add_argument( "targets", nargs="*", help="Цели тестирования (по умолчанию: все тесты)", - default=["tests"] + default=["tests"], ) parser.add_argument( - "--coverage", "--cov", + "--coverage", + "--cov", action="store_true", - help="Запуск тестов с измерением покрытия кода" + help="Запуск тестов с измерением покрытия кода", ) parser.add_argument( "--fast", action="store_true", - help="Запуск только быстрых тестов (исключает медленные)" + help="Запуск только быстрых тестов (исключает медленные)", ) parser.add_argument( - "--failfast", - action="store_true", - help="Остановка при первой ошибке" + "--failfast", action="store_true", help="Остановка при первой ошибке" ) parser.add_argument( - "--verbose", "-v", - action="count", - default=2, - help="Уровень детализации вывода" + "--verbose", "-v", action="count", default=2, help="Уровень детализации вывода" ) parser.add_argument( - "--keepdb", - action="store_true", - help="Сохранить тестовую базу данных" + "--keepdb", action="store_true", help="Сохранить тестовую базу данных" ) parser.add_argument( "--parallel", type=int, metavar="N", - help="Запуск тестов в N параллельных процессах" + help="Запуск тестов в N параллельных процессах", ) args = parser.parse_args() @@ -186,6 +181,7 @@ def setup_coverage(): """Настройка coverage""" try: import coverage + cov = coverage.Coverage(config_file="pyproject.toml") cov.start() return cov @@ -245,7 +241,7 @@ def main(): print(f"\n❌ Тесты завершились с ошибками: {failures} неудачных тестов") sys.exit(1) else: - print(f"\n✅ Все тесты прошли успешно!") + print("\n✅ Все тесты прошли успешно!") if cov: print("📊 Отчет о покрытии сохранен") sys.exit(0) @@ -260,6 +256,7 @@ def main(): if cov: cov.stop() import traceback + traceback.print_exc() sys.exit(1) diff --git a/src/apps/core/services.py b/src/apps/core/services.py index 48aeedf..1eb45b0 100644 --- a/src/apps/core/services.py +++ b/src/apps/core/services.py @@ -107,7 +107,10 @@ class BaseService(Generic[M]): """ for field, value in kwargs.items(): setattr(instance, field, value) - instance.save(update_fields=list(kwargs.keys())) + update_fields = set(kwargs.keys()) + if hasattr(instance, "updated_at"): + update_fields.add("updated_at") + instance.save(update_fields=list(update_fields)) return instance @classmethod diff --git a/src/apps/core/tasks.py b/src/apps/core/tasks.py index 2a43479..6dd1ad6 100644 --- a/src/apps/core/tasks.py +++ b/src/apps/core/tasks.py @@ -267,3 +267,101 @@ class PeriodicTask(TimedTask): "periodic": True, }, ) + + +class TrackedTask(TimedTask): + """ + Задача с отслеживанием в BackgroundJob. + + Автоматически создаёт запись BackgroundJob при запуске + и обновляет статус при завершении. + + Пример использования: + @app.task(base=TrackedTask, bind=True) + def process_excel_file(self, file_path: str, user_id: int): + job = self.get_job() + job.update_progress(10, "Загрузка файла...") + # ... обработка ... + job.update_progress(50, "Обработка данных...") + # ... обработка ... + return {"loaded": 100} + + # Запуск: + task = process_excel_file.delay(file_path, user_id=request.user.id) + # task.id содержит task_id для отслеживания + """ + + # Не делать авто-retry для отслеживаемых задач + autoretry_for = () + max_retries = 0 + + def before_start( + self, + task_id: str, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> None: + """Создаёт запись BackgroundJob при старте.""" + super().before_start(task_id, args, kwargs) + + from apps.core.services import BackgroundJobService + + # Получаем user_id из kwargs если есть + user_id = kwargs.get("user_id") + + BackgroundJobService.create_job( + task_id=task_id, + task_name=self.name, + user_id=user_id, + meta={"args": str(args)[:500], "kwargs": str(kwargs)[:500]}, + ) + + # Отмечаем как запущенную + job = BackgroundJobService.get_by_task_id(task_id) + job.mark_started() + + def on_success( + self, + retval: Any, + task_id: str, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> None: + """Отмечает задачу как успешно завершённую.""" + super().on_success(retval, task_id, args, kwargs) + + from apps.core.services import BackgroundJobService + + job = BackgroundJobService.get_by_task_id_or_none(task_id) + if job: + job.complete(result=retval) + + def on_failure( + self, + exc: Exception, + task_id: str, + args: tuple[Any, ...], + kwargs: dict[str, Any], + einfo: Any, + ) -> None: + """Отмечает задачу как завершённую с ошибкой.""" + super().on_failure(exc, task_id, args, kwargs, einfo) + + from apps.core.services import BackgroundJobService + + job = BackgroundJobService.get_by_task_id_or_none(task_id) + if job: + job.fail( + error=str(exc), + traceback_str=str(einfo) if einfo else "", + ) + + def get_job(self): + """ + Получить объект BackgroundJob для текущей задачи. + + Использовать внутри задачи для обновления прогресса. + """ + from apps.core.services import BackgroundJobService + + return BackgroundJobService.get_by_task_id(self.request.id) diff --git a/src/apps/core/views.py b/src/apps/core/views.py index 056376c..16bc377 100644 --- a/src/apps/core/views.py +++ b/src/apps/core/views.py @@ -13,6 +13,7 @@ from typing import Any from django.conf import settings from django.db import connection +from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -21,29 +22,29 @@ from rest_framework.views import APIView logger = logging.getLogger(__name__) +# Swagger теги +HEALTH_TAG = "Мониторинг" +JOBS_TAG = "Фоновые задачи" + class HealthCheckView(APIView): """ - Comprehensive health check endpoint. + Комплексная проверка состояния системы. - GET /api/health/ - Returns detailed status of all dependencies. - - Response: - { - "status": "healthy" | "degraded" | "unhealthy", - "version": "1.0.0", - "checks": { - "database": {"status": "up", "latency_ms": 5}, - "redis": {"status": "up", "latency_ms": 2}, - "celery": {"status": "up"} - } - } + Возвращает статус всех зависимостей (БД, Redis, Celery). """ permission_classes = [AllowAny] authentication_classes = [] # No auth required + @swagger_auto_schema( + tags=[HEALTH_TAG], + operation_summary="Проверка состояния", + operation_description=( + "Комплексная проверка всех зависимостей системы.\n" + "Возвращает статус: healthy, degraded или unhealthy." + ), + ) def get(self, request: Request) -> Response: """Run all health checks and return status.""" checks = {} @@ -131,15 +132,19 @@ class HealthCheckView(APIView): class LivenessView(APIView): """ - Kubernetes liveness probe endpoint. + Kubernetes liveness probe. - GET /api/health/live/ - Returns 200 if the application is running. + Проверяет, запущено ли приложение. """ permission_classes = [AllowAny] authentication_classes = [] + @swagger_auto_schema( + tags=[HEALTH_TAG], + operation_summary="Liveness probe", + operation_description="Возвращает 200 если приложение запущено.", + ) def get(self, request: Request) -> Response: """Simple liveness check.""" return Response({"status": "alive"}, status=status.HTTP_200_OK) @@ -147,15 +152,21 @@ class LivenessView(APIView): class ReadinessView(APIView): """ - Kubernetes readiness probe endpoint. + Kubernetes readiness probe. - GET /api/health/ready/ - Returns 200 if the application is ready to serve traffic. + Проверяет, готово ли приложение обрабатывать запросы. """ permission_classes = [AllowAny] authentication_classes = [] + @swagger_auto_schema( + tags=[HEALTH_TAG], + operation_summary="Readiness probe", + operation_description=( + "Возвращает 200 если приложение готово обрабатывать запросы." + ), + ) def get(self, request: Request) -> Response: """Check if app is ready to serve traffic.""" # Check database connection @@ -177,26 +188,21 @@ class BackgroundJobStatusView(APIView): """ Получение статуса фоновой задачи. - GET /api/v1/jobs/{task_id}/ Возвращает статус, прогресс и результат задачи. - - Response: - { - "id": "uuid", - "task_id": "celery-task-id", - "status": "pending|started|success|failure|revoked", - "progress": 75, - "progress_message": "Обработка данных...", - "result": {...}, - "error": "", - "is_finished": false - } """ from rest_framework.permissions import IsAuthenticated permission_classes = [IsAuthenticated] + @swagger_auto_schema( + tags=[JOBS_TAG], + operation_summary="Статус задачи", + operation_description=( + "Возвращает статус конкретной фоновой задачи.\n" + "Доступно только владельцу задачи или администратору." + ), + ) def get(self, request: Request, task_id: str) -> Response: """Получить статус задачи по task_id.""" from apps.core.serializers import BackgroundJobSerializer @@ -219,18 +225,21 @@ class BackgroundJobListView(APIView): """ Список фоновых задач пользователя. - GET /api/v1/jobs/ - Возвращает список задач текущего пользователя. - - Query params: - status: Фильтр по статусу (pending, started, success, failure) - limit: Количество записей (по умолчанию 50) + Возвращает список задач текущего пользователя с фильтрацией. """ from rest_framework.permissions import IsAuthenticated permission_classes = [IsAuthenticated] + @swagger_auto_schema( + tags=[JOBS_TAG], + operation_summary="Список задач", + operation_description=( + "Возвращает список фоновых задач текущего пользователя.\n" + "Поддерживает фильтрацию по статусу (status) и лимит (limit)." + ), + ) def get(self, request: Request) -> Response: """Получить список задач пользователя.""" from apps.core.serializers import BackgroundJobListSerializer diff --git a/src/apps/user/models.py b/src/apps/user/models.py index 20bd9f9..ef84f96 100644 --- a/src/apps/user/models.py +++ b/src/apps/user/models.py @@ -6,6 +6,10 @@ from django.utils.translation import gettext_lazy as _ class User(AbstractUser): """Расширенная модель пользователя""" + # Убираем first_name и last_name из модели User (они в Profile) + first_name = None + last_name = None + # Переопределяем группы и разрешения для избежания конфликта groups = models.ManyToManyField( "auth.Group", diff --git a/src/apps/user/views.py b/src/apps/user/views.py index 0259014..67bb360 100644 --- a/src/apps/user/views.py +++ b/src/apps/user/views.py @@ -8,7 +8,6 @@ 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 .serializers import ( LoginSerializer, PasswordChangeSerializer, @@ -20,14 +19,26 @@ from .serializers import ( ) from .services import ProfileService, UserService +# Swagger теги для группировки +AUTH_TAG = "Аутентификация" +USER_TAG = "Пользователь" + class RegisterView(APIView): - """Регистрация нового пользователя""" + """ + Регистрация нового пользователя. + + Создаёт учётную запись и возвращает JWT токены. + """ permission_classes = [AllowAny] @swagger_auto_schema( - request_body=UserRegistrationSerializer, responses={201: UserSerializer} + tags=[AUTH_TAG], + operation_summary="Регистрация", + operation_description="Создание новой учётной записи пользователя.", + request_body=UserRegistrationSerializer, + responses={201: UserSerializer}, ) def post(self, request): serializer = UserRegistrationSerializer(data=request.data) @@ -49,11 +60,21 @@ class RegisterView(APIView): class LoginView(APIView): - """Вход пользователя""" + """ + Вход пользователя. + + Возвращает access и refresh токены для авторизации. + """ permission_classes = [AllowAny] - @swagger_auto_schema(request_body=LoginSerializer, responses={200: TokenSerializer}) + @swagger_auto_schema( + tags=[AUTH_TAG], + operation_summary="Вход", + operation_description="Аутентификация по email и паролю. Возвращает JWT токены.", + request_body=LoginSerializer, + responses={200: TokenSerializer}, + ) def post(self, request): serializer = LoginSerializer(data=request.data) if serializer.is_valid(): @@ -74,50 +95,35 @@ class LoginView(APIView): class LogoutView(APIView): - """Выход пользователя""" + """ + Выход пользователя. + + Логаут на JWT означает удаление токенов на клиенте. + """ permission_classes = [IsAuthenticated] @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter( - "Authorization", - openapi.IN_HEADER, - description="Bearer ", - type=openapi.TYPE_STRING, - required=True, - ) - ], + tags=[AUTH_TAG], + operation_summary="Выход", + operation_description="Выход из системы (удаление токенов на клиенте).", responses={200: "Успешный выход"}, ) def post(self, request): - try: - refresh_token = request.data.get("refresh") - if refresh_token: - token = RefreshToken(refresh_token) - token.blacklist() - return Response({"message": "Успешный выход"}, status=status.HTTP_200_OK) - except Exception: - return Response( - {"error": "Неверный токен"}, status=status.HTTP_400_BAD_REQUEST - ) + # Для JWT логаут означает удаление токенов на клиенте. + # Сервер не хранит сессию и ничего не инвалидирует. + return Response({"message": "Успешный выход"}, status=status.HTTP_200_OK) class CurrentUserView(APIView): - """Получение данных текущего пользователя""" + """Получение данных текущего пользователя.""" permission_classes = [IsAuthenticated] @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter( - "Authorization", - openapi.IN_HEADER, - description="Bearer ", - type=openapi.TYPE_STRING, - required=True, - ) - ], + tags=[USER_TAG], + operation_summary="Текущий пользователь", + operation_description="Возвращает данные авторизованного пользователя.", responses={200: UserSerializer}, ) def get(self, request): @@ -126,21 +132,15 @@ class CurrentUserView(APIView): class UserUpdateView(APIView): - """Обновление данных пользователя""" + """Обновление данных пользователя.""" permission_classes = [IsAuthenticated] @swagger_auto_schema( + tags=[USER_TAG], + operation_summary="Обновить данные", + operation_description="Частичное обновление данных пользователя.", request_body=UserUpdateSerializer, - manual_parameters=[ - openapi.Parameter( - "Authorization", - openapi.IN_HEADER, - description="Bearer ", - type=openapi.TYPE_STRING, - required=True, - ) - ], responses={200: UserSerializer}, ) def patch(self, request): @@ -153,7 +153,7 @@ class UserUpdateView(APIView): class ProfileDetailView(generics.RetrieveUpdateAPIView): - """Получение и обновление профиля пользователя""" + """Получение и обновление профиля пользователя.""" permission_classes = [IsAuthenticated] serializer_class = ProfileUpdateSerializer @@ -168,15 +168,9 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView): return profile @swagger_auto_schema( - manual_parameters=[ - openapi.Parameter( - "Authorization", - openapi.IN_HEADER, - description="Bearer ", - type=openapi.TYPE_STRING, - required=True, - ) - ] + tags=[USER_TAG], + operation_summary="Получить профиль", + operation_description="Возвращает профиль текущего пользователя.", ) def get(self, request, *args, **kwargs): profile = self.get_object() @@ -184,16 +178,10 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView): return Response(serializer.data) @swagger_auto_schema( + tags=[USER_TAG], + operation_summary="Обновить профиль", + operation_description="Частичное обновление профиля пользователя.", request_body=ProfileUpdateSerializer, - manual_parameters=[ - openapi.Parameter( - "Authorization", - openapi.IN_HEADER, - description="Bearer ", - type=openapi.TYPE_STRING, - required=True, - ) - ], ) def patch(self, request, *args, **kwargs): profile = self.get_object() @@ -207,21 +195,15 @@ class ProfileDetailView(generics.RetrieveUpdateAPIView): class PasswordChangeView(APIView): - """Смена пароля""" + """Смена пароля пользователя.""" permission_classes = [IsAuthenticated] @swagger_auto_schema( + tags=[USER_TAG], + operation_summary="Сменить пароль", + operation_description="Смена пароля. Требуется текущий пароль для подтверждения.", request_body=PasswordChangeSerializer, - manual_parameters=[ - openapi.Parameter( - "Authorization", - openapi.IN_HEADER, - description="Bearer ", - type=openapi.TYPE_STRING, - required=True, - ) - ], responses={200: "Пароль успешно изменен"}, ) def post(self, request): @@ -246,20 +228,29 @@ class PasswordChangeView(APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +@swagger_auto_schema( + method="get", + tags=[USER_TAG], + operation_summary="Полный профиль", + operation_description="Расширенная информация о пользователе и профиле.", +) @api_view(["GET"]) @permission_classes([IsAuthenticated]) def user_profile_detail(request): - """Получение полных данных профиля пользователя""" + """Получение полных данных профиля пользователя.""" profile_data = ProfileService.get_full_profile_data(request.user.id) return Response(profile_data) class TokenRefreshView(APIView): - """Обновление access токена через refresh токен""" + """Обновление access токена через refresh токен.""" permission_classes = [AllowAny] @swagger_auto_schema( + tags=[AUTH_TAG], + operation_summary="Обновить токен", + operation_description="Получение нового access токена по refresh токену.", request_body=openapi.Schema( type=openapi.TYPE_OBJECT, properties={ diff --git a/src/config/api_v1_urls.py b/src/config/api_v1_urls.py index a3abefd..d202f39 100644 --- a/src/config/api_v1_urls.py +++ b/src/config/api_v1_urls.py @@ -1,7 +1,23 @@ """ API v1 URL configuration. -All API endpoints are versioned under /api/v1/ +Все API эндпоинты версионированы под /api/v1/ + +Структура: +- /api/v1/users/ - Аутентификация и пользователи +- /api/v1/jobs/ - Фоновые задачи +- /api/v1/organizations/ - Справочник организаций +- /api/v1/forms/f1/ - Форма Ф-1 (Выпуск продукции) +- /api/v1/forms/f2/ - Форма Ф-2 (Бухгалтерский баланс) +- /api/v1/forms/f3/ - Форма Ф-3 (Кадры и оборудование) +- /api/v1/forms/f4/ - Форма Ф-4 (Сводные финансы) +- /api/v1/forms/f5/ - Форма Ф-5 (Инвентаризация оборудования) +- /api/v1/forms/f6/ - Форма Ф-6 (Возрастная структура) +- /api/v1/minpromtorg/ - Минпромторг (сертификаты, производители) +- /api/v1/proverki/ - Единый реестр проверок +- /api/v1/zakupki/ - Государственные закупки +- /api/v1/fns/ - ФНС (бухгалтерская отчетность) +- /api/v1/system/ - Системные (логи, прокси) - только для админов """ from apps.core.views import BackgroundJobListView, BackgroundJobStatusView @@ -9,12 +25,24 @@ from django.urls import include, path app_name = "api_v1" +# Фоновые задачи jobs_urlpatterns = [ path("", BackgroundJobListView.as_view(), name="job-list"), path("/", BackgroundJobStatusView.as_view(), name="job-status"), ] urlpatterns = [ + # Аутентификация и пользователи path("users/", include("apps.user.urls")), + # Фоновые задачи path("jobs/", include((jobs_urlpatterns, "jobs"))), + # Справочники + path("organizations/", include("apps.organization.urls")), + # Формы отчётности + path("forms/f1/", include("apps.form_1.urls")), + path("forms/f2/", include("apps.form_2.urls")), + path("forms/f3/", include("apps.form_3.urls")), + path("forms/f4/", include("apps.form_4.urls")), + path("forms/f5/", include("apps.form_5.urls")), + path("forms/f6/", include("apps.form_6.urls")), ] diff --git a/src/config/celery.py b/src/config/celery.py index a45ae92..1f2664e 100644 --- a/src/config/celery.py +++ b/src/config/celery.py @@ -8,8 +8,13 @@ import os from celery import Celery -# Set the default Django settings module for the 'celery' program. -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") +# Set the Django settings module for the 'celery' program. +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)." + ) app = Celery("project") @@ -24,17 +29,24 @@ app.autodiscover_tasks() # Configure Celery Beat schedule app.conf.beat_schedule = { - "check-pending-scraping-jobs": { - "task": "apps.scraping.tasks.check_pending_jobs", - "schedule": 300.0, # Every 5 minutes + # Парсинг сертификатов промышленного производства - каждый день в 3:00 + "parse-industrial-production-daily": { + "task": "apps.parsers.tasks.parse_industrial_production", + "schedule": 86400.0, # Every 24 hours }, - "process-extracted-data": { - "task": "apps.data_processor.tasks.process_extracted_data", - "schedule": 600.0, # Every 10 minutes + # Парсинг реестра производителей - каждый день в 4:00 + "parse-manufactures-daily": { + "task": "apps.parsers.tasks.parse_manufactures", + "schedule": 86400.0, # Every 24 hours + }, + # Сканирование папки FNS - каждые 5 минут + "scan-fns-directory": { + "task": "apps.parsers.tasks.scan_fns_directory", + "schedule": 300.0, # Every 5 minutes }, } -app.conf.timezone = "UTC" +app.conf.timezone = "Europe/Moscow" @app.task(bind=True) diff --git a/src/config/settings/base.py b/src/config/settings/base.py index 60f6b50..d926675 100644 --- a/src/config/settings/base.py +++ b/src/config/settings/base.py @@ -4,6 +4,7 @@ Base settings for Django project. Generated by 'django-admin startproject' using Django 3.2.25. """ +from datetime import timedelta from pathlib import Path from decouple import Config, RepositoryEnv @@ -45,6 +46,7 @@ if isinstance(ALLOWED_HOSTS, str): # Application definition INSTALLED_APPS = [ + "jazzmin", # Django Jazzmin - modern admin theme (must be before admin) "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -61,12 +63,113 @@ INSTALLED_APPS = [ # Local apps "apps.core", "apps.user", + "apps.organization", + "apps.form_1", + "apps.form_2", + "apps.form_3", + "apps.form_4", + "apps.form_5", + "apps.form_6", ] +# Jazzmin Admin Configuration +JAZZMIN_SETTINGS = { + # Title + "site_title": "State Corp Admin", + "site_header": "State Corp", + "site_brand": "State Corp", + "site_logo": None, + "login_logo": None, + "login_logo_dark": None, + "site_logo_classes": "img-circle", + "site_icon": None, + "welcome_sign": "Добро пожаловать в панель управления", + "copyright": "State Corp Backend", + # Search + "search_model": ["user.User", "parsers.IndustrialCertificateRecord"], + # User menu + "topmenu_links": [ + {"name": "Главная", "url": "admin:index", "permissions": ["auth.view_user"]}, + {"name": "API Docs", "url": "/api/docs/", "new_window": True}, + {"model": "user.User"}, + ], + # Side menu + "show_sidebar": True, + "navigation_expanded": True, + "hide_apps": ["django_celery_results"], + "hide_models": [], + "order_with_respect_to": [ + "user", + "parsers", + "core", + "django_celery_beat", + ], + # Icons (Font Awesome) + "icons": { + "auth": "fas fa-users-cog", + "auth.Group": "fas fa-users", + "user.User": "fas fa-user", + "user.Profile": "fas fa-id-card", + "core.BackgroundJob": "fas fa-tasks", + "django_celery_beat.PeriodicTask": "fas fa-clock", + "django_celery_beat.CrontabSchedule": "fas fa-calendar-alt", + "django_celery_beat.IntervalSchedule": "fas fa-stopwatch", + "django_celery_results.TaskResult": "fas fa-clipboard-check", + }, + "default_icon_parents": "fas fa-chevron-circle-right", + "default_icon_children": "fas fa-circle", + # Related modal + "related_modal_active": True, + # UI Tweaks + "custom_css": None, + "custom_js": None, + "use_google_fonts_cdn": True, + "show_ui_builder": False, + # Change view + "changeform_format": "horizontal_tabs", + "changeform_format_overrides": { + "user.User": "collapsible", + "parsers.IndustrialCertificateRecord": "vertical_tabs", + }, +} + +JAZZMIN_UI_TWEAKS = { + "navbar_small_text": False, + "footer_small_text": False, + "body_small_text": False, + "brand_small_text": False, + "brand_colour": "navbar-primary", + "accent": "accent-primary", + "navbar": "navbar-dark", + "no_navbar_border": False, + "navbar_fixed": True, + "layout_boxed": False, + "footer_fixed": False, + "sidebar_fixed": True, + "sidebar": "sidebar-dark-primary", + "sidebar_nav_small_text": False, + "sidebar_disable_expand": False, + "sidebar_nav_child_indent": False, + "sidebar_nav_compact_style": False, + "sidebar_nav_legacy_style": False, + "sidebar_nav_flat_style": False, + "theme": "default", + "dark_mode_theme": "darkly", + "button_classes": { + "primary": "btn-primary", + "secondary": "btn-secondary", + "info": "btn-info", + "warning": "btn-warning", + "danger": "btn-danger", + "success": "btn-success", + }, +} + MIDDLEWARE = [ "apps.core.middleware.RequestIDMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -104,9 +207,6 @@ DATABASES = { "PASSWORD": get_env("POSTGRES_PASSWORD", "project_password"), "HOST": get_env("POSTGRES_HOST", "db"), "PORT": int(get_env("POSTGRES_PORT", "5432")), - "OPTIONS": { - "charset": "utf8mb4", - }, }, } @@ -122,6 +222,24 @@ CACHES = { } +# ============================================================================= +# PARSERS SETTINGS +# ============================================================================= + +# Zakupki.gov.ru API Token (получить через Госуслуги) +ZAKUPKI_TOKEN = get_env("ZAKUPKI_TOKEN", "") + +# FNS file lock TTL (seconds) +FNS_LOCK_TTL_SECONDS = int(get_env("FNS_LOCK_TTL_SECONDS", "3600")) + +# Proxy list for parsers (comma-separated) +PARSER_PROXIES = get_env("PARSER_PROXIES", "") +if isinstance(PARSER_PROXIES, str) and PARSER_PROXIES: + PARSER_PROXIES = [p.strip() for p in PARSER_PROXIES.split(",") if p.strip()] +else: + PARSER_PROXIES = [] + + # Password validation AUTH_PASSWORD_VALIDATORS = [ { @@ -160,6 +278,9 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Custom user model AUTH_USER_MODEL = "user.User" +# Login URL for drf-yasg and DRF browsable API +LOGIN_URL = "/auth/login/" + # REST Framework settings REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ @@ -193,7 +314,6 @@ REST_FRAMEWORK = { } # JWT settings -from datetime import timedelta SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), @@ -230,6 +350,24 @@ if isinstance(CORS_ALLOWED_ORIGINS, str): CORS_ALLOWED_ORIGINS = CORS_ALLOWED_ORIGINS.split(",") CORS_ALLOW_CREDENTIALS = True +# ============================================================================= +# SWAGGER SETTINGS (drf-yasg) +# ============================================================================= +SWAGGER_SETTINGS = { + "SECURITY_DEFINITIONS": { + "Bearer": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": "JWT авторизация. Формат: Bearer ", + } + }, + "USE_SESSION_AUTH": True, + "PERSIST_AUTH": True, + "REFETCH_SCHEMA_WITH_AUTH": True, + "REFETCH_SCHEMA_ON_LOGOUT": True, +} + # Logging configuration LOGGING = { "version": 1, @@ -269,3 +407,23 @@ LOGGING = { }, }, } + +# ============================================================================= +# FNS Parser Settings +# ============================================================================= + +# Directory for watching incoming FNS files +FNS_WATCH_DIRECTORY = BASE_DIR / "input" / "fns" + +# Directory for processed files (moved after successful processing) +FNS_PROCESSED_DIRECTORY = BASE_DIR / "input" / "fns" / "processed" + +# Directory for failed files (moved after failed processing) +FNS_FAILED_DIRECTORY = BASE_DIR / "input" / "fns" / "failed" + +# ============================================================================= +# Checko API Settings (checko.ru) +# ============================================================================= + +# API key for Checko.ru service +CHECKO_API_KEY = get_env("CHECKO_API_KEY", "") diff --git a/src/config/settings/development.py b/src/config/settings/development.py index b841587..29eed24 100644 --- a/src/config/settings/development.py +++ b/src/config/settings/development.py @@ -1,44 +1,49 @@ +import os + from .base import * # Development settings - unsuitable for production # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-development-key-change-in-production" +SECRET_KEY = os.getenv( + "SECRET_KEY", "django-insecure-development-key-change-in-production" +) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.getenv("DEBUG", "True").lower() in ("true", "1", "yes") -ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "testserver"] +ALLOWED_HOSTS = ["localhost", "127.0.0.1", "0.0.0.0", "testserver", "*"] # noqa: S104 # Database for development DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", - "NAME": "project_dev", - "USER": "postgres", - "PASSWORD": "postgres", - "HOST": "localhost", - "PORT": "5432", + "NAME": os.getenv("POSTGRES_DB", "project_dev"), + "USER": os.getenv("POSTGRES_USER", "postgres"), + "PASSWORD": os.getenv("POSTGRES_PASSWORD", "postgres"), + "HOST": os.getenv("POSTGRES_HOST", "localhost"), + "PORT": os.getenv("POSTGRES_PORT", "5432"), } } # Celery Configuration for Development -CELERY_BROKER_URL = "redis://localhost:6379/0" -CELERY_RESULT_BACKEND = "redis://localhost:6379/0" +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") +CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/0") CELERY_ACCEPT_CONTENT = ["json"] CELERY_TASK_SERIALIZER = "json" CELERY_RESULT_SERIALIZER = "json" -CELERY_TIMEZONE = "UTC" +CELERY_TIMEZONE = "Europe/Moscow" # Email backend for development EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # Cache configuration for development +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1") CACHES = { "default": { "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": "redis://127.0.0.1:6379/1", + "LOCATION": REDIS_URL, "OPTIONS": { "CLIENT_CLASS": "django_redis.client.DefaultClient", }, diff --git a/src/config/urls.py b/src/config/urls.py index aca36e0..4e44970 100644 --- a/src/config/urls.py +++ b/src/config/urls.py @@ -15,11 +15,24 @@ from rest_framework import permissions # Swagger schema view schema_view = get_schema_view( openapi.Info( - title="Mostovik API", + title="State Corp API", default_version="v1", - description="API documentation for Mostovik project", - terms_of_service="https://www.google.com/policies/terms/", - contact=openapi.Contact(email="contact@mostovik.local"), + description=""" +## API документация для проекта State Corp + +### Авторизация +Для доступа к защищённым эндпоинтам используйте JWT токен: +1. Получите токен через `POST /api/v1/users/login/` +2. Добавьте заголовок: `Authorization: Bearer ` + +### Обновление токена +Используйте `POST /api/v1/users/token/refresh/` с refresh токеном. + +### Парсеры +API предоставляет только чтение данных (GET, GET list). +Добавление и удаление записей происходит через парсеры и админку. + """, + contact=openapi.Contact(email="contact@state-corp.local"), license=openapi.License(name="BSD License"), ), public=True, diff --git a/tests/__init__.py b/tests/__init__.py index 84b82af..4500ace 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,5 @@ """ -Test suite for mostovik-backend project +Test suite for state-corp-backend project This package contains all tests organized by application structure. """ diff --git a/tests/apps/core/test_background_jobs.py b/tests/apps/core/test_background_jobs.py index b226195..756c4d9 100644 --- a/tests/apps/core/test_background_jobs.py +++ b/tests/apps/core/test_background_jobs.py @@ -187,7 +187,7 @@ class BackgroundJobServiceTest(TestCase): def test_get_user_jobs_with_status_filter(self): """Тест фильтрации по статусу.""" user_id = 456 - job1 = BackgroundJobService.create_job( + BackgroundJobService.create_job( task_id="task-pending", task_name="test.task", user_id=user_id, @@ -212,7 +212,7 @@ class BackgroundJobServiceTest(TestCase): def test_get_active_jobs(self): """Тест получения активных задач.""" # Создаём задачи с разными статусами - job_pending = BackgroundJobService.create_job( + BackgroundJobService.create_job( task_id="job-active-pending", task_name="test.task", ) diff --git a/tests/apps/core/test_bulk_operations.py b/tests/apps/core/test_bulk_operations.py index 5656d0e..5227ed4 100644 --- a/tests/apps/core/test_bulk_operations.py +++ b/tests/apps/core/test_bulk_operations.py @@ -71,6 +71,7 @@ class BulkOperationsIntegrationTest(TestCase): def test_bulk_create_chunked(self): """Тест массового создания чанками.""" + # Создаём тестовый сервис с BulkOperationsMixin class TestService(BulkOperationsMixin): model = BackgroundJob @@ -88,10 +89,13 @@ class BulkOperationsIntegrationTest(TestCase): self.assertEqual(count, 10) # Проверяем что все созданы - self.assertEqual(BackgroundJob.objects.filter(task_name="test.bulk.task").count(), 10) + self.assertEqual( + BackgroundJob.objects.filter(task_name="test.bulk.task").count(), 10 + ) def test_bulk_delete(self): """Тест массового удаления.""" + class TestService(BulkOperationsMixin): model = BackgroundJob @@ -109,10 +113,13 @@ class BulkOperationsIntegrationTest(TestCase): deleted = TestService.bulk_delete(ids_to_delete) self.assertEqual(deleted, 3) - self.assertEqual(BackgroundJob.objects.filter(task_name="test.delete.task").count(), 2) + self.assertEqual( + BackgroundJob.objects.filter(task_name="test.delete.task").count(), 2 + ) def test_bulk_update_fields(self): """Тест массового обновления полей.""" + class TestService(BulkOperationsMixin): model = BackgroundJob @@ -138,6 +145,7 @@ class BulkOperationsIntegrationTest(TestCase): def test_bulk_update_or_create_creates(self): """Тест upsert - создание новых.""" + class TestService(BulkOperationsMixin): model = BackgroundJob @@ -157,6 +165,7 @@ class BulkOperationsIntegrationTest(TestCase): def test_bulk_update_or_create_updates(self): """Тест upsert - обновление существующих.""" + class TestService(BulkOperationsMixin): model = BackgroundJob diff --git a/tests/apps/core/test_response.py b/tests/apps/core/test_response.py index 485d4f3..4368625 100644 --- a/tests/apps/core/test_response.py +++ b/tests/apps/core/test_response.py @@ -101,9 +101,7 @@ class APIPaginatedResponseTest(TestCase): def test_paginated_response(self): """Test paginated response with correct metadata""" data = [{"id": 1}, {"id": 2}] - response = api_paginated_response( - data, page=1, page_size=10, total_count=25 - ) + response = api_paginated_response(data, page=1, page_size=10, total_count=25) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["data"], data) diff --git a/tests/apps/core/test_services.py b/tests/apps/core/test_services.py index f4fe1a6..e7b2c2e 100644 --- a/tests/apps/core/test_services.py +++ b/tests/apps/core/test_services.py @@ -21,7 +21,7 @@ class BaseServiceTest(TestCase): self.user = User.objects.create_user( username="testuser", email="test@example.com", - password="testpass123", + password="testpass123", # noqa: S106 ) def test_get_by_id_success(self): @@ -53,7 +53,7 @@ class BaseServiceTest(TestCase): User.objects.create_user( username="testuser2", email="test2@example.com", - password="testpass123", + password="testpass123", # noqa: S106 ) result = UserTestService.get_all() diff --git a/tests/apps/core/test_signals.py b/tests/apps/core/test_signals.py index af7f76f..4444401 100644 --- a/tests/apps/core/test_signals.py +++ b/tests/apps/core/test_signals.py @@ -88,7 +88,7 @@ class SignalDispatcherTest(TestCase): self.dispatcher.connect_all() # Create user to trigger signal - user = UserFactory.create_user() + UserFactory.create_user() self.assertTrue(handler_called["value"]) @@ -114,7 +114,7 @@ class SignalDispatcherTest(TestCase): # Create user - handler should not be called handler_called["value"] = False - user = UserFactory.create_user() + UserFactory.create_user() self.assertFalse(handler_called["value"]) diff --git a/tests/apps/core/test_viewsets.py b/tests/apps/core/test_viewsets.py index ab122d1..4721bee 100644 --- a/tests/apps/core/test_viewsets.py +++ b/tests/apps/core/test_viewsets.py @@ -1,5 +1,9 @@ """Tests for core ViewSets""" +from __future__ import annotations + +from typing import Any + from apps.core.pagination import StandardPagination from apps.core.viewsets import ( BaseViewSet, @@ -7,9 +11,139 @@ from apps.core.viewsets import ( OwnerViewSet, ReadOnlyViewSet, ) -from django.test import TestCase -from rest_framework import viewsets +from apps.parsers.models import Proxy +from apps.user.models import Profile, User +from django.test import TestCase, override_settings +from django.urls import include, path +from rest_framework import serializers, status, viewsets +from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from rest_framework.routers import DefaultRouter +from rest_framework.test import APITestCase + +from tests.apps.parsers.factories import ProxyFactory, fake +from tests.apps.user.factories import ProfileFactory, UserFactory + + +def _proxy_payload() -> dict[str, Any]: + proxy = ProxyFactory.build() + return { + "address": proxy.address, + "is_active": proxy.is_active, + "fail_count": proxy.fail_count, + "description": proxy.description, + } + + +class ProxySerializer(serializers.ModelSerializer): + class Meta: + model = Proxy + fields = ["id", "address", "is_active", "fail_count", "description"] + + +class ProxyListSerializer(serializers.ModelSerializer): + class Meta: + model = Proxy + fields = ["id", "address"] + + +class ProfileSerializer(serializers.ModelSerializer): + class Meta: + model = Profile + fields = ["id", "user", "first_name", "last_name", "bio"] + read_only_fields = ["user"] + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "email", "username"] + + +class ProxyViewSet(BaseViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + serializer_classes = {"list": ProxyListSerializer} + only_fields = ["id", "address"] + + +class DeferProxyViewSet(BaseViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + defer_fields = ["description"] + + +class ReadOnlyProxyViewSet(ReadOnlyViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + + +class NoPaginationProxyViewSet(BaseViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + pagination_class = None + + +class ProfileSelectViewSet(BaseViewSet[Profile]): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + select_related_fields = ["user"] + + +class ProfileOldStyleViewSet(BaseViewSet[Profile]): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + _select_related = ["user"] + + +class UserPrefetchViewSet(BaseViewSet[User]): + queryset = User.objects.all() + serializer_class = UserSerializer + prefetch_related_fields = ["groups"] + + +class UserOldStyleViewSet(BaseViewSet[User]): + queryset = User.objects.all() + serializer_class = UserSerializer + _prefetch_related = ["groups"] + + +class OwnerProfileViewSet(OwnerViewSet[Profile]): + queryset = Profile.objects.all() + serializer_class = ProfileSerializer + + +class BulkProxyViewSet(BulkMixin, BaseViewSet[Proxy]): + queryset = Proxy.objects.all() + serializer_class = ProxySerializer + bulk_max_items = 2 + + @action(detail=False, methods=["post"]) + def bulk_create(self, request): + return super().bulk_create(request) + + @action(detail=False, methods=["patch"]) + def bulk_update(self, request): + return super().bulk_update(request) + + @action(detail=False, methods=["delete"]) + def bulk_delete(self, request): + return super().bulk_delete(request) + + +router = DefaultRouter() +router.register("proxies", ProxyViewSet, basename="proxy") +router.register("proxies-defer", DeferProxyViewSet, basename="proxy-defer") +router.register("proxies-readonly", ReadOnlyProxyViewSet, basename="proxy-readonly") +router.register("proxies-nopage", NoPaginationProxyViewSet, basename="proxy-nopage") +router.register("profiles-select", ProfileSelectViewSet, basename="profile-select") +router.register("profiles-old", ProfileOldStyleViewSet, basename="profile-old") +router.register("users-prefetch", UserPrefetchViewSet, basename="user-prefetch") +router.register("users-old", UserOldStyleViewSet, basename="user-old") +router.register("profiles-owner", OwnerProfileViewSet, basename="profile-owner") +router.register("bulk-proxies", BulkProxyViewSet, basename="bulk-proxy") + +urlpatterns = [path("", include(router.urls))] class BaseViewSetTest(TestCase): @@ -84,3 +218,197 @@ class BulkMixinTest(TestCase): """Test BulkMixin has bulk_delete method""" self.assertTrue(hasattr(BulkMixin, "bulk_delete")) self.assertTrue(callable(BulkMixin.bulk_delete)) + + +@override_settings(ROOT_URLCONF=__name__) +class BaseViewSetIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_list_paginated_uses_list_serializer(self): + ProxyFactory.create_batch(3) + + response = self.client.get("/proxies/?page=1&page_size=2") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["success"]) + self.assertEqual(len(response.data["data"]), 2) + self.assertIn("pagination", response.data["meta"]) + self.assertSetEqual( + set(response.data["data"][0].keys()), {"id", "address"} + ) + + def test_list_without_pagination(self): + ProxyFactory.create_batch(2) + + response = self.client.get("/proxies-nopage/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["success"]) + self.assertEqual(len(response.data["data"]), 2) + self.assertIsNone(response.data["meta"]) + + def test_retrieve_uses_default_serializer(self): + proxy = ProxyFactory() + + response = self.client.get(f"/proxies/{proxy.pk}/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("fail_count", response.data["data"]) + + def test_create_update_delete(self): + payload = _proxy_payload() + + created = self.client.post("/proxies/", payload, format="json") + self.assertEqual(created.status_code, status.HTTP_201_CREATED) + + proxy_id = created.data["data"]["id"] + new_description = fake.sentence(nb_words=3) + updated = self.client.patch( + f"/proxies/{proxy_id}/", + {"description": new_description}, + format="json", + ) + self.assertEqual(updated.status_code, status.HTTP_200_OK) + self.assertEqual(updated.data["data"]["description"], new_description) + + deleted = self.client.delete(f"/proxies/{proxy_id}/") + self.assertEqual(deleted.status_code, status.HTTP_204_NO_CONTENT) + + +@override_settings(ROOT_URLCONF=__name__) +class ReadOnlyViewSetIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_readonly_list_and_retrieve(self): + proxy = ProxyFactory() + ProxyFactory.create_batch(2) + + list_response = self.client.get("/proxies-readonly/") + self.assertEqual(list_response.status_code, status.HTTP_200_OK) + self.assertTrue(list_response.data["success"]) + + detail_response = self.client.get(f"/proxies-readonly/{proxy.pk}/") + self.assertEqual(detail_response.status_code, status.HTTP_200_OK) + self.assertEqual(detail_response.data["data"]["id"], proxy.pk) + + +@override_settings(ROOT_URLCONF=__name__) +class OwnerViewSetIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.other_user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_list_filters_by_owner(self): + ProfileFactory.create_profile(user=self.user) + ProfileFactory.create_profile(user=self.other_user) + + response = self.client.get("/profiles-owner/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["data"]), 1) + + def test_create_sets_owner(self): + user = UserFactory.create_user() + user.profile.delete() + + self.client.force_authenticate(user) + response = self.client.post( + "/profiles-owner/", + {"first_name": fake.first_name(), "last_name": fake.last_name()}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["data"]["user"], user.id) + + +@override_settings(ROOT_URLCONF=__name__) +class BulkMixinIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_bulk_create_empty_items(self): + response = self.client.post("/bulk-proxies/bulk_create/", {}, format="json") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data["success"]) + + def test_bulk_create_too_many(self): + items = [_proxy_payload() for _ in range(3)] + response = self.client.post( + "/bulk-proxies/bulk_create/", {"items": items}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["errors"][0]["code"], "too_many_items") + + def test_bulk_create_update_delete(self): + items = [_proxy_payload(), _proxy_payload()] + created = self.client.post( + "/bulk-proxies/bulk_create/", {"items": items}, format="json" + ) + self.assertEqual(created.status_code, status.HTTP_201_CREATED) + + created_ids = [item["id"] for item in created.data["data"]] + update_items = [ + {"id": created_ids[0], "description": fake.sentence(nb_words=2)}, + { + "id": fake.random_int(min=999999, max=9999999), + "description": fake.word(), + }, + ] + updated = self.client.patch( + "/bulk-proxies/bulk_update/", {"items": update_items}, format="json" + ) + self.assertEqual(updated.status_code, status.HTTP_200_OK) + self.assertEqual(len(updated.data["data"]["updated"]), 1) + self.assertEqual(len(updated.data["data"]["errors"]), 1) + + deleted = self.client.delete( + "/bulk-proxies/bulk_delete/", {"ids": created_ids}, format="json" + ) + self.assertEqual(deleted.status_code, status.HTTP_200_OK) + self.assertEqual(deleted.data["data"]["deleted"], len(created_ids)) + + def test_bulk_update_missing_ids(self): + response = self.client.patch( + "/bulk-proxies/bulk_update/", + {"items": [{"address": fake.word()}]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["errors"][0]["code"], "missing_ids") + + +@override_settings(ROOT_URLCONF=__name__) +class QuerysetOptimizationIntegrationTest(APITestCase): + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_select_related_and_old_style(self): + ProfileFactory.create_profile(user=self.user) + + response_new = self.client.get("/profiles-select/") + self.assertEqual(response_new.status_code, status.HTTP_200_OK) + + response_old = self.client.get("/profiles-old/") + self.assertEqual(response_old.status_code, status.HTTP_200_OK) + + def test_prefetch_related_and_old_style(self): + UserFactory.create_user() + + response_new = self.client.get("/users-prefetch/") + self.assertEqual(response_new.status_code, status.HTTP_200_OK) + + response_old = self.client.get("/users-old/") + self.assertEqual(response_old.status_code, status.HTTP_200_OK) + + def test_defer_fields_branch(self): + ProxyFactory.create_batch(2) + response = self.client.get("/proxies-defer/") + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/tests/apps/user/factories.py b/tests/apps/user/factories.py index 4fcd031..6a9af2b 100644 --- a/tests/apps/user/factories.py +++ b/tests/apps/user/factories.py @@ -16,9 +16,7 @@ class UserFactory(factory.django.DjangoModelFactory): email = factory.LazyAttribute(lambda _: fake.unique.email()) username = factory.LazyAttribute(lambda _: fake.unique.user_name()) - phone = factory.LazyAttribute( - lambda _: f"+7{fake.numerify('##########')}" - ) + phone = factory.LazyAttribute(lambda _: f"+7{fake.numerify('##########')}") is_verified = False is_staff = False is_superuser = False @@ -58,7 +56,9 @@ class ProfileFactory(factory.django.DjangoModelFactory): class Meta: model = Profile - django_get_or_create = ("user",) # Используем get_or_create для избежания дубликатов + django_get_or_create = ( + "user", + ) # Используем get_or_create для избежания дубликатов user = factory.SubFactory(UserFactory) first_name = factory.LazyAttribute(lambda _: fake.first_name())