From 199d871923a8ca356e9780b826870f43b183533c Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Wed, 21 Jan 2026 20:16:25 +0100 Subject: [PATCH] feat(parsers): add proverki.gov.ru parser with sync_inspections task - Add InspectionRecord model with is_federal_law_248, data_year, data_month fields - Add ProverkiClient with Playwright support for JS-rendered portal - Add streaming XML parser for large files (>50MB) - Add sync_inspections task with incremental loading logic - Starts from 01.01.2025 if DB is empty - Loads both FZ-294 and FZ-248 inspections - Stops after 2 consecutive empty months - Add InspectionService methods: get_last_loaded_period, has_data_for_period - Add Minpromtorg parsers (certificates, manufacturers) - Add Django Admin for parser models - Update README with parsers documentation and changelog --- .gitignore | 2 +- README.md | 81 +- docker-compose.yml | 45 +- docker/Dockerfile.celery | 37 +- docker/Dockerfile.web | 17 +- pyproject.toml | 3 + requirements.txt | 262 +++- src/apps/core/admin.py | 152 +++ src/apps/parsers/__init__.py | 10 + src/apps/parsers/admin.py | 387 ++++++ src/apps/parsers/apps.py | 12 + src/apps/parsers/clients/__init__.py | 22 + src/apps/parsers/clients/base.py | 238 ++++ .../parsers/clients/minpromtorg/__init__.py | 18 + .../parsers/clients/minpromtorg/industrial.py | 225 ++++ .../clients/minpromtorg/manufactures.py | 218 ++++ .../parsers/clients/minpromtorg/schemas.py | 59 + src/apps/parsers/clients/proverki/__init__.py | 14 + src/apps/parsers/clients/proverki/client.py | 1102 +++++++++++++++++ src/apps/parsers/clients/proverki/schemas.py | 90 ++ .../migrations/0001_initial_parsers.py | 90 ++ .../migrations/0002_add_proxy_model.py | 32 + .../migrations/0003_add_inspection_model.py | 53 + .../migrations/0004_add_unique_constraints.py | 25 + .../0005_add_inspection_fz248_fields.py | 32 + src/apps/parsers/migrations/__init__.py | 0 src/apps/parsers/models.py | 363 ++++++ src/apps/parsers/serializers.py | 7 + src/apps/parsers/services.py | 536 ++++++++ src/apps/parsers/signals.py | 14 + src/apps/parsers/tasks.py | 580 +++++++++ src/apps/parsers/urls.py | 5 + src/apps/parsers/views.py | 7 + src/apps/user/admin.py | 181 +++ .../0002_remove_firstname_lastname.py | 27 + src/apps/user/models.py | 4 + src/config/api_v1_urls.py | 1 + src/config/celery.py | 16 +- src/config/settings/base.py | 99 ++ src/config/settings/development.py | 29 +- tests/apps/parsers/__init__.py | 1 + tests/apps/parsers/factories.py | 347 ++++++ tests/apps/parsers/test_clients.py | 646 ++++++++++ tests/apps/parsers/test_models.py | 141 +++ tests/apps/parsers/test_services.py | 677 ++++++++++ 45 files changed, 6810 insertions(+), 97 deletions(-) create mode 100644 src/apps/core/admin.py create mode 100644 src/apps/parsers/__init__.py create mode 100644 src/apps/parsers/admin.py create mode 100644 src/apps/parsers/apps.py create mode 100644 src/apps/parsers/clients/__init__.py create mode 100644 src/apps/parsers/clients/base.py create mode 100644 src/apps/parsers/clients/minpromtorg/__init__.py create mode 100644 src/apps/parsers/clients/minpromtorg/industrial.py create mode 100644 src/apps/parsers/clients/minpromtorg/manufactures.py create mode 100644 src/apps/parsers/clients/minpromtorg/schemas.py create mode 100644 src/apps/parsers/clients/proverki/__init__.py create mode 100644 src/apps/parsers/clients/proverki/client.py create mode 100644 src/apps/parsers/clients/proverki/schemas.py create mode 100644 src/apps/parsers/migrations/0001_initial_parsers.py create mode 100644 src/apps/parsers/migrations/0002_add_proxy_model.py create mode 100644 src/apps/parsers/migrations/0003_add_inspection_model.py create mode 100644 src/apps/parsers/migrations/0004_add_unique_constraints.py create mode 100644 src/apps/parsers/migrations/0005_add_inspection_fz248_fields.py create mode 100644 src/apps/parsers/migrations/__init__.py create mode 100644 src/apps/parsers/models.py create mode 100644 src/apps/parsers/serializers.py create mode 100644 src/apps/parsers/services.py create mode 100644 src/apps/parsers/signals.py create mode 100644 src/apps/parsers/tasks.py create mode 100644 src/apps/parsers/urls.py create mode 100644 src/apps/parsers/views.py create mode 100644 src/apps/user/admin.py create mode 100644 src/apps/user/migrations/0002_remove_firstname_lastname.py create mode 100644 tests/apps/parsers/__init__.py create mode 100644 tests/apps/parsers/factories.py create mode 100644 tests/apps/parsers/test_clients.py create mode 100644 tests/apps/parsers/test_models.py create mode 100644 tests/apps/parsers/test_services.py diff --git a/.gitignore b/.gitignore index a5bd201..8ef9cb4 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,4 @@ Thumbs.db # Backup files *.bak -*.backup \ No newline at end of file +*.backupdata/ diff --git a/README.md b/README.md index 2d9f74c..9a1e509 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,53 @@ - **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 +356,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 b2e5137..a76721b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,19 @@ -version: '3.8' - services: db: image: postgres:15.10 - container_name: project_db + container_name: mostovik_db restart: unless-stopped environment: - POSTGRES_DB: ${POSTGRES_DB:-project_dev} + POSTGRES_DB: ${POSTGRES_DB:-mostovik_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" networks: - - project_network + - mostovik_network healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 30s @@ -24,14 +22,14 @@ services: redis: image: redis:7-alpine - container_name: project_redis + container_name: mostovik_redis restart: unless-stopped ports: - "6379:6379" volumes: - - redis_data:/data + - ./data/redis:/data networks: - - project_network + - mostovik_network healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 30s @@ -42,7 +40,7 @@ services: build: context: . dockerfile: docker/Dockerfile.web - container_name: project_web + container_name: mostovik_web restart: unless-stopped depends_on: db: @@ -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:-mostovik_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 @@ -67,7 +66,7 @@ services: ports: - "8000:8000" networks: - - project_network + - mostovik_network command: > sh -c "python src/manage.py migrate && python src/manage.py collectstatic --noinput && @@ -77,7 +76,7 @@ services: build: context: . dockerfile: docker/Dockerfile.celery - container_name: project_celery_worker + container_name: mostovik_celery_worker restart: unless-stopped depends_on: db: @@ -88,23 +87,24 @@ services: - DEBUG=${DEBUG:-True} - POSTGRES_HOST=db - POSTGRES_PORT=5432 - - POSTGRES_DB=${POSTGRES_DB:-project_dev} + - POSTGRES_DB=${POSTGRES_DB:-mostovik_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 networks: - - project_network + - mostovik_network command: celery -A config worker --loglevel=info celery_beat: build: context: . dockerfile: docker/Dockerfile.celery - container_name: project_celery_beat + container_name: mostovik_celery_beat restart: unless-stopped depends_on: db: @@ -115,22 +115,19 @@ services: - DEBUG=${DEBUG:-True} - POSTGRES_HOST=db - POSTGRES_PORT=5432 - - POSTGRES_DB=${POSTGRES_DB:-project_dev} + - POSTGRES_DB=${POSTGRES_DB:-mostovik_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 networks: - - project_network + - mostovik_network command: celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler -volumes: - postgres_data: - redis_data: - networks: - project_network: - driver: bridge \ No newline at end of file + mostovik_network: + driver: bridge diff --git a/docker/Dockerfile.celery b/docker/Dockerfile.celery index 9b5a0bd..bce2b61 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/* # Создание рабочей директории @@ -28,8 +46,15 @@ COPY src/ ./src/ # Создание необходимых директорий RUN mkdir -p 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..7c1803c 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/* # Создание рабочей директории @@ -29,6 +29,9 @@ COPY src/ ./src/ # Создание необходимых директорий RUN mkdir -p logs staticfiles media +# PYTHONPATH для доступа к модулям +ENV PYTHONPATH=/app/src + # Создание пользователя для запуска приложения RUN groupadd -r appgroup && useradd -r -g appgroup appuser RUN chown -R appuser:appgroup /app diff --git a/pyproject.toml b/pyproject.toml index fb661b2..5ed9f1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ 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", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index 070dfe5..d9012e3 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 +alabaster==0.7.16 + # via sphinx amqp==5.3.1 # via kombu asgiref==3.11.0 # via # django # django-cors-headers +async-timeout==5.0.1 ; python_full_version < '3.11.3' + # via redis attrs==25.4.0 # via # outcome @@ -14,49 +18,69 @@ attrs==25.4.0 # twisted automat==25.4.16 # via twisted +babel==2.17.0 + # via sphinx +bandit==1.7.5 beautifulsoup4==4.12.3 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend billiard==4.2.4 # via celery +black==23.12.1 celery==5.3.6 # via - # mostovik-backend (pyproject.toml) # django-celery-beat # django-celery-results + # flower + # mostovik-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 + # gevent + # trio +cfgv==3.5.0 + # via pre-commit charset-normalizer==3.4.4 # via requests click==8.1.7 # via + # black # celery # click-didyoumean # click-plugins # click-repl + # typer click-didyoumean==0.3.1 # via celery click-plugins==1.1.1.2 # via celery click-repl==0.3.0 # via celery +colorama==0.4.6 ; sys_platform == 'win32' + # via + # bandit + # click + # pytest + # sphinx constantly==23.10.4 # via twisted coreapi==2.3.3 # via - # mostovik-backend (pyproject.toml) # django-rest-swagger + # mostovik-backend # openapi-codec coreschema==0.0.4 # via coreapi +coverage==7.4.0 + # via pytest-cov cron-descriptor==2.0.6 # via django-celery-beat cryptography==42.0.5 # via - # mostovik-backend (pyproject.toml) + # mostovik-backend # pyopenssl # scrapy # service-identity @@ -66,59 +90,108 @@ cssselect==1.3.0 # scrapy defusedxml==0.7.1 # via scrapy +distlib==0.4.0 + # via virtualenv django==3.2.25 # via - # mostovik-backend (pyproject.toml) # django-celery-beat # django-celery-results # django-cors-headers + # django-debug-toolbar + # django-extensions # django-filter + # django-jazzmin # django-redis + # django-stubs + # django-stubs-ext # django-timezone-field # djangorestframework # djangorestframework-simplejwt # drf-yasg # model-bakery + # mostovik-backend django-celery-beat==2.6.0 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend django-celery-results==2.5.1 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend django-cors-headers==4.3.1 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend +django-debug-toolbar==4.2.0 +django-extensions==3.2.3 django-filter==23.5 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend +django-jazzmin==2.6.2 + # via mostovik-backend django-redis==5.4.0 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend django-rest-swagger==2.2.0 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend +django-stubs==4.2.7 +django-stubs-ext==5.2.9 + # via django-stubs 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 + # mostovik-backend djangorestframework-simplejwt==5.3.1 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend +docutils==0.20.1 + # via + # sphinx + # sphinx-rtd-theme drf-yasg==1.21.10 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend +et-xmlfile==2.0.0 + # via openpyxl +factory-boy==3.3.0 + # via mostovik-backend +faker==40.1.2 + # via + # factory-boy + # mostovik-backend filelock==3.20.3 - # via tldextract + # via + # tldextract + # virtualenv +flake8==6.1.0 +flower==2.0.1 +gevent==23.9.1 +gitdb==4.0.12 + # via gitpython +gitpython==3.1.46 + # via bandit +greenlet==3.3.0 + # via + # gevent + # playwright +gunicorn==21.2.0 h11==0.16.0 # via wsproto +humanize==4.15.0 + # via flower hyperlink==21.0.0 # via twisted +identify==2.6.16 + # via pre-commit idna==3.11 # via # hyperlink # requests # tldextract # trio +imagesize==1.4.1 + # via sphinx incremental==24.11.0 # via twisted inflection==0.5.1 # via drf-yasg +iniconfig==2.3.0 + # via pytest +isort==5.13.2 itemadapter==0.13.1 # via # itemloaders @@ -128,7 +201,9 @@ itemloaders==1.3.2 itypes==1.2.0 # via coreapi jinja2==3.1.6 - # via coreschema + # via + # coreschema + # sphinx jmespath==1.0.1 # via # itemloaders @@ -139,97 +214,157 @@ lxml==6.0.2 # via # parsel # scrapy +markdown-it-py==3.0.0 + # via rich markupsafe==3.0.3 - # via jinja2 + # via + # jinja2 + # werkzeug +mccabe==0.7.0 + # via flake8 +mdurl==0.1.2 + # via markdown-it-py model-bakery==1.17.0 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend +mypy==1.8.0 +mypy-extensions==1.1.0 + # via + # black + # mypy +nodeenv==1.10.0 + # via pre-commit numpy==1.24.4 # via - # mostovik-backend (pyproject.toml) + # mostovik-backend # pandas openapi-codec==1.3.2 # via django-rest-swagger +openpyxl==3.1.5 + # via mostovik-backend outcome==1.3.0.post0 # via # trio # trio-websocket packaging==25.0 # via + # black # drf-yasg + # gunicorn # incremental # kombu # parsel + # pytest # scrapy + # sphinx pandas==2.0.3 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend parsel==1.10.0 # via # itemloaders # scrapy +pathspec==1.0.3 + # via black pillow==12.1.0 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend +platformdirs==4.5.1 + # via + # black + # virtualenv +playwright==1.57.0 + # via mostovik-backend +pluggy==1.6.0 + # via pytest +pre-commit==3.6.0 +prometheus-client==0.24.1 + # via flower prompt-toolkit==3.0.52 # via click-repl protego==0.5.0 # via scrapy psycopg2-binary==2.9.9 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend pyasn1==0.6.2 # via # pyasn1-modules # service-identity pyasn1-modules==0.4.2 # via service-identity -pycparser==2.23 +pycodestyle==2.11.1 + # via flake8 +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 +pyflakes==3.1.0 + # via flake8 +pygments==2.19.2 + # via + # rich + # sphinx pyjwt==2.10.1 # via djangorestframework-simplejwt pyopenssl==25.1.0 # via scrapy +pypydispatcher==2.1.2 ; platform_python_implementation == 'PyPy' + # via scrapy pysocks==1.7.1 # via urllib3 +pytest==7.4.4 + # via + # pytest-cov + # pytest-django +pytest-cov==4.1.0 +pytest-django==4.7.0 python-crontab==3.3.0 # via django-celery-beat python-dateutil==2.8.2 # via - # mostovik-backend (pyproject.toml) # celery + # mostovik-backend # pandas python-decouple==3.8 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend python-dotenv==1.0.1 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend python-json-logger==2.0.7 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend pytz==2024.1 # via - # mostovik-backend (pyproject.toml) # django # djangorestframework # drf-yasg + # flower + # mostovik-backend # pandas pyyaml==6.0.3 - # via drf-yasg + # via + # bandit + # drf-yasg + # pre-commit queuelib==1.8.0 # via scrapy redis==5.0.3 # via - # mostovik-backend (pyproject.toml) # django-redis + # mostovik-backend requests==2.31.0 # via - # mostovik-backend (pyproject.toml) # coreapi + # mostovik-backend # requests-file + # sphinx # tldextract requests-file==3.0.1 # via tldextract +rich==14.2.0 + # via bandit +ruff==0.1.14 scrapy==2.11.2 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend selenium==4.17.2 - # via mostovik-backend (pyproject.toml) + # via mostovik-backend service-identity==24.2.0 # via scrapy setuptools==80.9.0 @@ -238,16 +373,47 @@ simplejson==3.20.2 # via django-rest-swagger six==1.17.0 # via python-dateutil +smmap==5.0.2 + # via gitdb sniffio==1.3.1 # via trio +snowballstemmer==3.0.1 + # via sphinx sortedcontainers==2.4.0 # via trio soupsieve==2.8.2 # via beautifulsoup4 +sphinx==7.2.6 + # via + # sphinx-rtd-theme + # sphinxcontrib-jquery +sphinx-rtd-theme==2.0.0 +sphinxcontrib-applehelp==2.0.0 + # via sphinx +sphinxcontrib-devhelp==2.0.0 + # via sphinx +sphinxcontrib-htmlhelp==2.1.0 + # via sphinx +sphinxcontrib-jquery==4.1 + # via sphinx-rtd-theme +sphinxcontrib-jsmath==1.0.1 + # via sphinx +sphinxcontrib-qthelp==2.0.0 + # via sphinx +sphinxcontrib-serializinghtml==2.0.0 + # via sphinx sqlparse==0.5.5 - # via django + # via + # django + # django-debug-toolbar +stevedore==5.6.0 + # via bandit tldextract==5.3.1 # via scrapy +tomli==2.4.0 ; python_full_version <= '3.11' + # via coverage +tornado==6.5.4 + # via flower trio==0.32.0 # via # selenium @@ -256,16 +422,28 @@ trio-websocket==0.12.2 # via selenium twisted==25.5.0 # via scrapy +typer==0.9.0 +types-pytz==2025.2.0.20251108 + # via django-stubs +types-pyyaml==6.0.12.20250915 + # via django-stubs +types-requests==2.31.0.20240125 typing-extensions==4.15.0 # via # cron-descriptor + # django-stubs + # django-stubs-ext + # mypy + # pyee # pyopenssl # selenium # twisted + # typer tzdata==2025.3 # via # celery # django-celery-beat + # faker # kombu # pandas uritemplate==4.2.0 @@ -276,20 +454,28 @@ urllib3==2.6.3 # via # requests # selenium + # types-requests vine==5.1.0 # via # amqp # celery # kombu +virtualenv==20.36.1 + # via pre-commit w3lib==2.3.1 # via # parsel # scrapy +watchdog==3.0.0 wcwidth==0.2.14 # via prompt-toolkit +werkzeug==3.0.1 wsproto==1.3.2 # via trio-websocket +zope-event==6.1 + # via gevent zope-interface==8.2 # via + # gevent # scrapy # twisted diff --git a/src/apps/core/admin.py b/src/apps/core/admin.py new file mode 100644 index 0000000..7fe9e2e --- /dev/null +++ b/src/apps/core/admin.py @@ -0,0 +1,152 @@ +""" +Admin configuration for core app. +""" + +from apps.core.models import BackgroundJob +from django.contrib import admin +from django.utils.html import format_html + + +@admin.register(BackgroundJob) +class BackgroundJobAdmin(admin.ModelAdmin): + """Admin для фоновых задач.""" + + list_display = [ + "task_name_short", + "status_badge", + "progress_bar", + "user_id", + "started_at", + "duration_display", + "created_at", + ] + list_filter = ["status", "task_name", "created_at"] + search_fields = ["task_id", "task_name", "error"] + readonly_fields = [ + "id", + "task_id", + "task_name", + "status", + "progress", + "progress_message", + "result", + "error", + "traceback", + "started_at", + "completed_at", + "user_id", + "meta", + "created_at", + "updated_at", + ] + ordering = ["-created_at"] + list_per_page = 50 + date_hierarchy = "created_at" + + fieldsets = ( + ( + "Задача", + {"fields": ("id", "task_id", "task_name", "user_id")}, + ), + ( + "Статус", + {"fields": ("status", "progress", "progress_message")}, + ), + ( + "Результат", + {"fields": ("result",), "classes": ("collapse",)}, + ), + ( + "Ошибка", + {"fields": ("error", "traceback"), "classes": ("collapse",)}, + ), + ( + "Время", + {"fields": ("started_at", "completed_at", "created_at", "updated_at")}, + ), + ( + "Метаданные", + {"fields": ("meta",), "classes": ("collapse",)}, + ), + ) + + def task_name_short(self, obj): + """Сокращённое имя задачи.""" + name = obj.task_name or "" + # Берём только последнюю часть пути + parts = name.split(".") + if len(parts) > 2: + return parts[-1] + return name + + task_name_short.short_description = "Задача" + task_name_short.admin_order_field = "task_name" + + def status_badge(self, obj): + """Цветной бейдж статуса.""" + colors = { + "pending": "#6c757d", + "started": "#007bff", + "success": "#28a745", + "failure": "#dc3545", + "revoked": "#ffc107", + "retry": "#17a2b8", + } + color = colors.get(obj.status, "#6c757d") + return format_html( + '{}', + color, + obj.get_status_display(), + ) + + status_badge.short_description = "Статус" + status_badge.admin_order_field = "status" + + def progress_bar(self, obj): + """Прогресс-бар.""" + progress = obj.progress or 0 + color = "#28a745" if progress == 100 else "#007bff" + return format_html( + '
' + '
' + "{}%
", + progress, + color, + progress, + ) + + progress_bar.short_description = "Прогресс" + + def duration_display(self, obj): + """Длительность выполнения.""" + duration = obj.duration + if duration is None: + return "-" + if duration < 60: + return f"{duration:.1f} сек" + return f"{duration / 60:.1f} мин" + + duration_display.short_description = "Длительность" + + def has_add_permission(self, request): + """Запретить создание записей вручную.""" + return False + + def has_change_permission(self, request, obj=None): + """Запретить редактирование записей.""" + return False + + actions = ["revoke_jobs"] + + @admin.action(description="Отменить выбранные задачи") + def revoke_jobs(self, request, queryset): + from celery import current_app + + count = 0 + for job in queryset.filter(status__in=["pending", "started"]): + current_app.control.revoke(job.task_id, terminate=True) + job.revoke() + count += 1 + self.message_user(request, f"Отменено {count} задач") diff --git a/src/apps/parsers/__init__.py b/src/apps/parsers/__init__.py new file mode 100644 index 0000000..0083698 --- /dev/null +++ b/src/apps/parsers/__init__.py @@ -0,0 +1,10 @@ +""" +Приложение для парсеров и ETL процессов. + +Содержит: +- Модели для конфигурации источников данных и результатов парсинга +- Сервисы для извлечения, трансформации и загрузки данных (ETL) +- Celery задачи для фоновой обработки +""" + +default_app_config = "apps.parsers.apps.ParsersConfig" diff --git a/src/apps/parsers/admin.py b/src/apps/parsers/admin.py new file mode 100644 index 0000000..1a8741c --- /dev/null +++ b/src/apps/parsers/admin.py @@ -0,0 +1,387 @@ +""" +Admin configuration for parsers app. +""" + +from apps.parsers.models import ( + IndustrialCertificateRecord, + InspectionRecord, + ManufacturerRecord, + ParserLoadLog, + Proxy, +) +from django.contrib import admin +from django.utils.html import format_html + + +@admin.register(Proxy) +class ProxyAdmin(admin.ModelAdmin): + """Admin для прокси-серверов.""" + + list_display = [ + "address", + "is_active_badge", + "fail_count", + "last_used_at", + "created_at", + ] + list_filter = ["is_active", "created_at"] + search_fields = ["address"] + readonly_fields = ["created_at", "updated_at", "last_used_at"] + ordering = ["-is_active", "-last_used_at"] + list_per_page = 50 + + fieldsets = ( + ("Основное", {"fields": ("address", "is_active")}), + ("Статистика", {"fields": ("fail_count", "last_used_at")}), + ("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), + ) + + def is_active_badge(self, obj): + """Цветной бейдж активности.""" + if obj.is_active: + return format_html( + 'Активен' + ) + return format_html( + 'Неактивен' + ) + + is_active_badge.short_description = "Статус" + is_active_badge.admin_order_field = "is_active" + + actions = ["activate_proxies", "deactivate_proxies", "reset_fail_count"] + + @admin.action(description="Активировать выбранные прокси") + def activate_proxies(self, request, queryset): + updated = queryset.update(is_active=True) + self.message_user(request, f"Активировано {updated} прокси") + + @admin.action(description="Деактивировать выбранные прокси") + def deactivate_proxies(self, request, queryset): + updated = queryset.update(is_active=False) + self.message_user(request, f"Деактивировано {updated} прокси") + + @admin.action(description="Сбросить счётчик ошибок") + def reset_fail_count(self, request, queryset): + updated = queryset.update(fail_count=0) + self.message_user(request, f"Сброшен счётчик для {updated} прокси") + + +@admin.register(ParserLoadLog) +class ParserLoadLogAdmin(admin.ModelAdmin): + """Admin для логов загрузки.""" + + list_display = [ + "id", + "source", + "batch_id", + "status_badge", + "records_count", + "created_at", + ] + list_filter = ["source", "status", "created_at"] + search_fields = ["batch_id", "error_message"] + readonly_fields = ["created_at", "updated_at"] + ordering = ["-created_at"] + list_per_page = 50 + date_hierarchy = "created_at" + + fieldsets = ( + ("Основное", {"fields": ("source", "batch_id", "status")}), + ("Результат", {"fields": ("records_count", "error_message")}), + ("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), + ) + + def status_badge(self, obj): + """Цветной бейдж статуса.""" + colors = { + "success": "#28a745", + "failed": "#dc3545", + "in_progress": "#ffc107", + "pending": "#6c757d", + } + color = colors.get(obj.status, "#6c757d") + return format_html( + '{}', + color, + obj.get_status_display() + if hasattr(obj, "get_status_display") + else obj.status, + ) + + status_badge.short_description = "Статус" + status_badge.admin_order_field = "status" + + def has_add_permission(self, request): + """Запретить создание логов вручную.""" + return False + + +class HasCertificateNumberFilter(admin.SimpleListFilter): + """Фильтр по наличию номера сертификата.""" + + title = "Номер сертификата" + parameter_name = "has_cert_number" + + def lookups(self, request, model_admin): + return [ + ("yes", "С номером"), + ("no", "Без номера"), + ] + + def queryset(self, request, queryset): + if self.value() == "yes": + return queryset.exclude(certificate_number__in=["-", ""]) + if self.value() == "no": + return queryset.filter(certificate_number__in=["-", ""]) + return queryset + + +@admin.register(IndustrialCertificateRecord) +class IndustrialCertificateRecordAdmin(admin.ModelAdmin): + """Admin для сертификатов промышленного производства.""" + + list_display = [ + "certificate_number", + "organisation_name_short", + "inn", + "ogrn", + "issue_date", + "expiry_date", + "load_batch", + ] + list_filter = [HasCertificateNumberFilter, "load_batch", "created_at"] + search_fields = [ + "certificate_number", + "organisation_name", + "inn", + "ogrn", + ] + readonly_fields = ["created_at", "updated_at", "load_batch"] + ordering = ["-created_at"] + list_per_page = 100 + date_hierarchy = "created_at" + raw_id_fields = [] + + fieldsets = ( + ( + "Сертификат", + {"fields": ("certificate_number", "issue_date", "expiry_date")}, + ), + ( + "Организация", + {"fields": ("organisation_name", "inn", "ogrn")}, + ), + ( + "Документ", + {"fields": ("certificate_file_url",), "classes": ("collapse",)}, + ), + ( + "Системное", + { + "fields": ("load_batch", "created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + def organisation_name_short(self, obj): + """Сокращённое название организации.""" + name = obj.organisation_name or "" + return name[:60] + "..." if len(name) > 60 else name + + organisation_name_short.short_description = "Организация" + organisation_name_short.admin_order_field = "organisation_name" + + def has_add_permission(self, request): + """Запретить создание записей вручную.""" + return False + + def has_change_permission(self, request, obj=None): + """Запретить редактирование записей.""" + return False + + +@admin.register(ManufacturerRecord) +class ManufacturerRecordAdmin(admin.ModelAdmin): + """Admin для реестра производителей.""" + + list_display = [ + "full_legal_name_short", + "inn", + "ogrn", + "address_short", + "load_batch", + "created_at", + ] + list_filter = ["load_batch", "created_at"] + search_fields = [ + "full_legal_name", + "inn", + "ogrn", + "address", + ] + readonly_fields = ["created_at", "updated_at", "load_batch"] + ordering = ["-created_at"] + list_per_page = 100 + date_hierarchy = "created_at" + + fieldsets = ( + ( + "Организация", + {"fields": ("full_legal_name", "inn", "ogrn")}, + ), + ( + "Адрес", + {"fields": ("address",)}, + ), + ( + "Системное", + { + "fields": ("load_batch", "created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + def full_legal_name_short(self, obj): + """Сокращённое название.""" + name = obj.full_legal_name or "" + return name[:60] + "..." if len(name) > 60 else name + + full_legal_name_short.short_description = "Название" + full_legal_name_short.admin_order_field = "full_legal_name" + + def address_short(self, obj): + """Сокращённый адрес.""" + addr = obj.address or "" + return addr[:40] + "..." if len(addr) > 40 else addr + + address_short.short_description = "Адрес" + address_short.admin_order_field = "address" + + def has_add_permission(self, request): + """Запретить создание записей вручную.""" + return False + + def has_change_permission(self, request, obj=None): + """Запретить редактирование записей.""" + return False + + +@admin.register(InspectionRecord) +class InspectionRecordAdmin(admin.ModelAdmin): + """Admin для проверок из Единого реестра проверок.""" + + list_display = [ + "registration_number", + "organisation_name_short", + "inn", + "control_authority_short", + "inspection_type", + "status_badge", + "start_date", + "load_batch", + ] + list_filter = [ + "inspection_type", + "inspection_form", + "status", + "load_batch", + "created_at", + ] + search_fields = [ + "registration_number", + "organisation_name", + "inn", + "ogrn", + "control_authority", + ] + readonly_fields = ["created_at", "updated_at", "load_batch"] + ordering = ["-created_at"] + list_per_page = 100 + date_hierarchy = "created_at" + + fieldsets = ( + ( + "Проверка", + { + "fields": ( + "registration_number", + "inspection_type", + "inspection_form", + "status", + ) + }, + ), + ( + "Организация", + {"fields": ("organisation_name", "inn", "ogrn")}, + ), + ( + "Контрольный орган", + {"fields": ("control_authority", "legal_basis")}, + ), + ( + "Сроки и результат", + {"fields": ("start_date", "end_date", "result")}, + ), + ( + "Системное", + { + "fields": ("load_batch", "created_at", "updated_at"), + "classes": ("collapse",), + }, + ), + ) + + def organisation_name_short(self, obj): + """Сокращённое название организации.""" + name = obj.organisation_name or "" + return name[:50] + "..." if len(name) > 50 else name + + organisation_name_short.short_description = "Организация" + organisation_name_short.admin_order_field = "organisation_name" + + def control_authority_short(self, obj): + """Сокращённое название контрольного органа.""" + name = obj.control_authority or "" + return name[:30] + "..." if len(name) > 30 else name + + control_authority_short.short_description = "Контр. орган" + control_authority_short.admin_order_field = "control_authority" + + def status_badge(self, obj): + """Цветной бейдж статуса.""" + status = obj.status or "" + status_lower = status.lower() + + if "завершен" in status_lower: + color = "#28a745" + elif "процесс" in status_lower or "проведен" in status_lower: + color = "#ffc107" + elif "отменен" in status_lower or "прекращ" in status_lower: + color = "#dc3545" + else: + color = "#6c757d" + + return format_html( + '{}', + color, + status[:20] if len(status) > 20 else status, + ) + + status_badge.short_description = "Статус" + status_badge.admin_order_field = "status" + + def has_add_permission(self, request): + """Запретить создание записей вручную.""" + return False + + def has_change_permission(self, request, obj=None): + """Запретить редактирование записей.""" + return False diff --git a/src/apps/parsers/apps.py b/src/apps/parsers/apps.py new file mode 100644 index 0000000..3576eb9 --- /dev/null +++ b/src/apps/parsers/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class ParsersConfig(AppConfig): + """Конфигурация приложения парсеров.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "apps.parsers" + verbose_name = "Парсеры и ETL" + + def ready(self): + import apps.parsers.signals # noqa diff --git a/src/apps/parsers/clients/__init__.py b/src/apps/parsers/clients/__init__.py new file mode 100644 index 0000000..e2cb352 --- /dev/null +++ b/src/apps/parsers/clients/__init__.py @@ -0,0 +1,22 @@ +""" +Клиенты для парсинга внешних источников данных. + +Каждый источник имеет изолированный клиент, который: +- Принимает настройки (proxy и т.д.) через конструктор +- Возвращает типизированные dataclass объекты +- Не зависит от Django ORM +""" + +from apps.parsers.clients.base import BaseHTTPClient +from apps.parsers.clients.minpromtorg import ( + IndustrialProductionClient, + ManufacturesClient, +) +from apps.parsers.clients.proverki import ProverkiClient + +__all__ = [ + "BaseHTTPClient", + "IndustrialProductionClient", + "ManufacturesClient", + "ProverkiClient", +] diff --git a/src/apps/parsers/clients/base.py b/src/apps/parsers/clients/base.py new file mode 100644 index 0000000..9bfbd5a --- /dev/null +++ b/src/apps/parsers/clients/base.py @@ -0,0 +1,238 @@ +""" +Базовый HTTP клиент для парсеров. + +Изолирован от Django, использует только стандартные библиотеки и requests. +""" + +import logging +import random +from dataclasses import dataclass, field +from typing import Any + +import requests + +logger = logging.getLogger(__name__) + + +class HTTPClientError(Exception): + """Базовое исключение HTTP клиента.""" + + def __init__( + self, message: str, status_code: int | None = None, url: str | None = None + ): + self.message = message + self.status_code = status_code + self.url = url + super().__init__(message) + + +class ConnectionError(HTTPClientError): + """Ошибка подключения.""" + + pass + + +class HTTPError(HTTPClientError): + """HTTP ошибка (4xx, 5xx).""" + + pass + + +@dataclass +class BaseHTTPClient: + """ + Базовый HTTP клиент для парсинга внешних источников. + + Изолирован от Django. Принимает все настройки через конструктор. + + Поддерживает работу со списком прокси — при каждом запросе выбирается случайный. + + Использование: + # Без прокси + client = BaseHTTPClient(base_url="https://api.example.com") + + # С одним прокси + client = BaseHTTPClient( + base_url="https://api.example.com", + proxies=["http://proxy:8080"] + ) + + # Со списком прокси (выбор случайный) + client = BaseHTTPClient( + base_url="https://api.example.com", + proxies=["http://proxy1:8080", "http://proxy2:8080"] + ) + """ + + base_url: str + proxies: list[str] | None = None + timeout: int = 30 + headers: dict[str, str] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Инициализация после создания dataclass.""" + self._session: requests.Session | None = None + self._current_proxy: str | None = None + # Убираем trailing slash + self.base_url = self.base_url.rstrip("/") + + def _select_proxy(self) -> str | None: + """Выбрать случайный прокси из списка.""" + if not self.proxies: + return None + return random.choice(self.proxies) # noqa: S311 - not for cryptographic use + + @property + def session(self) -> requests.Session: + """Ленивая инициализация сессии.""" + if self._session is None: + self._session = self._create_session() + return self._session + + def _create_session(self) -> requests.Session: + """Создать и настроить сессию requests.""" + session = requests.Session() + + # Настройка прокси + self._current_proxy = self._select_proxy() + if self._current_proxy: + session.proxies = { + "http": self._current_proxy, + "https": self._current_proxy, + } + logger.debug("Proxy configured: %s", self._current_proxy) + + # Базовые заголовки + default_headers = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + "Accept-Encoding": "gzip, deflate, br", + } + default_headers.update(self.headers) + session.headers.update(default_headers) + + return session + + def rotate_proxy(self) -> str | None: + """ + Сменить прокси на другой из списка. + + Пересоздаёт сессию с новым прокси. + + Returns: + Новый прокси или None + """ + if self._session is not None: + self._session.close() + self._session = None + + self._current_proxy = self._select_proxy() + logger.info("Rotated proxy to: %s", self._current_proxy) + return self._current_proxy + + @property + def current_proxy(self) -> str | None: + """Текущий используемый прокси.""" + return self._current_proxy + + def _build_url(self, endpoint: str) -> str: + """Построить полный URL.""" + if endpoint.startswith(("http://", "https://")): + return endpoint + endpoint = endpoint.lstrip("/") + return f"{self.base_url}/{endpoint}" + + def get( + self, endpoint: str, params: dict[str, Any] | None = None + ) -> requests.Response: + """ + Выполнить GET запрос. + + Args: + endpoint: Путь или полный URL + params: Query параметры + + Returns: + Response объект + + Raises: + ConnectionError: При ошибке подключения + HTTPError: При HTTP ошибке (4xx, 5xx) + """ + url = self._build_url(endpoint) + logger.info("GET %s (proxy: %s)", url, self._current_proxy) + + try: + response = self.session.get(url, params=params, timeout=self.timeout) + except requests.exceptions.ConnectionError as e: + logger.error("Connection error: %s - %s", url, e) + raise ConnectionError(f"Failed to connect to {url}", url=url) from e + except requests.exceptions.Timeout as e: + logger.error("Timeout: %s", url) + raise ConnectionError(f"Request timeout for {url}", url=url) from e + except requests.exceptions.RequestException as e: + logger.error("Request error: %s - %s", url, e) + raise HTTPClientError(f"Request failed: {e}", url=url) from e + + if not response.ok: + logger.error("HTTP error %d: %s", response.status_code, url) + raise HTTPError( + f"HTTP {response.status_code} for {url}", + status_code=response.status_code, + url=url, + ) + + logger.debug("Response %d from %s", response.status_code, url) + return response + + def get_json(self, endpoint: str, params: dict[str, Any] | None = None) -> dict: + """ + Выполнить GET запрос и вернуть JSON. + + Args: + endpoint: Путь или полный URL + params: Query параметры + + Returns: + Распарсенный JSON как dict + """ + response = self.get(endpoint, params=params) + return response.json() + + def download_file(self, endpoint: str) -> bytes: + """ + Скачать файл. + + Args: + endpoint: Путь или полный URL файла + + Returns: + Содержимое файла как bytes + """ + url = self._build_url(endpoint) + logger.info("Downloading file: %s", url) + + response = self.get(endpoint) + content = response.content + + logger.info("Downloaded %d bytes from %s", len(content), url) + return content + + def close(self) -> None: + """Закрыть сессию.""" + if self._session is not None: + self._session.close() + self._session = None + logger.debug("Session closed") + + def __enter__(self) -> "BaseHTTPClient": + """Поддержка context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Закрытие при выходе из context manager.""" + self.close() diff --git a/src/apps/parsers/clients/minpromtorg/__init__.py b/src/apps/parsers/clients/minpromtorg/__init__.py new file mode 100644 index 0000000..5fba646 --- /dev/null +++ b/src/apps/parsers/clients/minpromtorg/__init__.py @@ -0,0 +1,18 @@ +""" +Клиенты для парсинга данных с портала Минпромторга. + +Источники: +- IndustrialProductionClient: сертификаты промышленного производства +- ManufacturesClient: реестр производителей +""" + +from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient +from apps.parsers.clients.minpromtorg.manufactures import ManufacturesClient +from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer + +__all__ = [ + "IndustrialProductionClient", + "ManufacturesClient", + "IndustrialCertificate", + "Manufacturer", +] diff --git a/src/apps/parsers/clients/minpromtorg/industrial.py b/src/apps/parsers/clients/minpromtorg/industrial.py new file mode 100644 index 0000000..6f6abdf --- /dev/null +++ b/src/apps/parsers/clients/minpromtorg/industrial.py @@ -0,0 +1,225 @@ +""" +Клиент для парсинга данных о промышленном производстве РФ. + +Источник: Минпромторг, раздел "Заключения о подтверждении производства". +""" + +import logging +import re +from dataclasses import dataclass, field +from datetime import datetime +from io import BytesIO + +from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError +from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate +from openpyxl import load_workbook + +logger = logging.getLogger(__name__) + +# Конфигурация по умолчанию +DEFAULT_HOST = "minpromtorg.gov.ru" +DEFAULT_API_PATH = "/api/kss-document-preview" +DEFAULT_DOC_TYPE = "668d4f2a-966a-4b65-9fb9-2f1ad19a3d1f" +DEFAULT_QUERY = "Заключения о подтверждении производства промышленной продукции" +FILE_PATTERN = re.compile(r"data_resolutions_(\d{8})") + + +class IndustrialProductionClientError(HTTPClientError): + """Ошибка клиента промышленного производства.""" + + pass + + +@dataclass +class IndustrialProductionClient: + """ + Клиент для получения данных о сертификатах промышленного производства. + + Полностью изолирован от Django. Все настройки передаются через конструктор. + + Использование: + # Без прокси + client = IndustrialProductionClient() + + # Со списком прокси + client = IndustrialProductionClient(proxies=["http://proxy1:8080"]) + certificates = client.fetch_certificates() + + for cert in certificates: + print(cert.certificate_number, cert.organisation_name) + """ + + proxies: list[str] | None = None + host: str = DEFAULT_HOST + api_path: str = DEFAULT_API_PATH + doc_type: str = DEFAULT_DOC_TYPE + query: str = DEFAULT_QUERY + timeout: int = 120 + _http_client: BaseHTTPClient | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + """Инициализация HTTP клиента.""" + self._http_client = None + + @property + def http_client(self) -> BaseHTTPClient: + """Ленивая инициализация HTTP клиента.""" + if self._http_client is None: + self._http_client = BaseHTTPClient( + base_url=f"https://{self.host}", + proxies=self.proxies, + timeout=self.timeout, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0", + "Accept": "application/json", + }, + ) + return self._http_client + + def fetch_certificates(self) -> list[IndustrialCertificate]: + """ + Получить список сертификатов промышленного производства. + + Процесс: + 1. Запрос к API для получения списка файлов + 2. Поиск последнего файла по дате + 3. Скачивание и парсинг Excel файла + 4. Преобразование в список IndustrialCertificate + + Returns: + Список сертификатов + + Raises: + IndustrialProductionClientError: При ошибке получения данных + """ + logger.info("Fetching industrial production certificates") + + try: + # 1. Получить список файлов + files_data = self._fetch_files_list() + + # 2. Найти последний файл + file_url = self._get_latest_file_url(files_data) + if not file_url: + logger.warning("No files found matching pattern") + return [] + + # 3. Скачать и распарсить Excel + certificates = self._download_and_parse(file_url) + + logger.info("Fetched %d certificates", len(certificates)) + return certificates + + except HTTPClientError: + raise + except Exception as e: + logger.error("Error fetching certificates: %s", e) + raise IndustrialProductionClientError( + f"Failed to fetch certificates: {e}" + ) from e + + def _fetch_files_list(self) -> list[dict]: + """Получить список файлов с API.""" + params = { + "types[]": self.doc_type, + "fragment": self.query, + } + data = self.http_client.get_json(self.api_path, params=params) + + # Найти документ с файлами + for item in data.get("data", []): + if self.query in item.get("name", ""): + return item.get("files", []) + + return [] + + def _get_latest_file_url(self, files_data: list[dict]) -> str | None: + """Найти URL последнего файла по дате в имени.""" + if not files_data: + return None + + latest_file = None + latest_date = None + + for file_info in files_data: + name = file_info.get("name", "") + match = FILE_PATTERN.search(name) + if not match: + continue + + try: + file_date = datetime.strptime(match.group(1), "%Y%m%d") + if latest_date is None or file_date > latest_date: + latest_date = file_date + latest_file = file_info + except ValueError: + continue + + if latest_file: + url = latest_file.get("url", "") + logger.info( + "Latest file: %s (date: %s)", latest_file.get("name"), latest_date + ) + # URL может быть относительным + if url and not url.startswith("http"): + return f"https://{self.host}{url}" + return url + + return None + + def _download_and_parse(self, file_url: str) -> list[IndustrialCertificate]: + """Скачать Excel файл и распарсить его.""" + logger.info("Downloading Excel file: %s", file_url) + + content = self.http_client.download_file(file_url) + logger.info("Downloaded %d bytes", len(content)) + + excel_data = BytesIO(content) + wb = load_workbook(filename=excel_data, data_only=True) + ws = wb.active + + certificates = [] + # Пропускаем заголовок (первая строка) + # Колонки: Dateofcon, Numberofcon, Expirationdate, Document, Nameoforg, INN, OGRN + for row in ws.iter_rows(min_row=2, values_only=True): + if not row or not any(row): + continue + + cert = self._parse_row(row) + if cert: + certificates.append(cert) + + wb.close() + return certificates + + def _parse_row(self, row: tuple) -> IndustrialCertificate | None: + """Преобразовать строку Excel в IndustrialCertificate.""" + try: + # Порядок колонок в Excel: + # Dateofcon, Numberofcon, Expirationdate, Document, Nameoforg, INN, OGRN + return IndustrialCertificate( + issue_date=str(row[0] or ""), + certificate_number=str(row[1] or ""), + expiry_date=str(row[2] or ""), + certificate_file_url=str(row[3] or ""), + organisation_name=str(row[4] or ""), + inn=str(row[5] or ""), + ogrn=str(row[6] or ""), + ) + except (IndexError, TypeError) as e: + logger.warning("Failed to parse row: %s - %s", row, e) + return None + + def close(self) -> None: + """Закрыть клиент и освободить ресурсы.""" + if self._http_client is not None: + self._http_client.close() + self._http_client = None + + def __enter__(self) -> "IndustrialProductionClient": + """Поддержка context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Закрытие при выходе из context manager.""" + self.close() diff --git a/src/apps/parsers/clients/minpromtorg/manufactures.py b/src/apps/parsers/clients/minpromtorg/manufactures.py new file mode 100644 index 0000000..026fba8 --- /dev/null +++ b/src/apps/parsers/clients/minpromtorg/manufactures.py @@ -0,0 +1,218 @@ +""" +Клиент для парсинга реестра производителей Минпромторга. + +Источник: Минпромторг, реестр производителей. +""" + +import logging +import re +from dataclasses import dataclass, field +from datetime import datetime +from io import BytesIO + +from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError +from apps.parsers.clients.minpromtorg.schemas import Manufacturer +from openpyxl import load_workbook + +logger = logging.getLogger(__name__) + +# Конфигурация по умолчанию +DEFAULT_HOST = "minpromtorg.gov.ru" +DEFAULT_API_PATH = "/api/kss-document-preview" +DEFAULT_DOC_TYPE = "668d4f2a-966a-4b65-9fb9-2f1ad19a3d1f" +DEFAULT_QUERY = "Производители промышленной продукции" +FILE_PATTERN = re.compile(r"data_orgs_(\d{8})") + + +class ManufacturesClientError(HTTPClientError): + """Ошибка клиента реестра производителей.""" + + pass + + +@dataclass +class ManufacturesClient: + """ + Клиент для получения данных о производителях из реестра Минпромторга. + + Полностью изолирован от Django. Все настройки передаются через конструктор. + + Использование: + # Без прокси + client = ManufacturesClient() + + # Со списком прокси + client = ManufacturesClient(proxies=["http://proxy1:8080"]) + manufacturers = client.fetch_manufacturers() + + for m in manufacturers: + print(m.full_legal_name, m.inn) + """ + + proxies: list[str] | None = None + host: str = DEFAULT_HOST + api_path: str = DEFAULT_API_PATH + doc_type: str = DEFAULT_DOC_TYPE + query: str = DEFAULT_QUERY + timeout: int = 120 + _http_client: BaseHTTPClient | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + """Инициализация HTTP клиента.""" + self._http_client = None + + @property + def http_client(self) -> BaseHTTPClient: + """Ленивая инициализация HTTP клиента.""" + if self._http_client is None: + self._http_client = BaseHTTPClient( + base_url=f"https://{self.host}", + proxies=self.proxies, + timeout=self.timeout, + headers={ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0", + "Accept": "application/json", + }, + ) + return self._http_client + + def fetch_manufacturers(self) -> list[Manufacturer]: + """ + Получить список производителей из реестра. + + Процесс: + 1. Запрос к API для получения списка файлов + 2. Поиск последнего файла по дате + 3. Скачивание и парсинг Excel файла + 4. Преобразование в список Manufacturer + + Returns: + Список производителей + + Raises: + ManufacturesClientError: При ошибке получения данных + """ + logger.info("Fetching manufacturers registry") + + try: + # 1. Получить список файлов + files_data = self._fetch_files_list() + + # 2. Найти последний файл + file_url = self._get_latest_file_url(files_data) + if not file_url: + logger.warning("No files found matching pattern") + return [] + + # 3. Скачать и распарсить Excel + manufacturers = self._download_and_parse(file_url) + + logger.info("Fetched %d manufacturers", len(manufacturers)) + return manufacturers + + except HTTPClientError: + raise + except Exception as e: + logger.error("Error fetching manufacturers: %s", e) + raise ManufacturesClientError(f"Failed to fetch manufacturers: {e}") from e + + def _fetch_files_list(self) -> list[dict]: + """Получить список файлов с API.""" + params = { + "types[]": self.doc_type, + "fragment": self.query, + } + data = self.http_client.get_json(self.api_path, params=params) + + # Найти документ с файлами + for item in data.get("data", []): + if self.query in item.get("name", ""): + return item.get("files", []) + + return [] + + def _get_latest_file_url(self, files_data: list[dict]) -> str | None: + """Найти URL последнего файла по дате в имени.""" + if not files_data: + return None + + latest_file = None + latest_date = None + + for file_info in files_data: + name = file_info.get("name", "") + match = FILE_PATTERN.search(name) + if not match: + continue + + try: + file_date = datetime.strptime(match.group(1), "%Y%m%d") + if latest_date is None or file_date > latest_date: + latest_date = file_date + latest_file = file_info + except ValueError: + continue + + if latest_file: + url = latest_file.get("url", "") + logger.info( + "Latest file: %s (date: %s)", latest_file.get("name"), latest_date + ) + if url and not url.startswith("http"): + return f"https://{self.host}{url}" + return url + + return None + + def _download_and_parse(self, file_url: str) -> list[Manufacturer]: + """Скачать Excel файл и распарсить его.""" + logger.info("Downloading Excel file: %s", file_url) + + content = self.http_client.download_file(file_url) + logger.info("Downloaded %d bytes", len(content)) + + excel_data = BytesIO(content) + wb = load_workbook(filename=excel_data, data_only=True) + ws = wb.active + + manufacturers = [] + # Пропускаем заголовок (первая строка) + for row in ws.iter_rows(min_row=2, values_only=True): + if not row or not any(row): + continue + + manufacturer = self._parse_row(row) + if manufacturer: + manufacturers.append(manufacturer) + + wb.close() + return manufacturers + + def _parse_row(self, row: tuple) -> Manufacturer | None: + """Преобразовать строку Excel в Manufacturer.""" + try: + # Порядок колонок в Excel: + # full_legal_name, inn, ogrn, address + return Manufacturer( + full_legal_name=str(row[0] or ""), + inn=str(row[1] or ""), + ogrn=str(row[2] or ""), + address=str(row[3] or "") if len(row) > 3 else "", + ) + except (IndexError, TypeError) as e: + logger.warning("Failed to parse row: %s - %s", row, e) + return None + + def close(self) -> None: + """Закрыть клиент и освободить ресурсы.""" + if self._http_client is not None: + self._http_client.close() + self._http_client = None + + def __enter__(self) -> "ManufacturesClient": + """Поддержка context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Закрытие при выходе из context manager.""" + self.close() diff --git a/src/apps/parsers/clients/minpromtorg/schemas.py b/src/apps/parsers/clients/minpromtorg/schemas.py new file mode 100644 index 0000000..3c85ecd --- /dev/null +++ b/src/apps/parsers/clients/minpromtorg/schemas.py @@ -0,0 +1,59 @@ +""" +Dataclass схемы для данных Минпромторга. + +Эти классы представляют данные, возвращаемые клиентами. +Они не зависят от Django ORM и используются как DTO. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class IndustrialCertificate: + """ + Сертификат промышленного производства РФ. + + Источник: Минпромторг, раздел "Промышленное производство". + """ + + issue_date: str + """Дата выдачи сертификата.""" + + certificate_number: str + """Номер сертификата.""" + + expiry_date: str + """Дата окончания действия.""" + + certificate_file_url: str + """URL файла сертификата.""" + + organisation_name: str + """Наименование организации.""" + + inn: str + """ИНН организации.""" + + ogrn: str + """ОГРН организации.""" + + +@dataclass(frozen=True) +class Manufacturer: + """ + Производитель из реестра Минпромторга. + + Источник: Минпромторг, реестр производителей. + """ + + full_legal_name: str + """Полное наименование организации.""" + + inn: str + """ИНН организации.""" + + ogrn: str + """ОГРН организации.""" + + address: str + """Адрес организации.""" diff --git a/src/apps/parsers/clients/proverki/__init__.py b/src/apps/parsers/clients/proverki/__init__.py new file mode 100644 index 0000000..f6147d7 --- /dev/null +++ b/src/apps/parsers/clients/proverki/__init__.py @@ -0,0 +1,14 @@ +""" +Клиенты для proverki.gov.ru - Единый реестр проверок. + +Источник: ФГИС "Единый реестр проверок" (Генпрокуратура РФ). +""" + +from apps.parsers.clients.proverki.client import ProverkiClient +from apps.parsers.clients.proverki.schemas import Inspection, InspectionPlan + +__all__ = [ + "ProverkiClient", + "Inspection", + "InspectionPlan", +] diff --git a/src/apps/parsers/clients/proverki/client.py b/src/apps/parsers/clients/proverki/client.py new file mode 100644 index 0000000..f74ade2 --- /dev/null +++ b/src/apps/parsers/clients/proverki/client.py @@ -0,0 +1,1102 @@ +""" +Клиент для парсинга данных с proverki.gov.ru. + +Источник: ФГИС "Единый реестр проверок" (Генпрокуратура РФ). + +Поддерживает несколько стратегий получения данных: +1. Прямой доступ к Open Data файлам (XML) +2. API запросы (если доступны) +3. Playwright headless browser для динамического контента (fallback) +""" + +import io +import logging +import tempfile +import zipfile +from collections.abc import Callable +from dataclasses import dataclass, field +from xml.etree import ( # noqa: S314 - XML parsing with proper error handling + ElementTree as ET, +) + +from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError +from apps.parsers.clients.proverki.schemas import Inspection, InspectionPlan + +logger = logging.getLogger(__name__) + +# Конфигурация по умолчанию +DEFAULT_HOST = "proverki.gov.ru" +DEFAULT_OPEN_DATA_PATH = "/portal/public-open-data" + +# Паттерны URL для открытых данных +# Формат: https://proverki.gov.ru/opendata/{dataset_id}/data-{date}.zip +OPEN_DATA_BASE_URL = "https://proverki.gov.ru/opendata" + +# URL портала открытых данных (для Playwright) +OPEN_DATA_PORTAL_URL = "https://proverki.gov.ru/portal/public-open-data" + + +class ProverkiClientError(HTTPClientError): + """Ошибка клиента proverki.gov.ru.""" + + pass + + +@dataclass +class ProverkiClient: + """ + Клиент для получения данных о проверках с proverki.gov.ru. + + Полностью изолирован от Django. Все настройки передаются через конструктор. + + Стратегия работы: + 1. Пытается получить данные через прямые HTTP запросы к Open Data + 2. Если сайт требует JavaScript - использует Playwright + 3. Скачивает ZIP/XML архивы с данными + 4. Парсит XML и возвращает структурированные данные + + Использование: + client = ProverkiClient() + inspections = client.fetch_inspections(year=2025) + + for inspection in inspections: + print(inspection.registration_number, inspection.inn) + """ + + proxies: list[str] | None = None + host: str = DEFAULT_HOST + timeout: int = 120 + temp_dir: str | None = None + use_playwright: bool = True # Использовать Playwright как fallback + _http_client: BaseHTTPClient | None = field(default=None, repr=False) + _playwright: object | None = field(default=None, repr=False) + _browser: object | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + """Инициализация клиента.""" + self._http_client = None + self._playwright = None + self._browser = None + self._temp_dir = self.temp_dir or tempfile.gettempdir() + + @property + def http_client(self) -> BaseHTTPClient: + """Ленивая инициализация HTTP клиента.""" + if self._http_client is None: + self._http_client = BaseHTTPClient( + base_url=f"https://{self.host}", + proxies=self.proxies, + timeout=self.timeout, + headers={ + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "application/json, application/xml, text/html, */*", + "Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7", + }, + ) + return self._http_client + + def fetch_inspections( + self, + *, + year: int | None = None, + month: int | None = None, + file_url: str | None = None, + is_federal_law_248: bool = False, + progress_callback: Callable[[int, str], None] | None = None, + ) -> list[Inspection]: + """ + Получить список проверок. + + Args: + year: Год плана проверок (если не указан file_url) + month: Месяц (опционально) + file_url: Прямая ссылка на файл данных + is_federal_law_248: Запрос проверок по ФЗ-248 (новые проверки с 2021) + progress_callback: Callback для отчёта о прогрессе (percent, message) + + Returns: + Список проверок + + Raises: + ProverkiClientError: При ошибке получения данных + """ + fz_type = "ФЗ-248" if is_federal_law_248 else "ФЗ-294" + logger.info( + "Fetching inspections (year=%s, month=%s, %s)", year, month, fz_type + ) + + if progress_callback: + progress_callback(0, "Инициализация...") + + try: + # Если передан прямой URL - скачиваем его + if file_url: + return self._download_and_parse(file_url, progress_callback) + + # Иначе пытаемся найти файлы для указанного периода + plans = self._discover_data_files( + year=year, month=month, is_federal_law_248=is_federal_law_248 + ) + + if not plans: + logger.warning("No data files found for year=%s, month=%s", year, month) + return [] + + if progress_callback: + progress_callback(10, f"Найдено {len(plans)} файлов данных") + + # Скачиваем и парсим каждый файл + all_inspections = [] + for i, plan in enumerate(plans): + if progress_callback: + progress = 10 + (i * 80) // len(plans) + progress_callback(progress, f"Загрузка {plan.file_name}...") + + inspections = self._download_and_parse( + plan.file_url, None, file_format=plan.file_format + ) + all_inspections.extend(inspections) + logger.info( + "Parsed %d inspections from %s", len(inspections), plan.file_name + ) + + if progress_callback: + progress_callback(95, f"Загружено {len(all_inspections)} проверок") + + logger.info("Total fetched %d inspections", len(all_inspections)) + return all_inspections + + except HTTPClientError: + raise + except Exception as e: + logger.error("Error fetching inspections: %s", e) + raise ProverkiClientError(f"Failed to fetch inspections: {e}") from e + + def _discover_data_files( + self, + *, + year: int | None = None, + month: int | None = None, + is_federal_law_248: bool = False, + ) -> list[InspectionPlan]: + """ + Найти доступные файлы данных для указанного периода. + + URL структура портала proverki.gov.ru: + - Проверки по месяцам: /portal/public-open-data/check/{year}/{month} + - Планы проверок: /portal/public-open-data/check/{year}/plans + - Параметр isFederalLaw248=true для проверок по ФЗ-248 + """ + plans = [] + + if not year: + return plans + + base_url = "https://proverki.gov.ru/portal/public-open-data/check" + fz_param = "true" if is_federal_law_248 else "false" + fz_suffix = "fz248" if is_federal_law_248 else "fz294" + + # Если указан конкретный месяц - ищем проверки за этот месяц + if month: + portal_url = f"{base_url}/{year}/{month}?isFederalLaw248={fz_param}" + plans.append( + InspectionPlan( + year=year, + month=month, + file_url=portal_url, + file_name=f"inspection-{month}-{year}-{fz_suffix}.zip", + file_format="portal", + ) + ) + else: + # Без месяца - ищем план проверок на год + portal_url = f"{base_url}/{year}/plans?isFederalLaw248={fz_param}" + plans.append( + InspectionPlan( + year=year, + month=None, + file_url=portal_url, + file_name=f"plan-{year}-{fz_suffix}.zip", + file_format="portal", + ) + ) + + logger.info( + "Discovered %d data files for year=%s, month=%s, fz248=%s", + len(plans), + year, + month, + is_federal_law_248, + ) + return plans + + def _download_and_parse( # noqa: C901 + self, + file_url: str, + progress_callback: Callable[[int, str], None] | None = None, + file_format: str = "auto", + ) -> list[Inspection]: + """Скачать файл и распарсить его содержимое.""" + logger.info("Downloading: %s (format=%s)", file_url, file_format) + + # Если это портал - сразу используем Playwright + if file_format == "portal" or "/portal/" in file_url: + if not self.use_playwright: + raise ProverkiClientError( + "Портал proverki.gov.ru требует JavaScript. " + "Включите use_playwright=True.", + url=file_url, + ) + if progress_callback: + progress_callback(20, "Навигация по порталу...") + content = self._download_from_portal(file_url, progress_callback) + self._close_playwright() + else: + if progress_callback: + progress_callback(20, f"Скачивание {file_url}...") + + content = self.http_client.download_file(file_url) + logger.info("Downloaded %d bytes", len(content)) + + # Проверка на HTML ответ (сайт требует JavaScript) + if content[:15].lower().startswith((b" bytes: + """ + Скачать данные с портала proverki.gov.ru через Playwright. + + Навигация: + 1. Открыть страницу датасета + 2. Дождаться загрузки Angular/SPA контента + 3. Кликнуть на вкладку "Скачать" (download tab) + 4. Найти и кликнуть на "Набор данных" (ZIP) + 5. Скачать файл + + Args: + portal_url: URL страницы датасета на портале + progress_callback: Игнорируется (для совместимости) + + Returns: + Содержимое ZIP файла в байтах + """ + logger.info("Downloading from portal: %s", portal_url) + + browser = self._get_browser() + context = browser.new_context( + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + accept_downloads=True, + ) + page = context.new_page() + + try: + # Переходим на страницу датасета + logger.info("Navigating to dataset page: %s", portal_url) + page.goto(portal_url, wait_until="networkidle", timeout=60000) + + # Ждём загрузки SPA контента (Angular) - ищем признаки загруженной страницы + # На proverki.gov.ru используется Angular, контент загружается динамически + try: + # Ждём появления контента страницы (заголовок датасета или вкладки) + page.wait_for_selector( + "text=Паспорт, text=Скачать, h1, h2, .card, .dataset", + timeout=15000, + ) + except Exception: + logger.warning("Timeout waiting for SPA content, continuing anyway...") + + page.wait_for_timeout(3000) # Дополнительное ожидание для Angular + + # Debug: скриншот для анализа + page_content = page.content() + logger.info( + "Page loaded, content length: %d, title: %s", + len(page_content), + page.title(), + ) + + # ШАГ 1: Кликаем на вкладку "Скачать" для доступа к ссылкам на файлы + # Портал имеет две вкладки: "Паспорт" и "Скачать" + download_tab = page.query_selector( + "a:has-text('Скачать'):not([href*='.zip']), " + "button:has-text('Скачать'), " + "[role='tab']:has-text('Скачать'), " + ".tab:has-text('Скачать'), " + ".nav-link:has-text('Скачать'), " + "li:has-text('Скачать') a, " + "span:has-text('Скачать')" + ) + + if download_tab: + logger.info("Found 'Скачать' tab, clicking...") + download_tab.click() + page.wait_for_timeout(2000) # Ждём загрузки контента вкладки + else: + logger.warning("'Скачать' tab not found, trying to find links directly") + + # ШАГ 2: Ищем ссылку "Набор данных" (основная кнопка загрузки ZIP) + # URL формат: https://proverki.gov.ru/blob/opendata/{year}/{month}/data-*.zip + zip_link = page.query_selector( + "a:has-text('Набор данных'), " + "a[href*='/blob/opendata/'][href$='.zip'], " + "a[href*='data-'][href$='.zip']" + ) + + if not zip_link: + # Попробуем найти в таблице истории изменений + # На скриншоте видны файлы data-YYYYMMDD-structure-*.zip + zip_link = page.query_selector( + "table a[href$='.zip'], " "a[href*='structure'][href$='.zip']" + ) + + if not zip_link: + # Последняя попытка - любая ссылка на .zip + zip_link = page.query_selector("a[href$='.zip']") + + if zip_link: + href = zip_link.get_attribute("href") + logger.info("Found ZIP download link: %s", href) + + with page.expect_download(timeout=120000) as download_info: + zip_link.click() + + download = download_info.value + download_path = download.path() + + if download_path: + with open(download_path, "rb") as f: + content = f.read() + logger.info("Downloaded %d bytes from portal", len(content)) + return content + + # Если не нашли ZIP - пробуем XML (паспорт набора данных) + xml_link = page.query_selector( + "a:has-text('Паспорт набора данных'), " "a[href$='.xml']" + ) + + if xml_link: + logger.info("Found XML link, clicking...") + with page.expect_download(timeout=60000) as download_info: + xml_link.click() + + download = download_info.value + download_path = download.path() + + if download_path: + with open(download_path, "rb") as f: + content = f.read() + logger.info("Downloaded %d bytes (XML) from portal", len(content)) + return content + + # Debug: выводим что есть на странице + all_links = page.query_selector_all("a[href]") + logger.warning( + "No download links found. Page has %d links. Sample hrefs: %s", + len(all_links), + [link.get_attribute("href") for link in all_links[:10]], + ) + + # Проверяем, есть ли на странице сообщение об ошибке или отсутствии данных + if ( + "не найден" in page_content.lower() + or "not found" in page_content.lower() + ): + raise ProverkiClientError( + "Данные за указанный период не найдены на портале", + url=portal_url, + ) + + raise ProverkiClientError( + "Не удалось найти ссылку на скачивание данных на портале. " + "Убедитесь, что выбран корректный год/месяц.", + url=portal_url, + ) + + finally: + context.close() + + def _parse_zip_archive( + self, + content: bytes, + progress_callback: Callable[[int, str], None] | None = None, + ) -> list[Inspection]: + """Распаковать ZIP архив и распарсить XML файлы внутри.""" + inspections = [] + + with zipfile.ZipFile(io.BytesIO(content)) as zf: + xml_files = [ + name for name in zf.namelist() if name.lower().endswith(".xml") + ] + + if not xml_files: + logger.warning("No XML files found in ZIP archive") + return [] + + logger.info("Found %d XML files in archive", len(xml_files)) + + for i, xml_name in enumerate(xml_files): + if progress_callback: + progress = 30 + (i * 60) // len(xml_files) + progress_callback(progress, f"Парсинг {xml_name}...") + + xml_content = zf.read(xml_name) + file_inspections = self._parse_xml_content(xml_content, None) + inspections.extend(file_inspections) + + return inspections + + def _parse_xml_content( # noqa: C901 + self, + content: bytes, + progress_callback: Callable[[int, str], None] | None = None, + ) -> list[Inspection]: + """ + Распарсить XML содержимое файла проверок. + + Поддерживает различные XML форматы proverki.gov.ru, включая с namespaces. + Использует потоковый парсинг для больших файлов (>50 МБ). + """ + inspections = [] + + # Для больших файлов используем iterparse (потоковый парсинг) + if len(content) > 50 * 1024 * 1024: # > 50 MB + logger.info( + "Large file detected (%d MB), using streaming parser", + len(content) // (1024 * 1024), + ) + return self._parse_xml_streaming(content, progress_callback) + + try: + # Пробуем разные кодировки + for encoding in ["utf-8", "windows-1251", "cp1251"]: + try: + xml_str = content.decode(encoding) + break + except UnicodeDecodeError: + continue + else: + xml_str = content.decode("utf-8", errors="replace") + + # Очистка невалидных XML символов (часто встречается в госданных) + xml_str = self._sanitize_xml(xml_str) + + root = ET.fromstring(xml_str) # noqa: S314 + + # Определяем namespace из root tag + # Формат: {namespace}tagname + ns = {} + root_tag = root.tag + if root_tag.startswith("{"): + ns_uri = root_tag[1 : root_tag.index("}")] + ns["ns"] = ns_uri + logger.info("Detected XML namespace: %s", ns_uri) + + # Ищем записи о проверках по разным возможным тегам + # Сначала с namespace, затем без + inspection_tags = [ + ".//ns:INSPECTION" if ns else None, + ".//ns:inspection" if ns else None, + ".//ns:check" if ns else None, + ".//ns:КНМ" if ns else None, + ".//inspection", + ".//check", + ".//proverka", + ".//КНМ", # Контрольно-надзорное мероприятие + ".//INSPECTION", + ".//record", + ".//item", + ] + + records = [] + for tag in inspection_tags: + if tag is None: + continue + try: + if ns and tag.startswith(".//ns:"): + found = root.findall(tag, ns) + else: + found = root.findall(tag) + if found: + records = found + logger.info("Found %d records with tag %s", len(found), tag) + break + except Exception as e: + logger.debug("Tag %s search failed: %s", tag, e) + continue + + # Если не нашли по тегам, берём все дочерние элементы корня + if not records: + records = list(root) + logger.debug("Using %d root children as records", len(records)) + + # Debug: показываем структуру XML + logger.info( + "XML structure: root=%s, children=%d, first_child=%s", + root.tag, + len(list(root)), + list(root)[0].tag if list(root) else "none", + ) + + # Если первый child - тоже контейнер, используем его детей + if records and len(records) == 1: + first_child = records[0] + child_tag = ( + first_child.tag.split("}")[-1].upper() + if "}" in first_child.tag + else first_child.tag.upper() + ) + if child_tag in ("INSPECTION", "CHECK", "КНМ", "RECORD"): + # Это и есть запись, используем её + pass + else: + # Это контейнер, берём его детей + records = list(first_child) + logger.info( + "Using %d children of first element as records", len(records) + ) + + for record in records: + inspection = self._parse_xml_record(record) + if inspection: + inspections.append(inspection) + + except ET.ParseError as e: + logger.error("XML parse error: %s", e) + raise ProverkiClientError(f"Failed to parse XML: {e}") from e + + return inspections + + def _parse_xml_streaming( + self, + content: bytes, + progress_callback: Callable[[int, str], None] | None = None, + ) -> list[Inspection]: + """ + Потоковый парсинг большого XML файла. + + Использует iterparse для обработки файла по элементам, + не загружая весь файл в память. + """ + inspections = [] + + # Декодируем и создаём поток + for encoding in ["utf-8", "windows-1251", "cp1251"]: + try: + xml_str = content.decode(encoding) + break + except UnicodeDecodeError: + continue + else: + xml_str = content.decode("utf-8", errors="replace") + + xml_str = self._sanitize_xml(xml_str) + + # Используем iterparse для потоковой обработки + import io + + xml_stream = io.StringIO(xml_str) + + # Определяем теги, которые нас интересуют + target_tags = {"INSPECTION", "inspection", "check", "КНМ"} + + count = 0 + try: + for _event, elem in ET.iterparse(xml_stream, events=["end"]): # noqa: S314 + # Извлекаем имя тега без namespace + tag_name = elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + + if tag_name in target_tags: + inspection = self._parse_xml_record(elem) + if inspection: + inspections.append(inspection) + count += 1 + + if count % 10000 == 0: + logger.info("Streaming parsed %d inspections...", count) + + # Очищаем элемент для освобождения памяти + elem.clear() + + except ET.ParseError as e: + logger.error("XML streaming parse error at %d records: %s", count, e) + if inspections: + logger.info( + "Returning %d successfully parsed records", len(inspections) + ) + else: + raise ProverkiClientError(f"Failed to parse XML: {e}") from e + + logger.info("Streaming parsing complete: %d inspections", len(inspections)) + return inspections + + def _sanitize_xml(self, xml_str: str) -> str: + """ + Очистить XML строку от невалидных символов. + + Госданные часто содержат управляющие символы, которые не допускаются в XML: + - Символы 0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F (кроме tab, newline, cr) + - Некорректные символы в значениях атрибутов + + Args: + xml_str: Исходная XML строка + + Returns: + Очищенная XML строка + """ + import re + + # Удаляем недопустимые XML символы (control characters кроме tab, newline, cr) + # XML 1.0 допускает: #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] + illegal_xml_chars_re = re.compile( + r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x84\x86-\x9f]" + ) + xml_str = illegal_xml_chars_re.sub("", xml_str) + + # Заменяем неэкранированные амперсанды (частая ошибка в госданных) + # Но не трогаем уже экранированные сущности + xml_str = re.sub( + r"&(?!(?:amp|lt|gt|apos|quot|#\d+|#x[0-9a-fA-F]+);)", "&", xml_str + ) + + return xml_str + + def _parse_xml_record(self, element: ET.Element) -> Inspection | None: # noqa: C901 + """ + Преобразовать XML элемент в объект Inspection. + + Адаптируется к различным форматам XML proverki.gov.ru: + - Данные могут быть в атрибутах элементов + - Данные могут быть во вложенных элементах (I_SUBJECT, I_AUTHORITY) + """ + try: + # Определяем namespace элемента + ns_uri = None + if element.tag.startswith("{"): + ns_uri = element.tag[1 : element.tag.index("}")] + + def find_child(tag_name: str) -> ET.Element | None: + """Найти дочерний элемент с учётом namespace.""" + if ns_uri: + child = element.find(f"{{{ns_uri}}}{tag_name}") + if child is not None: + return child + return element.find(tag_name) + + # Получаем вложенные элементы, где могут быть данные + i_subject = find_child("I_SUBJECT") # Данные об организации + i_authority = find_child("I_AUTHORITY") # Контролирующий орган + i_approve = find_child("I_APPROVE") # Информация об утверждении + i_classification = find_child("I_CLASSIFICATION") # Классификация + + def get_attr_value(attr_names: list[str]) -> str: # noqa: C901 + """Найти значение атрибута в элементе или вложенных элементах.""" + # Сначала ищем в атрибутах самого элемента INSPECTION + for name in attr_names: + if name in element.attrib: + return element.attrib[name].strip() + + # Ищем в I_SUBJECT (ORG_NAME, INN, OGRN) + if i_subject is not None: + for name in attr_names: + if name in i_subject.attrib: + return i_subject.attrib[name].strip() + + # Ищем в I_AUTHORITY (FRGU_ORG_NAME) + if i_authority is not None: + for name in attr_names: + if name in i_authority.attrib: + return i_authority.attrib[name].strip() + + # Ищем в I_CLASSIFICATION + if i_classification is not None: + for name in attr_names: + if name in i_classification.attrib: + return i_classification.attrib[name].strip() + + # Ищем в I_APPROVE + if i_approve is not None: + for name in attr_names: + if name in i_approve.attrib: + return i_approve.attrib[name].strip() + + # Fallback: ищем в дочерних элементах (текст) + for name in attr_names: + if ns_uri: + child = element.find(f"{{{ns_uri}}}{name}") + else: + child = element.find(name) + if child is not None and child.text: + return child.text.strip() + + return "" + + # Маппинг атрибутов на поля Inspection + # Используем названия из реального XML proverki.gov.ru + registration_number = get_attr_value( + [ + "ERPID", + "I_NUMBER", + "FRGU_NUM", + "registration_number", + "regnum", + "id", + "number", + ] + ) + + inn = get_attr_value(["INN", "inn", "ORG_INN", "I_INN"]) + ogrn = get_attr_value(["OGRN", "ogrn", "ORG_OGRN", "I_OGRN"]) + organisation_name = get_attr_value( + [ + "ORG_NAME", + "FULL_NAME", + "SHORT_NAME", + "I_NAME", + "organisation_name", + "org_name", + "name", + ] + ) + control_authority = get_attr_value( + [ + "FRGU_ORG_NAME", + "PROSEC_NAME", + "KNO_NAME", + "ORGAN_NAME", + "control_authority", + "authority", + ] + ) + inspection_type = get_attr_value( + [ + "ITYPE_NAME", + "TYPE_NAME", + "I_TYPE", + "inspection_type", + "type", + ] + ) + inspection_form = get_attr_value( + [ + "ICARRYOUT_TYPE_NAME", + "FORM_NAME", + "I_FORM", + "inspection_form", + "form", + ] + ) + start_date = get_attr_value( + [ + "START_DATE", + "I_DATE_START", + "DATE_START", + "start_date", + "date_start", + "date", + ] + ) + end_date = get_attr_value( + [ + "END_DATE", + "I_DATE_END", + "DATE_END", + "end_date", + "date_end", + ] + ) + status = get_attr_value( + [ + "STATUS", + "I_STATUS", + "status", + "state", + ] + ) + legal_basis = get_attr_value( + [ + "FZ_NAME", + "IREASON_NAME", + "I_REASON", + "REASON", + "legal_basis", + "basis", + "law", + ] + ) + result = get_attr_value( + [ + "RESULT", + "I_RESULT", + "result", + "outcome", + ] + ) + + inspection = Inspection( + registration_number=registration_number, + inn=inn, + ogrn=ogrn, + organisation_name=organisation_name, + control_authority=control_authority, + inspection_type=inspection_type, + inspection_form=inspection_form, + start_date=start_date, + end_date=end_date, + status=status, + legal_basis=legal_basis, + result=result, + ) + + # Проверяем что хотя бы базовые поля заполнены + if not inspection.inn and not inspection.registration_number: + # Debug: выводим структуру элемента + logger.debug( + "Empty inspection from element %s, attribs: %s", + element.tag, + list(element.attrib.keys())[:5], + ) + return None + + return inspection + + except Exception as e: + logger.warning("Failed to parse XML record: %s", e) + return None + + def fetch_inspection_plans(self, year: int) -> list[InspectionPlan]: + """ + Получить список доступных планов проверок за год. + + Args: + year: Год + + Returns: + Список InspectionPlan с метаданными о файлах + """ + return self._discover_data_files(year=year) + + def _get_browser(self): + """Ленивая инициализация Playwright browser.""" + if self._browser is None: + try: + from playwright.sync_api import sync_playwright + + self._playwright = sync_playwright().start() + self._browser = self._playwright.chromium.launch( + headless=True, + args=[ + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + ], + ) + logger.info("Playwright browser initialized") + except ImportError as e: + raise ProverkiClientError( + "Playwright не установлен. Установите: uv add playwright && " + "playwright install chromium" + ) from e + except Exception as e: + raise ProverkiClientError( + f"Не удалось запустить Playwright browser: {e}" + ) from e + return self._browser + + def _download_with_playwright( + self, + url: str, + progress_callback: Callable[[int, str], None] | None = None, + ) -> bytes: + """ + Скачать данные через Playwright (для сайтов с JavaScript). + + Примечание: progress_callback не используется внутри этого метода, + так как Playwright работает в асинхронном контексте, который конфликтует + с Django ORM. + + Args: + url: URL для загрузки + progress_callback: Игнорируется (для совместимости интерфейса) + + Returns: + Содержимое файла в байтах + """ + logger.info("Using Playwright to fetch: %s", url) + + browser = self._get_browser() + context = browser.new_context( + user_agent=( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" + ), + accept_downloads=True, + ) + page = context.new_page() + + try: + # Сначала пробуем прямой переход на URL (с рендерингом JS) + logger.info("Trying direct URL with JS rendering: %s", url) + response = page.goto(url, wait_until="networkidle", timeout=60000) + + # Проверяем, получили ли мы данные напрямую + content_type = response.headers.get("content-type", "") if response else "" + if ( + "xml" in content_type + or "zip" in content_type + or "octet-stream" in content_type + ): + # Получили файл напрямую + body = page.content() + if body and not body.strip().startswith(" None: + """Закрыть Playwright и освободить event loop.""" + if self._browser is not None: + try: + self._browser.close() + except Exception as e: + logger.warning("Error closing browser: %s", e) + self._browser = None + + if self._playwright is not None: + try: + self._playwright.stop() + except Exception as e: + logger.warning("Error stopping Playwright: %s", e) + self._playwright = None + + def close(self) -> None: + """Закрыть клиент и освободить ресурсы.""" + if self._http_client is not None: + self._http_client.close() + self._http_client = None + + self._close_playwright() + + def __enter__(self) -> "ProverkiClient": + """Поддержка context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Закрытие при выходе из context manager.""" + self.close() diff --git a/src/apps/parsers/clients/proverki/schemas.py b/src/apps/parsers/clients/proverki/schemas.py new file mode 100644 index 0000000..48c85d6 --- /dev/null +++ b/src/apps/parsers/clients/proverki/schemas.py @@ -0,0 +1,90 @@ +""" +Dataclass схемы для данных proverki.gov.ru. + +Эти классы представляют данные о проверках, возвращаемые клиентом. +Они не зависят от Django ORM и используются как DTO. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Inspection: + """ + Проверка из Единого реестра проверок. + + Источник: ФГИС "Единый реестр проверок" (proverki.gov.ru). + + Содержит данные о проведённых или запланированных проверках + юридических лиц и индивидуальных предпринимателей. + + Поддерживает два типа проверок: + - ФЗ-294 (традиционные проверки) + - ФЗ-248 (новые проверки с 2021 года) + """ + + registration_number: str + """Учётный номер проверки в реестре.""" + + inn: str + """ИНН проверяемого лица.""" + + ogrn: str + """ОГРН проверяемого лица.""" + + organisation_name: str + """Наименование проверяемого лица.""" + + control_authority: str + """Наименование контрольного (надзорного) органа.""" + + inspection_type: str + """Тип проверки (плановая/внеплановая).""" + + inspection_form: str + """Форма проверки (документарная/выездная).""" + + start_date: str + """Дата начала проверки (строка формата YYYY-MM-DD или DD.MM.YYYY).""" + + end_date: str + """Дата окончания проверки.""" + + status: str + """Статус проверки.""" + + legal_basis: str + """Правовое основание проверки (ФЗ-294, ФЗ-248 и т.д.).""" + + result: str = "" + """Результат проверки (если завершена).""" + + is_federal_law_248: bool = False + """Признак проверки по ФЗ-248 (новые проверки с 2021 года).""" + + +@dataclass(frozen=True) +class InspectionPlan: + """ + План проверок на определённый период. + + Содержит метаданные о плане проверок (год, период, количество записей). + """ + + year: int + """Год плана проверок.""" + + month: int | None + """Месяц (если план помесячный, иначе None).""" + + file_url: str + """URL файла с данными плана.""" + + file_name: str + """Имя файла.""" + + records_count: int = 0 + """Количество записей в плане (если известно).""" + + file_format: str = "xml" + """Формат файла (xml, csv, xlsx).""" diff --git a/src/apps/parsers/migrations/0001_initial_parsers.py b/src/apps/parsers/migrations/0001_initial_parsers.py new file mode 100644 index 0000000..9e84a7f --- /dev/null +++ b/src/apps/parsers/migrations/0001_initial_parsers.py @@ -0,0 +1,90 @@ +# Generated by Django 3.2.25 on 2026-01-21 16:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='IndustrialCertificateRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')), + ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='ID пакета загрузки')), + ('issue_date', models.CharField(blank=True, help_text='Дата выдачи сертификата', max_length=15, verbose_name='дата выдачи')), + ('certificate_number', models.CharField(db_index=True, help_text='Номер сертификата', max_length=100, verbose_name='номер сертификата')), + ('expiry_date', models.CharField(blank=True, help_text='Дата окончания действия', max_length=15, verbose_name='дата окончания')), + ('certificate_file_url', models.TextField(blank=True, help_text='Ссылка на файл сертификата', verbose_name='URL файла')), + ('organisation_name', models.TextField(help_text='Название организации', verbose_name='наименование организации')), + ('inn', models.CharField(db_index=True, help_text='ИНН организации', max_length=20, verbose_name='ИНН')), + ('ogrn', models.CharField(db_index=True, help_text='ОГРН организации', max_length=20, verbose_name='ОГРН')), + ], + options={ + 'verbose_name': 'сертификат промпроизводства', + 'verbose_name_plural': 'сертификаты промпроизводства', + 'db_table': 'parsers_industrial_certificate', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ManufacturerRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')), + ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='ID пакета загрузки')), + ('full_legal_name', models.TextField(help_text='Полное юридическое наименование организации', verbose_name='полное наименование')), + ('inn', models.CharField(db_index=True, help_text='ИНН организации', max_length=15, verbose_name='ИНН')), + ('ogrn', models.CharField(db_index=True, help_text='ОГРН организации', max_length=15, verbose_name='ОГРН')), + ('address', models.TextField(blank=True, help_text='Юридический адрес организации', verbose_name='адрес')), + ], + options={ + 'verbose_name': 'производитель', + 'verbose_name_plural': 'производители', + 'db_table': 'parsers_manufacturer', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ParserLoadLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')), + ('batch_id', models.PositiveIntegerField(db_index=True, help_text='Уникальный идентификатор пакета загрузки', verbose_name='ID пакета')), + ('source', models.CharField(choices=[('industrial', 'Промышленное производство'), ('manufactures', 'Реестр производителей')], db_index=True, help_text='Источник данных', max_length=50, verbose_name='источник')), + ('records_count', models.PositiveIntegerField(default=0, help_text='Количество загруженных записей', verbose_name='количество записей')), + ('status', models.CharField(default='success', help_text='Статус загрузки', max_length=20, verbose_name='статус')), + ('error_message', models.TextField(blank=True, help_text='Текст ошибки при неудачной загрузке', verbose_name='сообщение об ошибке')), + ], + options={ + 'verbose_name': 'лог загрузки', + 'verbose_name_plural': 'логи загрузок', + 'db_table': 'parsers_load_log', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='parserloadlog', + index=models.Index(fields=['source', 'batch_id'], name='parsers_loa_source_f175af_idx'), + ), + migrations.AddIndex( + model_name='manufacturerrecord', + index=models.Index(fields=['load_batch', 'inn'], name='parsers_man_load_ba_5a660e_idx'), + ), + migrations.AddIndex( + model_name='industrialcertificaterecord', + index=models.Index(fields=['inn', 'certificate_number'], name='parsers_ind_inn_6b7f8d_idx'), + ), + migrations.AddIndex( + model_name='industrialcertificaterecord', + index=models.Index(fields=['load_batch', 'inn'], name='parsers_ind_load_ba_6e497e_idx'), + ), + ] diff --git a/src/apps/parsers/migrations/0002_add_proxy_model.py b/src/apps/parsers/migrations/0002_add_proxy_model.py new file mode 100644 index 0000000..19b08a3 --- /dev/null +++ b/src/apps/parsers/migrations/0002_add_proxy_model.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.25 on 2026-01-21 16:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parsers', '0001_initial_parsers'), + ] + + operations = [ + migrations.CreateModel( + name='Proxy', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')), + ('address', models.CharField(help_text='Адрес прокси (например: http://proxy:8080)', max_length=255, unique=True, verbose_name='адрес')), + ('is_active', models.BooleanField(db_index=True, default=True, help_text='Доступен ли прокси для использования', verbose_name='активен')), + ('last_used_at', models.DateTimeField(blank=True, help_text='Время последнего использования', null=True, verbose_name='последнее использование')), + ('fail_count', models.PositiveIntegerField(default=0, help_text='Количество неудачных попыток подключения', verbose_name='количество ошибок')), + ('description', models.CharField(blank=True, help_text='Описание прокси (провайдер, локация и т.д.)', max_length=255, verbose_name='описание')), + ], + options={ + 'verbose_name': 'прокси', + 'verbose_name_plural': 'прокси', + 'db_table': 'parsers_proxy', + 'ordering': ['fail_count', '-last_used_at'], + }, + ), + ] diff --git a/src/apps/parsers/migrations/0003_add_inspection_model.py b/src/apps/parsers/migrations/0003_add_inspection_model.py new file mode 100644 index 0000000..0b95d0f --- /dev/null +++ b/src/apps/parsers/migrations/0003_add_inspection_model.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.25 on 2026-01-21 17:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parsers', '0002_add_proxy_model'), + ] + + operations = [ + migrations.CreateModel( + name='InspectionRecord', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')), + ('load_batch', models.PositiveIntegerField(db_index=True, help_text='Идентификатор пакета загрузки', verbose_name='ID пакета загрузки')), + ('registration_number', models.CharField(db_index=True, help_text='Учётный номер проверки в реестре', max_length=100, verbose_name='учётный номер')), + ('inn', models.CharField(db_index=True, help_text='ИНН проверяемого лица', max_length=20, verbose_name='ИНН')), + ('ogrn', models.CharField(blank=True, db_index=True, help_text='ОГРН проверяемого лица', max_length=20, verbose_name='ОГРН')), + ('organisation_name', models.TextField(help_text='Наименование проверяемого лица', verbose_name='наименование организации')), + ('control_authority', models.TextField(blank=True, help_text='Наименование контрольного (надзорного) органа', verbose_name='контрольный орган')), + ('inspection_type', models.CharField(blank=True, help_text='Тип проверки (плановая/внеплановая)', max_length=100, verbose_name='тип проверки')), + ('inspection_form', models.CharField(blank=True, help_text='Форма проверки (документарная/выездная)', max_length=100, verbose_name='форма проверки')), + ('start_date', models.CharField(blank=True, help_text='Дата начала проверки', max_length=20, verbose_name='дата начала')), + ('end_date', models.CharField(blank=True, help_text='Дата окончания проверки', max_length=20, verbose_name='дата окончания')), + ('status', models.CharField(blank=True, help_text='Статус проверки', max_length=100, verbose_name='статус')), + ('legal_basis', models.CharField(blank=True, help_text='Правовое основание проверки (ФЗ-294, ФЗ-248)', max_length=255, verbose_name='правовое основание')), + ('result', models.TextField(blank=True, help_text='Результат проверки', verbose_name='результат')), + ], + options={ + 'verbose_name': 'проверка', + 'verbose_name_plural': 'проверки', + 'db_table': 'parsers_inspection', + 'ordering': ['-created_at'], + }, + ), + migrations.AlterField( + model_name='parserloadlog', + name='source', + field=models.CharField(choices=[('industrial', 'Промышленное производство'), ('manufactures', 'Реестр производителей'), ('inspections', 'Единый реестр проверок')], db_index=True, help_text='Источник данных', max_length=50, verbose_name='источник'), + ), + migrations.AddIndex( + model_name='inspectionrecord', + index=models.Index(fields=['inn', 'registration_number'], name='parsers_ins_inn_0d75e5_idx'), + ), + migrations.AddIndex( + model_name='inspectionrecord', + index=models.Index(fields=['load_batch', 'inn'], name='parsers_ins_load_ba_45a131_idx'), + ), + ] diff --git a/src/apps/parsers/migrations/0004_add_unique_constraints.py b/src/apps/parsers/migrations/0004_add_unique_constraints.py new file mode 100644 index 0000000..6749449 --- /dev/null +++ b/src/apps/parsers/migrations/0004_add_unique_constraints.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.25 on 2026-01-21 17:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parsers', '0003_add_inspection_model'), + ] + + operations = [ + migrations.AddConstraint( + model_name='industrialcertificaterecord', + constraint=models.UniqueConstraint(fields=('certificate_number',), name='unique_certificate_number'), + ), + migrations.AddConstraint( + model_name='inspectionrecord', + constraint=models.UniqueConstraint(fields=('registration_number',), name='unique_inspection_registration_number'), + ), + migrations.AddConstraint( + model_name='manufacturerrecord', + constraint=models.UniqueConstraint(fields=('inn',), name='unique_manufacturer_inn'), + ), + ] diff --git a/src/apps/parsers/migrations/0005_add_inspection_fz248_fields.py b/src/apps/parsers/migrations/0005_add_inspection_fz248_fields.py new file mode 100644 index 0000000..d3fbc12 --- /dev/null +++ b/src/apps/parsers/migrations/0005_add_inspection_fz248_fields.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.25 on 2026-01-21 19:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('parsers', '0004_add_unique_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='inspectionrecord', + name='data_month', + field=models.PositiveSmallIntegerField(blank=True, db_index=True, help_text='Месяц, за который загружены данные', null=True, verbose_name='месяц данных'), + ), + migrations.AddField( + model_name='inspectionrecord', + name='data_year', + field=models.PositiveSmallIntegerField(blank=True, db_index=True, help_text='Год, за который загружены данные', null=True, verbose_name='год данных'), + ), + migrations.AddField( + model_name='inspectionrecord', + name='is_federal_law_248', + field=models.BooleanField(db_index=True, default=False, help_text='Проверка по ФЗ-248 (новые проверки с 2021 года)', verbose_name='по ФЗ-248'), + ), + migrations.AddIndex( + model_name='inspectionrecord', + index=models.Index(fields=['is_federal_law_248', 'data_year', 'data_month'], name='parsers_ins_is_fede_e271e9_idx'), + ), + ] diff --git a/src/apps/parsers/migrations/__init__.py b/src/apps/parsers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/parsers/models.py b/src/apps/parsers/models.py new file mode 100644 index 0000000..0cb8c33 --- /dev/null +++ b/src/apps/parsers/models.py @@ -0,0 +1,363 @@ +""" +Модели для приложения парсеров. + +Используют миксины из apps.core для стандартных полей и поведения. +""" + +from apps.core.mixins import TimestampMixin +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ParserLoadLog(TimestampMixin, models.Model): + """ + Лог загрузок парсеров. + + Хранит информацию о каждой загрузке данных из внешнего источника. + """ + + class Source(models.TextChoices): + INDUSTRIAL = "industrial", _("Промышленное производство") + MANUFACTURES = "manufactures", _("Реестр производителей") + INSPECTIONS = "inspections", _("Единый реестр проверок") + + batch_id = models.PositiveIntegerField( + _("ID пакета"), + db_index=True, + help_text=_("Уникальный идентификатор пакета загрузки"), + ) + source = models.CharField( + _("источник"), + max_length=50, + choices=Source.choices, + db_index=True, + help_text=_("Источник данных"), + ) + records_count = models.PositiveIntegerField( + _("количество записей"), + default=0, + help_text=_("Количество загруженных записей"), + ) + status = models.CharField( + _("статус"), + max_length=20, + default="success", + help_text=_("Статус загрузки"), + ) + error_message = models.TextField( + _("сообщение об ошибке"), + blank=True, + help_text=_("Текст ошибки при неудачной загрузке"), + ) + + class Meta: + db_table = "parsers_load_log" + verbose_name = _("лог загрузки") + verbose_name_plural = _("логи загрузок") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["source", "batch_id"]), + ] + + def __str__(self) -> str: + return f"Load #{self.batch_id} ({self.source}) - {self.records_count} records" + + +class IndustrialCertificateRecord(TimestampMixin, models.Model): + """ + Сертификат промышленного производства РФ. + + Данные загружаются из Минпромторга. + """ + + load_batch = models.PositiveIntegerField( + _("ID пакета загрузки"), + db_index=True, + help_text=_("Идентификатор пакета загрузки"), + ) + issue_date = models.CharField( + _("дата выдачи"), + max_length=15, + blank=True, + help_text=_("Дата выдачи сертификата"), + ) + certificate_number = models.CharField( + _("номер сертификата"), + max_length=100, + db_index=True, + help_text=_("Номер сертификата"), + ) + expiry_date = models.CharField( + _("дата окончания"), + max_length=15, + blank=True, + help_text=_("Дата окончания действия"), + ) + certificate_file_url = models.TextField( + _("URL файла"), + blank=True, + help_text=_("Ссылка на файл сертификата"), + ) + organisation_name = models.TextField( + _("наименование организации"), + help_text=_("Название организации"), + ) + inn = models.CharField( + _("ИНН"), + max_length=20, + db_index=True, + help_text=_("ИНН организации"), + ) + ogrn = models.CharField( + _("ОГРН"), + max_length=20, + db_index=True, + help_text=_("ОГРН организации"), + ) + + class Meta: + db_table = "parsers_industrial_certificate" + verbose_name = _("сертификат промпроизводства") + verbose_name_plural = _("сертификаты промпроизводства") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["inn", "certificate_number"]), + models.Index(fields=["load_batch", "inn"]), + ] + constraints = [ + models.UniqueConstraint( + fields=["certificate_number"], + name="unique_certificate_number", + ), + ] + + def __str__(self) -> str: + return f"{self.certificate_number} - {self.organisation_name[:50]}" + + +class ManufacturerRecord(TimestampMixin, models.Model): + """ + Производитель из реестра Минпромторга. + + Данные загружаются из Минпромторга. + """ + + load_batch = models.PositiveIntegerField( + _("ID пакета загрузки"), + db_index=True, + help_text=_("Идентификатор пакета загрузки"), + ) + full_legal_name = models.TextField( + _("полное наименование"), + help_text=_("Полное юридическое наименование организации"), + ) + inn = models.CharField( + _("ИНН"), + max_length=15, + db_index=True, + help_text=_("ИНН организации"), + ) + ogrn = models.CharField( + _("ОГРН"), + max_length=15, + db_index=True, + help_text=_("ОГРН организации"), + ) + address = models.TextField( + _("адрес"), + blank=True, + help_text=_("Юридический адрес организации"), + ) + + class Meta: + db_table = "parsers_manufacturer" + verbose_name = _("производитель") + verbose_name_plural = _("производители") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["load_batch", "inn"]), + ] + constraints = [ + models.UniqueConstraint( + fields=["inn"], + name="unique_manufacturer_inn", + ), + ] + + def __str__(self) -> str: + return f"{self.inn} - {self.full_legal_name[:50]}" + + +class Proxy(TimestampMixin, models.Model): + """ + Прокси-сервер для парсеров. + + Хранит список доступных прокси для использования в клиентах. + """ + + address = models.CharField( + _("адрес"), + max_length=255, + unique=True, + help_text=_("Адрес прокси (например: http://proxy:8080)"), + ) + is_active = models.BooleanField( + _("активен"), + default=True, + db_index=True, + help_text=_("Доступен ли прокси для использования"), + ) + last_used_at = models.DateTimeField( + _("последнее использование"), + null=True, + blank=True, + help_text=_("Время последнего использования"), + ) + fail_count = models.PositiveIntegerField( + _("количество ошибок"), + default=0, + help_text=_("Количество неудачных попыток подключения"), + ) + description = models.CharField( + _("описание"), + max_length=255, + blank=True, + help_text=_("Описание прокси (провайдер, локация и т.д.)"), + ) + + class Meta: + db_table = "parsers_proxy" + verbose_name = _("прокси") + verbose_name_plural = _("прокси") + ordering = ["fail_count", "-last_used_at"] + + def __str__(self) -> str: + status = "active" if self.is_active else "inactive" + return f"{self.address} ({status})" + + +class InspectionRecord(TimestampMixin, models.Model): + """ + Проверка из Единого реестра проверок (proverki.gov.ru). + + Данные загружаются из ФГИС "Единый реестр проверок" (Генпрокуратура РФ). + Поддерживает два типа проверок: + - ФЗ-294 (традиционные проверки) + - ФЗ-248 (новые проверки с 2021 года) + """ + + load_batch = models.PositiveIntegerField( + _("ID пакета загрузки"), + db_index=True, + help_text=_("Идентификатор пакета загрузки"), + ) + registration_number = models.CharField( + _("учётный номер"), + max_length=100, + db_index=True, + help_text=_("Учётный номер проверки в реестре"), + ) + inn = models.CharField( + _("ИНН"), + max_length=20, + db_index=True, + help_text=_("ИНН проверяемого лица"), + ) + ogrn = models.CharField( + _("ОГРН"), + max_length=20, + db_index=True, + blank=True, + help_text=_("ОГРН проверяемого лица"), + ) + organisation_name = models.TextField( + _("наименование организации"), + help_text=_("Наименование проверяемого лица"), + ) + control_authority = models.TextField( + _("контрольный орган"), + blank=True, + help_text=_("Наименование контрольного (надзорного) органа"), + ) + inspection_type = models.CharField( + _("тип проверки"), + max_length=100, + blank=True, + help_text=_("Тип проверки (плановая/внеплановая)"), + ) + inspection_form = models.CharField( + _("форма проверки"), + max_length=100, + blank=True, + help_text=_("Форма проверки (документарная/выездная)"), + ) + start_date = models.CharField( + _("дата начала"), + max_length=20, + blank=True, + help_text=_("Дата начала проверки"), + ) + end_date = models.CharField( + _("дата окончания"), + max_length=20, + blank=True, + help_text=_("Дата окончания проверки"), + ) + status = models.CharField( + _("статус"), + max_length=100, + blank=True, + help_text=_("Статус проверки"), + ) + legal_basis = models.CharField( + _("правовое основание"), + max_length=255, + blank=True, + help_text=_("Правовое основание проверки (ФЗ-294, ФЗ-248)"), + ) + result = models.TextField( + _("результат"), + blank=True, + help_text=_("Результат проверки"), + ) + is_federal_law_248 = models.BooleanField( + _("по ФЗ-248"), + default=False, + db_index=True, + help_text=_("Проверка по ФЗ-248 (новые проверки с 2021 года)"), + ) + data_year = models.PositiveSmallIntegerField( + _("год данных"), + db_index=True, + null=True, + blank=True, + help_text=_("Год, за который загружены данные"), + ) + data_month = models.PositiveSmallIntegerField( + _("месяц данных"), + db_index=True, + null=True, + blank=True, + help_text=_("Месяц, за который загружены данные"), + ) + + class Meta: + db_table = "parsers_inspection" + verbose_name = _("проверка") + verbose_name_plural = _("проверки") + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["inn", "registration_number"]), + models.Index(fields=["load_batch", "inn"]), + models.Index(fields=["is_federal_law_248", "data_year", "data_month"]), + ] + constraints = [ + models.UniqueConstraint( + fields=["registration_number"], + name="unique_inspection_registration_number", + ), + ] + + def __str__(self) -> str: + org_name = self.organisation_name[:50] if self.organisation_name else "" + return f"{self.registration_number} - {org_name}" diff --git a/src/apps/parsers/serializers.py b/src/apps/parsers/serializers.py new file mode 100644 index 0000000..0d53405 --- /dev/null +++ b/src/apps/parsers/serializers.py @@ -0,0 +1,7 @@ +""" +Сериализаторы для приложения парсеров. + +TODO: Добавить сериализаторы по мере необходимости. +""" + +# Сериализаторы будут добавлены по мере разработки конкретных парсеров diff --git a/src/apps/parsers/services.py b/src/apps/parsers/services.py new file mode 100644 index 0000000..e47893f --- /dev/null +++ b/src/apps/parsers/services.py @@ -0,0 +1,536 @@ +""" +Сервисы для приложения парсеров. + +Используют базовые классы из apps.core.services для стандартных операций. +""" + +import logging + +from apps.core.services import BaseService, BulkOperationsMixin +from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer +from apps.parsers.clients.proverki.schemas import Inspection +from apps.parsers.models import ( + IndustrialCertificateRecord, + InspectionRecord, + ManufacturerRecord, + ParserLoadLog, + Proxy, +) +from django.db import transaction +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +class ParserLoadLogService(BaseService[ParserLoadLog]): + """ + Сервис для управления логами загрузок парсеров. + + Отвечает за: + - Генерацию batch_id для новых загрузок + - Создание записей лога + - Обновление статуса загрузки + """ + + model = ParserLoadLog + + @classmethod + def get_next_batch_id(cls, source: str) -> int: + """ + Получить следующий batch_id для источника. + + Args: + source: Код источника (industrial, manufactures) + + Returns: + Следующий batch_id + """ + last_log = cls.model.objects.filter(source=source).order_by("-batch_id").first() + return (last_log.batch_id + 1) if last_log else 1 + + @classmethod + @transaction.atomic + def create_load_log( + cls, + *, + source: str, + batch_id: int, + records_count: int = 0, + status: str = "success", + error_message: str = "", + ) -> ParserLoadLog: + """ + Создать запись лога загрузки. + + Args: + source: Код источника + batch_id: ID пакета + records_count: Количество загруженных записей + status: Статус загрузки + error_message: Сообщение об ошибке + + Returns: + Созданная запись ParserLoadLog + """ + return cls.create( + source=source, + batch_id=batch_id, + records_count=records_count, + status=status, + error_message=error_message, + ) + + @classmethod + def mark_failed(cls, log: ParserLoadLog, error_message: str) -> ParserLoadLog: + """Отметить загрузку как неудачную.""" + return cls.update(log, status="failed", error_message=error_message) + + @classmethod + def update_records_count(cls, log: ParserLoadLog, count: int) -> ParserLoadLog: + """Обновить количество записей.""" + return cls.update(log, records_count=count) + + +class IndustrialCertificateService( + BulkOperationsMixin, BaseService[IndustrialCertificateRecord] +): + """ + Сервис для управления сертификатами промышленного производства. + + Отвечает за: + - Массовое сохранение сертификатов из парсера + - Поиск сертификатов по ИНН/ОГРН + """ + + model = IndustrialCertificateRecord + + @classmethod + @transaction.atomic + def save_certificates( + cls, + certificates: list[IndustrialCertificate], + batch_id: int, + *, + chunk_size: int = 500, + ) -> int: + """ + Сохранить список сертификатов из парсера. + + Преобразует dataclass объекты в Django модели и сохраняет чанками. + Дубликаты по certificate_number пропускаются (ignore_conflicts). + + Args: + certificates: Список сертификатов из клиента + batch_id: ID пакета загрузки + chunk_size: Размер чанка для bulk_create + + Returns: + Количество новых сохранённых записей + """ + if not certificates: + logger.warning("No certificates to save") + return 0 + + logger.info("Saving %d certificates (batch_id=%d)", len(certificates), batch_id) + + instances = [ + cls.model( + load_batch=batch_id, + issue_date=cert.issue_date, + certificate_number=cert.certificate_number, + expiry_date=cert.expiry_date, + certificate_file_url=cert.certificate_file_url, + organisation_name=cert.organisation_name, + inn=cert.inn, + ogrn=cert.ogrn, + ) + for cert in certificates + ] + + saved_count = cls.bulk_create_chunked( + instances, + chunk_size=chunk_size, + ignore_conflicts=True, # Skip duplicates by certificate_number + ) + logger.info("Saved %d certificates", saved_count) + + return saved_count + + @classmethod + def find_by_inn(cls, inn: str, batch_id: int | None = None): + """ + Найти сертификаты по ИНН. + + Args: + inn: ИНН организации + batch_id: Фильтр по пакету загрузки (опционально) + """ + qs = cls.filter(inn=inn) + if batch_id: + qs = qs.filter(load_batch=batch_id) + return qs + + @classmethod + def find_by_certificate_number(cls, certificate_number: str): + """Найти сертификаты по номеру.""" + return cls.filter(certificate_number=certificate_number) + + +class ManufacturerService(BulkOperationsMixin, BaseService[ManufacturerRecord]): + """ + Сервис для управления реестром производителей. + + Отвечает за: + - Массовое сохранение производителей из парсера + - Поиск производителей по ИНН/ОГРН + """ + + model = ManufacturerRecord + + @classmethod + @transaction.atomic + def save_manufacturers( + cls, + manufacturers: list[Manufacturer], + batch_id: int, + *, + chunk_size: int = 500, + ) -> int: + """ + Сохранить список производителей из парсера. + + Преобразует dataclass объекты в Django модели и сохраняет чанками. + Дубликаты по ИНН пропускаются (ignore_conflicts). + + Args: + manufacturers: Список производителей из клиента + batch_id: ID пакета загрузки + chunk_size: Размер чанка для bulk_create + + Returns: + Количество новых сохранённых записей + """ + if not manufacturers: + logger.warning("No manufacturers to save") + return 0 + + logger.info( + "Saving %d manufacturers (batch_id=%d)", len(manufacturers), batch_id + ) + + instances = [ + cls.model( + load_batch=batch_id, + full_legal_name=m.full_legal_name, + inn=m.inn, + ogrn=m.ogrn, + address=m.address, + ) + for m in manufacturers + ] + + saved_count = cls.bulk_create_chunked( + instances, + chunk_size=chunk_size, + ignore_conflicts=True, # Skip duplicates by INN + ) + logger.info("Saved %d manufacturers", saved_count) + + return saved_count + + @classmethod + def find_by_inn(cls, inn: str, batch_id: int | None = None): + """ + Найти производителей по ИНН. + + Args: + inn: ИНН организации + batch_id: Фильтр по пакету загрузки (опционально) + """ + qs = cls.filter(inn=inn) + if batch_id: + qs = qs.filter(load_batch=batch_id) + return qs + + @classmethod + def find_by_ogrn(cls, ogrn: str): + """Найти производителей по ОГРН.""" + return cls.filter(ogrn=ogrn) + + +class ProxyService(BaseService[Proxy]): + """ + Сервис для управления прокси-серверами. + + Отвечает за: + - Получение списка активных прокси + - Отметку использования прокси + - Регистрацию ошибок прокси + """ + + model = Proxy + + @classmethod + def get_active_proxies(cls) -> list[str]: + """ + Получить список адресов активных прокси. + + Returns: + Список адресов прокси (может быть пустым) + """ + proxies = cls.model.objects.filter(is_active=True).values_list( + "address", flat=True + ) + return list(proxies) + + @classmethod + def get_active_proxies_or_none(cls) -> list[str] | None: + """ + Получить список активных прокси или None, если их нет. + + Returns: + Список адресов прокси или None + """ + proxies = cls.get_active_proxies() + return proxies if proxies else None + + @classmethod + def mark_used(cls, address: str) -> None: + """ + Отметить прокси как использованный. + + Args: + address: Адрес прокси + """ + cls.model.objects.filter(address=address).update(last_used_at=timezone.now()) + + @classmethod + def mark_failed(cls, address: str) -> None: + """ + Увеличить счётчик ошибок прокси. + + Args: + address: Адрес прокси + """ + from django.db.models import F + + cls.model.objects.filter(address=address).update(fail_count=F("fail_count") + 1) + + @classmethod + def deactivate(cls, address: str) -> None: + """ + Деактивировать прокси. + + Args: + address: Адрес прокси + """ + cls.model.objects.filter(address=address).update(is_active=False) + + @classmethod + @transaction.atomic + def add_proxy(cls, address: str, description: str = "") -> Proxy: + """ + Добавить новый прокси. + + Args: + address: Адрес прокси (например: http://proxy:8080) + description: Описание прокси + + Returns: + Созданный объект Proxy + """ + proxy, _ = cls.model.objects.get_or_create( + address=address, + defaults={"description": description, "is_active": True}, + ) + return proxy + + @classmethod + @transaction.atomic + def add_proxies(cls, addresses: list[str]) -> int: + """ + Добавить список прокси. + + Args: + addresses: Список адресов прокси + + Returns: + Количество добавленных прокси + """ + created_count = 0 + for address in addresses: + _, created = cls.model.objects.get_or_create( + address=address, + defaults={"is_active": True}, + ) + if created: + created_count += 1 + return created_count + + +class InspectionService(BulkOperationsMixin, BaseService[InspectionRecord]): + """ + Сервис для управления данными о проверках. + + Отвечает за: + - Массовое сохранение проверок из парсера + - Поиск проверок по ИНН/ОГРН/номеру + """ + + model = InspectionRecord + + @classmethod + @transaction.atomic + def save_inspections( + cls, + inspections: list[Inspection], + batch_id: int, + *, + is_federal_law_248: bool = False, + data_year: int | None = None, + data_month: int | None = None, + chunk_size: int = 500, + ) -> int: + """ + Сохранить список проверок из парсера. + + Преобразует dataclass объекты в Django модели и сохраняет чанками. + Дубликаты по registration_number пропускаются (ignore_conflicts). + + Args: + inspections: Список проверок из клиента + batch_id: ID пакета загрузки + is_federal_law_248: Признак проверок по ФЗ-248 + data_year: Год, за который загружены данные + data_month: Месяц, за который загружены данные + chunk_size: Размер чанка для bulk_create + + Returns: + Количество новых сохранённых записей + """ + if not inspections: + logger.warning("No inspections to save") + return 0 + + fz_type = "ФЗ-248" if is_federal_law_248 else "ФЗ-294" + logger.info( + "Saving %d inspections (batch_id=%d, %s, year=%s, month=%s)", + len(inspections), + batch_id, + fz_type, + data_year, + data_month, + ) + + instances = [ + cls.model( + load_batch=batch_id, + registration_number=insp.registration_number, + inn=insp.inn, + ogrn=insp.ogrn, + organisation_name=insp.organisation_name, + control_authority=insp.control_authority, + inspection_type=insp.inspection_type, + inspection_form=insp.inspection_form, + start_date=insp.start_date, + end_date=insp.end_date, + status=insp.status, + legal_basis=insp.legal_basis, + result=insp.result, + is_federal_law_248=is_federal_law_248, + data_year=data_year, + data_month=data_month, + ) + for insp in inspections + ] + + saved_count = cls.bulk_create_chunked( + instances, + chunk_size=chunk_size, + ignore_conflicts=True, # Skip duplicates by registration_number + ) + logger.info("Saved %d inspections", saved_count) + + return saved_count + + @classmethod + def get_last_loaded_period( + cls, is_federal_law_248: bool = False + ) -> tuple[int | None, int | None]: + """ + Получить последний загруженный период (год, месяц). + + Args: + is_federal_law_248: Признак проверок по ФЗ-248 + + Returns: + Кортеж (year, month) или (None, None) если данных нет + """ + last_record = ( + cls.model.objects.filter(is_federal_law_248=is_federal_law_248) + .exclude(data_year__isnull=True) + .order_by("-data_year", "-data_month") + .values("data_year", "data_month") + .first() + ) + + if last_record: + return last_record["data_year"], last_record["data_month"] + return None, None + + @classmethod + def has_data_for_period( + cls, + year: int, + month: int, + is_federal_law_248: bool = False, + ) -> bool: + """ + Проверить, есть ли данные за указанный период. + + Args: + year: Год + month: Месяц + is_federal_law_248: Признак проверок по ФЗ-248 + + Returns: + True если данные есть + """ + return cls.model.objects.filter( + data_year=year, + data_month=month, + is_federal_law_248=is_federal_law_248, + ).exists() + + @classmethod + def find_by_inn(cls, inn: str, batch_id: int | None = None): + """ + Найти проверки по ИНН. + + Args: + inn: ИНН организации + batch_id: Фильтр по пакету загрузки (опционально) + """ + qs = cls.filter(inn=inn) + if batch_id: + qs = qs.filter(load_batch=batch_id) + return qs + + @classmethod + def find_by_registration_number(cls, registration_number: str): + """Найти проверки по учётному номеру.""" + return cls.filter(registration_number=registration_number) + + @classmethod + def find_by_control_authority(cls, authority: str, batch_id: int | None = None): + """ + Найти проверки по контрольному органу. + + Args: + authority: Наименование контрольного органа (частичное совпадение) + batch_id: Фильтр по пакету загрузки (опционально) + """ + qs = cls.filter(control_authority__icontains=authority) + if batch_id: + qs = qs.filter(load_batch=batch_id) + return qs diff --git a/src/apps/parsers/signals.py b/src/apps/parsers/signals.py new file mode 100644 index 0000000..2a26230 --- /dev/null +++ b/src/apps/parsers/signals.py @@ -0,0 +1,14 @@ +""" +Сигналы для приложения парсеров. + +Содержит обработчики сигналов для: +- Автоматических действий при создании/обновлении DataSource +- Очистки при удалении конфигураций +""" + +import logging + +logger = logging.getLogger(__name__) + + +# Сигналы будут добавлены по мере необходимости diff --git a/src/apps/parsers/tasks.py b/src/apps/parsers/tasks.py new file mode 100644 index 0000000..46f08ce --- /dev/null +++ b/src/apps/parsers/tasks.py @@ -0,0 +1,580 @@ +""" +Celery задачи для приложения парсеров. + +Задачи являются тонкими обёртками над сервисами и клиентами. +Интегрируются с BackgroundJob для отслеживания прогресса. +""" + +import logging +from datetime import datetime + +from apps.core.services import BackgroundJobService +from apps.parsers.clients.minpromtorg import ( + IndustrialProductionClient, + ManufacturesClient, +) +from apps.parsers.clients.proverki import ProverkiClient +from apps.parsers.models import ParserLoadLog +from apps.parsers.services import ( + IndustrialCertificateService, + InspectionService, + ManufacturerService, + ParserLoadLogService, + ProxyService, +) +from celery import shared_task + +logger = logging.getLogger(__name__) + +# Константы для синхронизации проверок +DEFAULT_START_YEAR = 2025 +DEFAULT_START_MONTH = 1 + + +@shared_task(bind=True) +def parse_industrial_production(self, proxies: list[str] | None = None) -> dict: + """ + Задача парсинга сертификатов промышленного производства. + + Args: + proxies: Список прокси-серверов (опционально). + Если не передан, берётся из БД. + + Returns: + Результат: batch_id, saved, status + """ + source = ParserLoadLog.Source.INDUSTRIAL + batch_id = ParserLoadLogService.get_next_batch_id(source) + task_id = self.request.id + + # Если прокси не переданы, берём из БД + if proxies is None: + proxies = ProxyService.get_active_proxies_or_none() + + logger.info( + "Starting industrial production parsing (task_id=%s, batch_id=%d, proxies=%d)", + task_id, + batch_id, + len(proxies) if proxies else 0, + ) + + # Создаём запись BackgroundJob для отслеживания прогресса + job = BackgroundJobService.create_job( + task_id=task_id, + task_name="apps.parsers.tasks.parse_industrial_production", + meta={"source": source, "batch_id": batch_id}, + ) + job.mark_started() + job.update_progress(0, "Инициализация парсера...") + + # Создаём запись лога + load_log = ParserLoadLogService.create_load_log( + source=source, + batch_id=batch_id, + status="in_progress", + ) + + try: + # Парсинг данных + job.update_progress(10, "Загрузка данных с API Минпромторга...") + with IndustrialProductionClient(proxies=proxies) as client: + certificates = client.fetch_certificates() + + # Сохранение в БД + job.update_progress(50, f"Сохранение {len(certificates)} сертификатов...") + saved_count = IndustrialCertificateService.save_certificates( + certificates, + batch_id=batch_id, + ) + + # Обновляем лог + ParserLoadLogService.update( + load_log, + status="success", + records_count=saved_count, + ) + + # Завершаем BackgroundJob + job.complete(result={"batch_id": batch_id, "saved": saved_count}) + + logger.info( + "Industrial production parsing completed (batch_id=%d, saved=%d)", + batch_id, + saved_count, + ) + + return { + "batch_id": batch_id, + "saved": saved_count, + "status": "success", + } + + except Exception as e: + logger.error("Industrial production parsing failed: %s", e, exc_info=True) + ParserLoadLogService.mark_failed(load_log, str(e)) + job.fail(error=str(e)) + + return { + "batch_id": batch_id, + "saved": 0, + "status": "failed", + "error": str(e), + } + + +@shared_task(bind=True) +def parse_manufactures(self, proxies: list[str] | None = None) -> dict: + """ + Задача парсинга реестра производителей. + + Args: + proxies: Список прокси-серверов (опционально). + Если не передан, берётся из БД. + + Returns: + Результат: batch_id, saved, status + """ + source = ParserLoadLog.Source.MANUFACTURES + batch_id = ParserLoadLogService.get_next_batch_id(source) + task_id = self.request.id + + # Если прокси не переданы, берём из БД + if proxies is None: + proxies = ProxyService.get_active_proxies_or_none() + + logger.info( + "Starting manufactures parsing (task_id=%s, batch_id=%d, proxies=%d)", + task_id, + batch_id, + len(proxies) if proxies else 0, + ) + + # Создаём запись BackgroundJob для отслеживания прогресса + job = BackgroundJobService.create_job( + task_id=task_id, + task_name="apps.parsers.tasks.parse_manufactures", + meta={"source": source, "batch_id": batch_id}, + ) + job.mark_started() + job.update_progress(0, "Инициализация парсера...") + + # Создаём запись лога + load_log = ParserLoadLogService.create_load_log( + source=source, + batch_id=batch_id, + status="in_progress", + ) + + try: + # Парсинг данных + job.update_progress(10, "Загрузка данных с API Минпромторга...") + with ManufacturesClient(proxies=proxies) as client: + manufacturers = client.fetch_manufacturers() + + # Сохранение в БД + job.update_progress(50, f"Сохранение {len(manufacturers)} производителей...") + saved_count = ManufacturerService.save_manufacturers( + manufacturers, + batch_id=batch_id, + ) + + # Обновляем лог + ParserLoadLogService.update( + load_log, + status="success", + records_count=saved_count, + ) + + # Завершаем BackgroundJob + job.complete(result={"batch_id": batch_id, "saved": saved_count}) + + logger.info( + "Manufactures parsing completed (batch_id=%d, saved=%d)", + batch_id, + saved_count, + ) + + return { + "batch_id": batch_id, + "saved": saved_count, + "status": "success", + } + + except Exception as e: + logger.error("Manufactures parsing failed: %s", e, exc_info=True) + ParserLoadLogService.mark_failed(load_log, str(e)) + job.fail(error=str(e)) + + return { + "batch_id": batch_id, + "saved": 0, + "status": "failed", + "error": str(e), + } + + +@shared_task +def parse_all_minpromtorg(proxies: list[str] | None = None) -> dict: + """ + Запустить все парсеры Минпромторга. + + Args: + proxies: Список прокси-серверов (опционально). + Если не передан, каждая задача возьмёт прокси из БД. + + Returns: + Результаты всех парсеров + """ + logger.info("Starting all Minpromtorg parsers") + + results = { + "industrial": parse_industrial_production.delay(proxies=proxies).id, + "manufactures": parse_manufactures.delay(proxies=proxies).id, + } + + return results + + +@shared_task(bind=True) +def parse_inspections( + self, + *, + year: int | None = None, + month: int | None = None, + file_url: str | None = None, + proxies: list[str] | None = None, +) -> dict: + """ + Задача парсинга данных о проверках с proverki.gov.ru. + + Args: + year: Год плана проверок (опционально) + month: Месяц (опционально) + file_url: Прямая ссылка на файл данных (опционально) + proxies: Список прокси-серверов (опционально). + Если не передан, берётся из БД. + + Returns: + Результат: batch_id, saved, status + """ + source = ParserLoadLog.Source.INSPECTIONS + batch_id = ParserLoadLogService.get_next_batch_id(source) + task_id = self.request.id + + # Если прокси не переданы, берём из БД + if proxies is None: + proxies = ProxyService.get_active_proxies_or_none() + + logger.info( + "Starting inspections parsing (task_id=%s, batch_id=%d, year=%s, month=%s, proxies=%d)", + task_id, + batch_id, + year, + month, + len(proxies) if proxies else 0, + ) + + # Создаём запись BackgroundJob для отслеживания прогресса + job = BackgroundJobService.create_job( + task_id=task_id, + task_name="apps.parsers.tasks.parse_inspections", + meta={"source": source, "batch_id": batch_id, "year": year, "month": month}, + ) + job.mark_started() + job.update_progress(0, "Инициализация парсера...") + + # Создаём запись лога + load_log = ParserLoadLogService.create_load_log( + source=source, + batch_id=batch_id, + status="in_progress", + ) + + def progress_callback(percent: int, message: str) -> None: + """Callback для обновления прогресса.""" + job.update_progress(percent, message) + + try: + # Парсинг данных + job.update_progress(10, "Загрузка данных с proverki.gov.ru...") + with ProverkiClient(proxies=proxies) as client: + inspections = client.fetch_inspections( + year=year, + month=month, + file_url=file_url, + progress_callback=progress_callback, + ) + + # Сохранение в БД + job.update_progress(80, f"Сохранение {len(inspections)} проверок...") + saved_count = InspectionService.save_inspections( + inspections, + batch_id=batch_id, + ) + + # Обновляем лог + ParserLoadLogService.update( + load_log, + status="success", + records_count=saved_count, + ) + + # Завершаем BackgroundJob + job.complete(result={"batch_id": batch_id, "saved": saved_count}) + + logger.info( + "Inspections parsing completed (batch_id=%d, saved=%d)", + batch_id, + saved_count, + ) + + return { + "batch_id": batch_id, + "saved": saved_count, + "status": "success", + } + + except Exception as e: + logger.error("Inspections parsing failed: %s", e, exc_info=True) + ParserLoadLogService.mark_failed(load_log, str(e)) + job.fail(error=str(e)) + + return { + "batch_id": batch_id, + "saved": 0, + "status": "failed", + "error": str(e), + } + + +@shared_task +def parse_all_sources(proxies: list[str] | None = None) -> dict: + """ + Запустить все парсеры из всех источников. + + Args: + proxies: Список прокси-серверов (опционально). + Если не передан, каждая задача возьмёт прокси из БД. + + Returns: + Task IDs всех запущенных парсеров + """ + logger.info("Starting all parsers from all sources") + + results = { + "industrial": parse_industrial_production.delay(proxies=proxies).id, + "manufactures": parse_manufactures.delay(proxies=proxies).id, + "inspections": parse_inspections.delay(proxies=proxies).id, + } + + return results + + +def _get_next_month(year: int, month: int) -> tuple[int, int]: + """Получить следующий месяц.""" + if month == 12: + return year + 1, 1 + return year, month + 1 + + +@shared_task(bind=True) +def sync_inspections( # noqa: C901 + self, + *, + proxies: list[str] | None = None, +) -> dict: + """ + Синхронизация данных о проверках с proverki.gov.ru. + + Логика работы: + 1. Проверяет последнюю загруженную дату в БД + 2. Если данных нет - начинает с 01.01.2025 + 3. Загружает месяц за месяцем до конца текущего года + 4. Загружает оба типа проверок (ФЗ-294 и ФЗ-248) + 5. Если данных нет за период - прекращает загрузку для этого типа + + Args: + proxies: Список прокси-серверов (опционально) + + Returns: + Результат синхронизации + """ + source = ParserLoadLog.Source.INSPECTIONS + batch_id = ParserLoadLogService.get_next_batch_id(source) + task_id = self.request.id + + # Если прокси не переданы, берём из БД + if proxies is None: + proxies = ProxyService.get_active_proxies_or_none() + + logger.info( + "Starting inspections sync (task_id=%s, batch_id=%d)", task_id, batch_id + ) + + # Создаём запись BackgroundJob + job = BackgroundJobService.create_job( + task_id=task_id, + task_name="apps.parsers.tasks.sync_inspections", + meta={"source": source, "batch_id": batch_id}, + ) + job.mark_started() + job.update_progress(0, "Инициализация синхронизации...") + + # Создаём запись лога + load_log = ParserLoadLogService.create_load_log( + source=source, + batch_id=batch_id, + status="in_progress", + ) + + current_year = datetime.now().year + current_month = datetime.now().month + total_saved = 0 + results = {"fz294": [], "fz248": []} + + try: + with ProverkiClient(proxies=proxies) as client: + # Обрабатываем оба типа проверок + for is_fz248 in [False, True]: + fz_key = "fz248" if is_fz248 else "fz294" + fz_name = "ФЗ-248" if is_fz248 else "ФЗ-294" + + # Определяем начальную точку + last_year, last_month = InspectionService.get_last_loaded_period( + is_federal_law_248=is_fz248 + ) + + if last_year and last_month: + # Начинаем со следующего месяца после последнего загруженного + start_year, start_month = _get_next_month(last_year, last_month) + logger.info( + "%s: continuing from %d/%d (last loaded: %d/%d)", + fz_name, + start_year, + start_month, + last_year, + last_month, + ) + else: + # Начинаем с дефолтной даты + start_year, start_month = DEFAULT_START_YEAR, DEFAULT_START_MONTH + logger.info( + "%s: no data in DB, starting from %d/%d", + fz_name, + start_year, + start_month, + ) + + # Загружаем месяц за месяцем + year, month = start_year, start_month + empty_months_count = 0 + + while year < current_year or ( + year == current_year and month <= current_month + ): + # Прекращаем если 2 месяца подряд нет данных + if empty_months_count >= 2: + logger.info( + "%s: stopping after %d empty months", + fz_name, + empty_months_count, + ) + break + + job.update_progress( + 20 + (50 if is_fz248 else 0), + f"Загрузка {fz_name} за {month:02d}/{year}...", + ) + + try: + inspections = client.fetch_inspections( + year=year, + month=month, + is_federal_law_248=is_fz248, + ) + + if inspections: + saved = InspectionService.save_inspections( + inspections, + batch_id=batch_id, + is_federal_law_248=is_fz248, + data_year=year, + data_month=month, + ) + total_saved += saved + results[fz_key].append( + { + "year": year, + "month": month, + "fetched": len(inspections), + "saved": saved, + } + ) + empty_months_count = 0 + logger.info( + "%s %d/%d: fetched %d, saved %d", + fz_name, + year, + month, + len(inspections), + saved, + ) + else: + empty_months_count += 1 + logger.info( + "%s %d/%d: no data found (empty_count=%d)", + fz_name, + year, + month, + empty_months_count, + ) + + except Exception as e: + logger.warning( + "%s %d/%d: error - %s", + fz_name, + year, + month, + str(e), + ) + empty_months_count += 1 + + # Переходим к следующему месяцу + year, month = _get_next_month(year, month) + + # Обновляем лог + ParserLoadLogService.update( + load_log, + status="success", + records_count=total_saved, + ) + + # Завершаем BackgroundJob + job.complete( + result={ + "batch_id": batch_id, + "total_saved": total_saved, + "results": results, + } + ) + + logger.info("Inspections sync completed (total_saved=%d)", total_saved) + + return { + "batch_id": batch_id, + "total_saved": total_saved, + "status": "success", + "results": results, + } + + except Exception as e: + logger.error("Inspections sync failed: %s", e, exc_info=True) + ParserLoadLogService.mark_failed(load_log, str(e)) + job.fail(error=str(e)) + + return { + "batch_id": batch_id, + "total_saved": total_saved, + "status": "failed", + "error": str(e), + } diff --git a/src/apps/parsers/urls.py b/src/apps/parsers/urls.py new file mode 100644 index 0000000..051ad2a --- /dev/null +++ b/src/apps/parsers/urls.py @@ -0,0 +1,5 @@ +app_name = "parsers" + +urlpatterns = [ + # URL-маршруты будут добавлены по мере разработки +] diff --git a/src/apps/parsers/views.py b/src/apps/parsers/views.py new file mode 100644 index 0000000..a890579 --- /dev/null +++ b/src/apps/parsers/views.py @@ -0,0 +1,7 @@ +""" +Views для приложения парсеров. + +TODO: Добавить views по мере необходимости. +""" + +# Views будут добавлены по мере разработки конкретных парсеров diff --git a/src/apps/user/admin.py b/src/apps/user/admin.py new file mode 100644 index 0000000..124a62d --- /dev/null +++ b/src/apps/user/admin.py @@ -0,0 +1,181 @@ +""" +Admin configuration for user app. +""" + +from apps.user.models import Profile, User +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.html import format_html +from django.utils.translation import gettext_lazy as _ + + +class ProfileInline(admin.StackedInline): + """Inline для профиля пользователя.""" + + model = Profile + can_delete = False + verbose_name_plural = "Профиль" + fk_name = "user" + fields = ["first_name", "last_name", "bio", "avatar", "date_of_birth"] + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """Admin для пользователей.""" + + inlines = [ProfileInline] + + list_display = [ + "username", + "email", + "phone", + "is_verified_badge", + "is_active_badge", + "is_staff", + "created_at", + ] + list_filter = ["is_staff", "is_superuser", "is_active", "is_verified", "created_at"] + search_fields = ["username", "email", "phone"] + ordering = ["-created_at"] + list_per_page = 50 + date_hierarchy = "created_at" + + fieldsets = ( + (None, {"fields": ("username", "password")}), + ( + _("Personal info"), + {"fields": ("email", "phone")}, + ), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "is_verified", + "groups", + "user_permissions", + ), + "classes": ("collapse",), + }, + ), + ( + _("Important dates"), + {"fields": ("last_login", "date_joined", "created_at", "updated_at")}, + ), + ) + + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ( + "username", + "email", + "password1", + "password2", + "is_staff", + "is_active", + ), + }, + ), + ) + + readonly_fields = ["created_at", "updated_at", "last_login", "date_joined"] + + def is_verified_badge(self, obj): + """Бейдж верификации.""" + if obj.is_verified: + return format_html( + '' + ) + return format_html( + '' + ) + + is_verified_badge.short_description = "Верифицирован" + is_verified_badge.admin_order_field = "is_verified" + + def is_active_badge(self, obj): + """Бейдж активности.""" + if obj.is_active: + return format_html( + 'Активен' + ) + return format_html( + 'Неактивен' + ) + + is_active_badge.short_description = "Статус" + is_active_badge.admin_order_field = "is_active" + + actions = ["verify_users", "unverify_users", "activate_users", "deactivate_users"] + + @admin.action(description="Верифицировать выбранных пользователей") + def verify_users(self, request, queryset): + updated = queryset.update(is_verified=True) + self.message_user(request, f"Верифицировано {updated} пользователей") + + @admin.action(description="Снять верификацию") + def unverify_users(self, request, queryset): + updated = queryset.update(is_verified=False) + self.message_user(request, f"Снята верификация у {updated} пользователей") + + @admin.action(description="Активировать пользователей") + def activate_users(self, request, queryset): + updated = queryset.update(is_active=True) + self.message_user(request, f"Активировано {updated} пользователей") + + @admin.action(description="Деактивировать пользователей") + def deactivate_users(self, request, queryset): + updated = queryset.update(is_active=False) + self.message_user(request, f"Деактивировано {updated} пользователей") + + +@admin.register(Profile) +class ProfileAdmin(admin.ModelAdmin): + """Admin для профилей.""" + + list_display = [ + "user", + "full_name", + "date_of_birth", + "has_avatar", + "created_at", + ] + list_filter = ["created_at"] + search_fields = ["user__username", "user__email", "first_name", "last_name"] + readonly_fields = ["created_at", "updated_at"] + ordering = ["-created_at"] + list_per_page = 50 + raw_id_fields = ["user"] + + fieldsets = ( + ("Пользователь", {"fields": ("user",)}), + ( + "Личная информация", + {"fields": ("first_name", "last_name", "bio", "date_of_birth")}, + ), + ("Аватар", {"fields": ("avatar",)}), + ("Даты", {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}), + ) + + def has_avatar(self, obj): + """Есть ли аватар.""" + if obj.avatar: + return format_html( + 'Да' + ) + return format_html( + 'Нет' + ) + + has_avatar.short_description = "Аватар" diff --git a/src/apps/user/migrations/0002_remove_firstname_lastname.py b/src/apps/user/migrations/0002_remove_firstname_lastname.py new file mode 100644 index 0000000..c12bb96 --- /dev/null +++ b/src/apps/user/migrations/0002_remove_firstname_lastname.py @@ -0,0 +1,27 @@ +# Generated by Django 3.2.25 on 2026-01-21 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('user', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='user', + name='first_name', + ), + migrations.RemoveField( + model_name='user', + name='last_name', + ), + migrations.AlterField( + model_name='user', + name='groups', + field=models.ManyToManyField(blank=True, help_text='', related_name='custom_user_set', related_query_name='custom_user', to='auth.Group', verbose_name='groups'), + ), + ] 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/config/api_v1_urls.py b/src/config/api_v1_urls.py index a3abefd..cdf8730 100644 --- a/src/config/api_v1_urls.py +++ b/src/config/api_v1_urls.py @@ -16,5 +16,6 @@ jobs_urlpatterns = [ urlpatterns = [ path("users/", include("apps.user.urls")), + path("parsers/", include("apps.parsers.urls")), path("jobs/", include((jobs_urlpatterns, "jobs"))), ] diff --git a/src/config/celery.py b/src/config/celery.py index a45ae92..0eecb09 100644 --- a/src/config/celery.py +++ b/src/config/celery.py @@ -24,17 +24,19 @@ 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 }, } -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..17cfd8b 100644 --- a/src/config/settings/base.py +++ b/src/config/settings/base.py @@ -45,6 +45,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,8 +62,106 @@ INSTALLED_APPS = [ # Local apps "apps.core", "apps.user", + "apps.parsers", ] +# Jazzmin Admin Configuration +JAZZMIN_SETTINGS = { + # Title + "site_title": "Mostovik Admin", + "site_header": "Mostovik", + "site_brand": "Mostovik", + "site_logo": None, + "login_logo": None, + "login_logo_dark": None, + "site_logo_classes": "img-circle", + "site_icon": None, + "welcome_sign": "Добро пожаловать в панель управления", + "copyright": "Mostovik 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", + "parsers.Proxy": "fas fa-shield-alt", + "parsers.ParserLoadLog": "fas fa-history", + "parsers.IndustrialCertificateRecord": "fas fa-certificate", + "parsers.ManufacturerRecord": "fas fa-industry", + "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", 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/tests/apps/parsers/__init__.py b/tests/apps/parsers/__init__.py new file mode 100644 index 0000000..30d1528 --- /dev/null +++ b/tests/apps/parsers/__init__.py @@ -0,0 +1 @@ +"""Tests for parsers app.""" diff --git a/tests/apps/parsers/factories.py b/tests/apps/parsers/factories.py new file mode 100644 index 0000000..4ff41ca --- /dev/null +++ b/tests/apps/parsers/factories.py @@ -0,0 +1,347 @@ +"""Factories for parsers tests.""" + +import random +from datetime import timedelta + +from django.utils import timezone + +import factory + +from apps.parsers.models import ( + IndustrialCertificateRecord, + InspectionRecord, + ManufacturerRecord, + ParserLoadLog, + Proxy, +) + +# === Хелперы для генерации реалистичных данных === + + +def generate_inn_legal() -> str: + """Генерация ИНН юридического лица (10 цифр).""" + # ИНН юрлица: NNNNXXXXXC (10 цифр) + # NNNN - код налогового органа + # XXXXX - порядковый номер + # C - контрольная цифра + region = random.choice(["77", "78", "50", "52", "63", "16", "66", "74", "54", "61"]) + inspection = str(random.randint(1, 99)).zfill(2) + number = str(random.randint(1, 99999)).zfill(5) + base = region + inspection + number + # Контрольная цифра (упрощённо) + control = str(sum(int(d) for d in base) % 10) + return base + control + + +def generate_ogrn() -> str: + """Генерация ОГРН юридического лица (13 цифр).""" + # ОГРН: СГГККННХХХХХЧ (13 цифр) + # С - признак (1 - юрлицо) + # ГГ - год регистрации + # КК - код региона + # НН - код инспекции + # ХХХХХ - номер записи + # Ч - контрольная цифра + sign = "1" + year = str(random.randint(2, 24)).zfill(2) + region = random.choice(["77", "78", "50", "52", "63", "16", "66", "74", "54", "61"]) + inspection = str(random.randint(1, 99)).zfill(2) + number = str(random.randint(1, 99999)).zfill(5) + base = sign + year + region + inspection + number + # Контрольная цифра: остаток от деления на 11, если 10 - то 0 + control = str(int(base) % 11 % 10) + return base + control + + +def generate_certificate_number() -> str: + """Генерация номера сертификата промпроизводства.""" + # Формат: ПП-XXXXXXXXXX или аналогичный + prefix = random.choice(["ПП", "СПП", "ЗППП"]) + year = random.randint(2020, 2025) + number = random.randint(1, 99999) + return f"{prefix}-{year}-{number:05d}" + + +def generate_company_name() -> str: + """Генерация реалистичного названия компании.""" + forms = ["ООО", "АО", "ПАО", "ЗАО", "ОАО"] + industries = [ + "Металлург", + "Промтех", + "Машстрой", + "Агропром", + "Нефтегаз", + "Химпром", + "Электроника", + "Автоком", + "Стройинвест", + "Техносервис", + "Приборостроение", + "Энергомаш", + "Станкопром", + "Спецсталь", + "Трубопрокат", + ] + suffixes = ["", " Групп", " Холдинг", " Инвест", " Трейд", " Индустрия", " Про"] + cities = [ + "Москва", + "Санкт-Петербург", + "Новосибирск", + "Екатеринбург", + "Казань", + "Челябинск", + ] + + form = random.choice(forms) + industry = random.choice(industries) + suffix = random.choice(suffixes) + city = random.choice(cities) if random.random() > 0.7 else "" + + name = f"{industry}{suffix}" + if city: + name = f"{name}-{city}" + + return f'{form} "{name}"' + + +def generate_legal_address() -> str: + """Генерация юридического адреса.""" + regions = [ + ("г. Москва", ""), + ("г. Санкт-Петербург", ""), + ("Московская обл.", "г. Подольск"), + ("Свердловская обл.", "г. Екатеринбург"), + ("Республика Татарстан", "г. Казань"), + ("Челябинская обл.", "г. Челябинск"), + ("Новосибирская обл.", "г. Новосибирск"), + ("Нижегородская обл.", "г. Нижний Новгород"), + ] + + region, city = random.choice(regions) + street_types = ["ул.", "пр-т", "пер.", "наб.", "ш."] + street_names = [ + "Ленина", + "Мира", + "Советская", + "Промышленная", + "Заводская", + "Первомайская", + "Октябрьская", + "Гагарина", + "Кирова", + "Строителей", + ] + + street = f"{random.choice(street_types)} {random.choice(street_names)}" + building = random.randint(1, 150) + office = random.randint(1, 500) if random.random() > 0.5 else None + + postal = f"{random.randint(100, 199)}0{random.randint(10, 99)}" + + parts = [postal, region] + if city: + parts.append(city) + parts.append(f"{street}, д. {building}") + if office: + parts.append(f"оф. {office}") + + return ", ".join(parts) + + +def generate_proxy_address() -> str: + """Генерация адреса прокси-сервера.""" + protocols = ["http", "https", "socks5"] + hosts = [ + f"{random.randint(1, 255)}.{random.randint(1, 255)}." + f"{random.randint(1, 255)}.{random.randint(1, 255)}", + f"proxy{random.randint(1, 50)}.example.com", + f"ru{random.randint(1, 20)}.proxy-service.net", + ] + ports = [8080, 3128, 8888, 1080, 8000, 9050] + + protocol = random.choice(protocols) + host = random.choice(hosts) + port = random.choice(ports) + + return f"{protocol}://{host}:{port}" + + +# === Фабрики === + + +class ProxyFactory(factory.django.DjangoModelFactory): + """Factory for Proxy model.""" + + class Meta: + model = Proxy + + address = factory.LazyFunction(generate_proxy_address) + is_active = True + fail_count = 0 + description = factory.LazyAttribute( + lambda _: random.choice( + [ + "Datacenter RU", + "Residential RU", + "Mobile RU", + "Datacenter EU", + "Premium proxy", + "Backup proxy", + ] + ) + ) + + +class ParserLoadLogFactory(factory.django.DjangoModelFactory): + """Factory for ParserLoadLog model.""" + + class Meta: + model = ParserLoadLog + + batch_id = factory.Sequence(lambda n: n + 1) + source = factory.LazyAttribute( + lambda _: random.choice( + [ + ParserLoadLog.Source.INDUSTRIAL, + ParserLoadLog.Source.MANUFACTURES, + ] + ) + ) + records_count = factory.LazyAttribute(lambda _: random.randint(100, 5000)) + status = "success" + error_message = "" + + +class IndustrialCertificateRecordFactory(factory.django.DjangoModelFactory): + """Factory for IndustrialCertificateRecord model.""" + + class Meta: + model = IndustrialCertificateRecord + + load_batch = factory.Sequence(lambda n: n + 1) + issue_date = factory.LazyAttribute( + lambda _: (timezone.now() - timedelta(days=random.randint(30, 365))).strftime( + "%d.%m.%Y" + ) + ) + certificate_number = factory.LazyFunction(generate_certificate_number) + expiry_date = factory.LazyAttribute( + lambda _: (timezone.now() + timedelta(days=random.randint(180, 730))).strftime( + "%d.%m.%Y" + ) + ) + certificate_file_url = factory.LazyAttribute( + lambda obj: f"https://minpromtorg.gov.ru/docs/certificates/" + f"{obj.certificate_number.replace('-', '_')}.pdf" + ) + organisation_name = factory.LazyFunction(generate_company_name) + inn = factory.LazyFunction(generate_inn_legal) + ogrn = factory.LazyFunction(generate_ogrn) + + +class ManufacturerRecordFactory(factory.django.DjangoModelFactory): + """Factory for ManufacturerRecord model.""" + + class Meta: + model = ManufacturerRecord + + load_batch = factory.Sequence(lambda n: n + 1) + full_legal_name = factory.LazyFunction(generate_company_name) + inn = factory.LazyFunction(generate_inn_legal) + ogrn = factory.LazyFunction(generate_ogrn) + address = factory.LazyFunction(generate_legal_address) + + +def generate_registration_number() -> str: + """Генерация учётного номера проверки.""" + # Формат: 772020123456 или подобный + region = random.choice(["77", "78", "50", "52", "63", "16", "66", "74", "54", "61"]) + year = random.randint(2020, 2025) + number = random.randint(1, 999999) + return f"{region}{year}{number:06d}" + + +def generate_control_authority() -> str: + """Генерация наименования контрольного органа.""" + authorities = [ + "Роспотребнадзор", + "Ростехнадзор", + "Росприроднадзор", + "МЧС России", + "Роструд", + "ФНС России", + "ФАС России", + "Россельхознадзор", + "Роскомнадзор", + "Росздравнадзор", + ] + prefixes = [ + "Управление", + "Территориальное управление", + "Межрегиональное управление", + "Отдел", + ] + regions = [ + "по г. Москве", + "по Санкт-Петербургу", + "по Московской области", + "по Свердловской области", + "по Республике Татарстан", + "по Челябинской области", + "по Новосибирской области", + ] + + authority = random.choice(authorities) + prefix = random.choice(prefixes) if random.random() > 0.3 else "" + region = random.choice(regions) if random.random() > 0.4 else "" + + if prefix and region: + return f"{prefix} {authority} {region}" + elif prefix: + return f"{prefix} {authority}" + elif region: + return f"{authority} {region}" + return authority + + +class InspectionRecordFactory(factory.django.DjangoModelFactory): + """Factory for InspectionRecord model.""" + + class Meta: + model = InspectionRecord + + load_batch = factory.Sequence(lambda n: n + 1) + registration_number = factory.LazyFunction(generate_registration_number) + inn = factory.LazyFunction(generate_inn_legal) + ogrn = factory.LazyFunction(generate_ogrn) + organisation_name = factory.LazyFunction(generate_company_name) + control_authority = factory.LazyFunction(generate_control_authority) + inspection_type = factory.LazyAttribute( + lambda _: random.choice(["плановая", "внеплановая"]) + ) + inspection_form = factory.LazyAttribute( + lambda _: random.choice(["документарная", "выездная", "документарная и выездная"]) + ) + start_date = factory.LazyAttribute( + lambda _: (timezone.now() - timedelta(days=random.randint(1, 180))).strftime( + "%Y-%m-%d" + ) + ) + end_date = factory.LazyAttribute( + lambda _: (timezone.now() + timedelta(days=random.randint(1, 30))).strftime( + "%Y-%m-%d" + ) + ) + status = factory.LazyAttribute( + lambda _: random.choice(["завершена", "в процессе", "запланирована"]) + ) + legal_basis = factory.LazyAttribute( + lambda _: random.choice(["294-ФЗ", "248-ФЗ", "184-ФЗ"]) + ) + result = factory.LazyAttribute( + lambda _: random.choice( + ["нарушения не выявлены", "выявлены нарушения", ""] + ) + if random.random() > 0.3 + else "" + ) diff --git a/tests/apps/parsers/test_clients.py b/tests/apps/parsers/test_clients.py new file mode 100644 index 0000000..e16594c --- /dev/null +++ b/tests/apps/parsers/test_clients.py @@ -0,0 +1,646 @@ +"""Tests for parsers clients.""" + +from io import BytesIO +from unittest.mock import patch + +from django.test import TestCase, tag + +from faker import Faker +from openpyxl import Workbook + +from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError +from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient +from apps.parsers.clients.minpromtorg.manufactures import ManufacturesClient +from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer +from apps.parsers.clients.proverki import ProverkiClient +from apps.parsers.clients.proverki.schemas import Inspection + +fake = Faker("ru_RU") + + +class BaseHTTPClientTest(TestCase): + """Tests for BaseHTTPClient.""" + + def test_client_initialization(self): + """Test client initializes with defaults.""" + client = BaseHTTPClient(base_url="https://example.com") + + self.assertEqual(client.base_url, "https://example.com") + self.assertIsNone(client.proxies) + self.assertEqual(client.timeout, 30) + + def test_client_with_proxies(self): + """Test client initializes with proxy list.""" + proxies = ["http://proxy1:8080", "http://proxy2:8080"] + client = BaseHTTPClient(base_url="https://example.com", proxies=proxies) + + self.assertEqual(client.proxies, proxies) + + def test_select_proxy_returns_none_without_proxies(self): + """Test _select_proxy returns None when no proxies.""" + client = BaseHTTPClient(base_url="https://example.com") + self.assertIsNone(client._select_proxy()) + + def test_select_proxy_returns_random_from_list(self): + """Test _select_proxy returns proxy from list.""" + proxies = ["http://proxy1:8080", "http://proxy2:8080"] + client = BaseHTTPClient(base_url="https://example.com", proxies=proxies) + + selected = client._select_proxy() + self.assertIn(selected, proxies) + + def test_current_proxy_property(self): + """Test current_proxy property is None before session creation.""" + proxies = ["http://proxy:8080"] + client = BaseHTTPClient(base_url="https://example.com", proxies=proxies) + + # current_proxy is None until session is created + proxy = client.current_proxy + self.assertIsNone(proxy) + + # After accessing session, proxy should be set + _ = client.session + proxy = client.current_proxy + self.assertEqual(proxy, "http://proxy:8080") + + +def _create_test_excel_certificates() -> bytes: + """Create test Excel file with certificate data.""" + wb = Workbook() + ws = wb.active + + # Header + ws.append( + [ + "issue_date", + "certificate_number", + "expiry_date", + "certificate_file_url", + "organisation_name", + "inn", + "ogrn", + ] + ) + + # Data rows + for i in range(5): + ws.append( + [ + "2024-01-01", + f"CERT-{i:04d}", + "2025-01-01", + f"https://example.com/cert{i}.pdf", + f"Company {i} LLC", + f"123456789{i}", + f"123456789012{i}", + ] + ) + + output = BytesIO() + wb.save(output) + output.seek(0) + return output.read() + + +def _create_test_excel_manufacturers() -> bytes: + """Create test Excel file with manufacturer data.""" + wb = Workbook() + ws = wb.active + + # Header + ws.append(["full_legal_name", "inn", "ogrn", "address"]) + + # Data rows + for i in range(5): + ws.append( + [ + f"Manufacturer {i} LLC", + f"123456789{i}", + f"123456789012{i}", + f"Address {i}, City", + ] + ) + + output = BytesIO() + wb.save(output) + output.seek(0) + return output.read() + + +class IndustrialProductionClientTest(TestCase): + """Tests for IndustrialProductionClient.""" + + def test_client_initialization(self): + """Test client initializes correctly.""" + client = IndustrialProductionClient() + + self.assertIsNone(client.proxies) + self.assertEqual(client.host, "minpromtorg.gov.ru") + + def test_client_with_proxies(self): + """Test client accepts proxy list.""" + proxies = ["http://proxy1:8080", "http://proxy2:8080"] + client = IndustrialProductionClient(proxies=proxies) + + self.assertEqual(client.proxies, proxies) + + def test_context_manager(self): + """Test client works as context manager.""" + with IndustrialProductionClient() as client: + self.assertIsInstance(client, IndustrialProductionClient) + + @patch.object(BaseHTTPClient, "get_json") + @patch.object(BaseHTTPClient, "download_file") + def test_fetch_certificates_success(self, mock_download, mock_get_json): + """Test successful certificate fetching.""" + # Mock API response + mock_get_json.return_value = { + "data": [ + { + "name": "Заключения о подтверждении производства промышленной продукции на территории Российской Федерации", + "files": [ + {"name": "data_resolutions_20240101.xlsx", "url": "/files/test.xlsx"}, + ], + } + ] + } + + # Mock Excel download + mock_download.return_value = _create_test_excel_certificates() + + with IndustrialProductionClient() as client: + certificates = client.fetch_certificates() + + self.assertEqual(len(certificates), 5) + self.assertIsInstance(certificates[0], IndustrialCertificate) + self.assertEqual(certificates[0].certificate_number, "CERT-0000") + + @patch.object(BaseHTTPClient, "get_json") + def test_fetch_certificates_no_files(self, mock_get_json): + """Test returns empty list when no files found.""" + mock_get_json.return_value = {"data": []} + + with IndustrialProductionClient() as client: + certificates = client.fetch_certificates() + + self.assertEqual(certificates, []) + + @patch.object(BaseHTTPClient, "get_json") + def test_get_latest_file_url_selects_newest(self, mock_get_json): + """Test selects file with latest date.""" + mock_get_json.return_value = { + "data": [ + { + "name": "Заключения о подтверждении производства промышленной продукции на территории Российской Федерации", + "files": [ + {"name": "data_resolutions_20240101.xlsx", "url": "/files/old.xlsx"}, + {"name": "data_resolutions_20240315.xlsx", "url": "/files/new.xlsx"}, + {"name": "data_resolutions_20240201.xlsx", "url": "/files/mid.xlsx"}, + ], + } + ] + } + + client = IndustrialProductionClient() + files_data = client._fetch_files_list() + url = client._get_latest_file_url(files_data) + + self.assertIn("new.xlsx", url) + + def test_parse_row_valid(self): + """Test parsing valid row.""" + client = IndustrialProductionClient() + row = ( + "2024-01-01", + "CERT-123", + "2025-01-01", + "https://example.com/cert.pdf", + "Test Company", + "1234567890", + "1234567890123", + ) + + result = client._parse_row(row) + + self.assertIsInstance(result, IndustrialCertificate) + self.assertEqual(result.certificate_number, "CERT-123") + self.assertEqual(result.inn, "1234567890") + + def test_parse_row_invalid(self): + """Test parsing invalid row returns None.""" + client = IndustrialProductionClient() + row = ("only", "two") # Not enough columns + + result = client._parse_row(row) + + self.assertIsNone(result) + + +class ManufacturesClientTest(TestCase): + """Tests for ManufacturesClient.""" + + def test_client_initialization(self): + """Test client initializes correctly.""" + client = ManufacturesClient() + + self.assertIsNone(client.proxies) + self.assertEqual(client.host, "minpromtorg.gov.ru") + + def test_client_with_proxies(self): + """Test client accepts proxy list.""" + proxies = ["http://proxy1:8080", "http://proxy2:8080"] + client = ManufacturesClient(proxies=proxies) + + self.assertEqual(client.proxies, proxies) + + def test_context_manager(self): + """Test client works as context manager.""" + with ManufacturesClient() as client: + self.assertIsInstance(client, ManufacturesClient) + + @patch.object(BaseHTTPClient, "get_json") + @patch.object(BaseHTTPClient, "download_file") + def test_fetch_manufacturers_success(self, mock_download, mock_get_json): + """Test successful manufacturer fetching.""" + # Mock API response + mock_get_json.return_value = { + "data": [ + { + "name": "Производители промышленной продукции", + "files": [ + {"name": "data_orgs_20240101.xlsx", "url": "/files/test.xlsx"}, + ], + } + ] + } + + # Mock Excel download + mock_download.return_value = _create_test_excel_manufacturers() + + with ManufacturesClient() as client: + manufacturers = client.fetch_manufacturers() + + self.assertEqual(len(manufacturers), 5) + self.assertIsInstance(manufacturers[0], Manufacturer) + self.assertEqual(manufacturers[0].full_legal_name, "Manufacturer 0 LLC") + + @patch.object(BaseHTTPClient, "get_json") + def test_fetch_manufacturers_no_files(self, mock_get_json): + """Test returns empty list when no files found.""" + mock_get_json.return_value = {"data": []} + + with ManufacturesClient() as client: + manufacturers = client.fetch_manufacturers() + + self.assertEqual(manufacturers, []) + + @patch.object(BaseHTTPClient, "get_json") + def test_get_latest_file_url_selects_newest(self, mock_get_json): + """Test selects file with latest date.""" + mock_get_json.return_value = { + "data": [ + { + "name": "Производители промышленной продукции", + "files": [ + {"name": "data_orgs_20240101.xlsx", "url": "/files/old.xlsx"}, + {"name": "data_orgs_20240315.xlsx", "url": "/files/new.xlsx"}, + {"name": "data_orgs_20240201.xlsx", "url": "/files/mid.xlsx"}, + ], + } + ] + } + + client = ManufacturesClient() + files_data = client._fetch_files_list() + url = client._get_latest_file_url(files_data) + + self.assertIn("new.xlsx", url) + + def test_parse_row_valid(self): + """Test parsing valid row.""" + client = ManufacturesClient() + row = ("Test Company LLC", "1234567890", "1234567890123", "Test Address") + + result = client._parse_row(row) + + self.assertIsInstance(result, Manufacturer) + self.assertEqual(result.full_legal_name, "Test Company LLC") + self.assertEqual(result.inn, "1234567890") + + def test_parse_row_without_address(self): + """Test parsing row without address.""" + client = ManufacturesClient() + row = ("Test Company LLC", "1234567890", "1234567890123") + + result = client._parse_row(row) + + self.assertIsInstance(result, Manufacturer) + self.assertEqual(result.address, "") + + +@tag("integration", "slow", "network") +class IndustrialProductionClientIntegrationTest(TestCase): + """ + Интеграционные тесты с реальной загрузкой данных. + + ВНИМАНИЕ: Эти тесты делают реальные HTTP запросы к внешним серверам. + Запускать с тегом: python manage.py test --tag=integration + """ + + def test_fetch_certificates_real_data(self): + """ + Интеграционный тест: реальная загрузка сертификатов с gisp.gov.ru. + + Этот тест: + 1. Подключается к реальному API + 2. Скачивает Excel файл + 3. Парсит данные + 4. Проверяет структуру результата + + Тест может занять время и зависит от доступности внешнего сервера. + """ + try: + with IndustrialProductionClient(timeout=120) as client: + certificates = client.fetch_certificates() + + # Проверяем что данные получены + self.assertIsInstance(certificates, list) + + # Если данные есть - проверяем структуру + if certificates: + cert = certificates[0] + self.assertIsInstance(cert, IndustrialCertificate) + self.assertIsNotNone(cert.certificate_number) + self.assertIsNotNone(cert.inn) + self.assertIsNotNone(cert.organisation_name) + + # Логируем для информации + print(f"\n[INTEGRATION] Loaded {len(certificates)} certificates") + print(f"[INTEGRATION] First certificate: {cert.certificate_number}") + print(f"[INTEGRATION] Organisation: {cert.organisation_name}") + else: + print("\n[INTEGRATION] No certificates found (API may be unavailable)") + + except HTTPClientError as e: + # API может быть недоступен - это ожидаемое поведение для интеграционных тестов + self.skipTest(f"External API unavailable: {e}") + + +@tag("integration", "slow", "network") +class ManufacturesClientIntegrationTest(TestCase): + """ + Интеграционные тесты для клиента производителей. + + ВНИМАНИЕ: Эти тесты делают реальные HTTP запросы к внешним серверам. + Запускать с тегом: python manage.py test --tag=integration + """ + + def test_fetch_manufacturers_real_data(self): + """ + Интеграционный тест: реальная загрузка производителей с gisp.gov.ru. + """ + try: + with ManufacturesClient(timeout=120) as client: + manufacturers = client.fetch_manufacturers() + + # Проверяем что данные получены + self.assertIsInstance(manufacturers, list) + + # Если данные есть - проверяем структуру + if manufacturers: + m = manufacturers[0] + self.assertIsInstance(m, Manufacturer) + self.assertIsNotNone(m.full_legal_name) + self.assertIsNotNone(m.inn) + + # Логируем для информации + print(f"\n[INTEGRATION] Loaded {len(manufacturers)} manufacturers") + print(f"[INTEGRATION] First manufacturer: {m.full_legal_name}") + print(f"[INTEGRATION] INN: {m.inn}") + else: + print("\n[INTEGRATION] No manufacturers found (API may be unavailable)") + + except HTTPClientError as e: + # API может быть недоступен - это ожидаемое поведение для интеграционных тестов + self.skipTest(f"External API unavailable: {e}") + + +def _create_test_xml_inspections() -> bytes: + """Create test XML file with inspection data.""" + xml_content = """ + + + 772024000001 + 7701234567 + 1027700000001 + ООО "Тест Компания 1" + Роспотребнадзор + плановая + документарная + 2024-01-15 + 2024-01-30 + завершена + 294-ФЗ + нарушения не выявлены + + + 772024000002 + 7702345678 + 1027700000002 + АО "Тест Компания 2" + Ростехнадзор + внеплановая + выездная + 2024-02-01 + 2024-02-15 + завершена + 248-ФЗ + выявлены нарушения + +""" + return xml_content.encode("utf-8") + + +def _create_test_xml_inspections_russian_tags() -> bytes: + """Create test XML with Russian tag names.""" + xml_content = """ +<Проверки> + <КНМ> + <УчетныйНомер>772024000003 + <ИНН>7703456789 + <ОГРН>1027700000003 + <Наименование>ПАО "Тест Компания 3" + <КонтрольныйОрган>МЧС России + <ТипПроверки>плановая + <ФормаПроверки>документарная и выездная + <ДатаНачала>2024-03-01 + <ДатаОкончания>2024-03-20 + <Статус>в процессе + <ПравовоеОснование>294-ФЗ + +""" + return xml_content.encode("utf-8") + + +class ProverkiClientTest(TestCase): + """Tests for ProverkiClient.""" + + def test_client_initialization(self): + """Test client initializes correctly.""" + client = ProverkiClient() + + self.assertIsNone(client.proxies) + self.assertEqual(client.host, "proverki.gov.ru") + + def test_client_with_proxies(self): + """Test client accepts proxy list.""" + proxies = ["http://proxy1:8080", "http://proxy2:8080"] + client = ProverkiClient(proxies=proxies) + + self.assertEqual(client.proxies, proxies) + + def test_context_manager(self): + """Test client works as context manager.""" + with ProverkiClient() as client: + self.assertIsInstance(client, ProverkiClient) + + def test_parse_xml_content_english_tags(self): + """Test parsing XML with English tag names.""" + client = ProverkiClient() + xml_content = _create_test_xml_inspections() + + inspections = client._parse_xml_content(xml_content, None) + + self.assertEqual(len(inspections), 2) + self.assertIsInstance(inspections[0], Inspection) + self.assertEqual(inspections[0].registration_number, "772024000001") + self.assertEqual(inspections[0].inn, "7701234567") + self.assertEqual(inspections[0].organisation_name, 'ООО "Тест Компания 1"') + self.assertEqual(inspections[0].control_authority, "Роспотребнадзор") + self.assertEqual(inspections[0].inspection_type, "плановая") + self.assertEqual(inspections[0].legal_basis, "294-ФЗ") + + def test_parse_xml_content_russian_tags(self): + """Test parsing XML with Russian tag names.""" + client = ProverkiClient() + xml_content = _create_test_xml_inspections_russian_tags() + + inspections = client._parse_xml_content(xml_content, None) + + self.assertEqual(len(inspections), 1) + self.assertIsInstance(inspections[0], Inspection) + self.assertEqual(inspections[0].registration_number, "772024000003") + self.assertEqual(inspections[0].inn, "7703456789") + self.assertEqual(inspections[0].control_authority, "МЧС России") + + def test_parse_xml_record_with_attributes(self): + """Test parsing XML record with attributes instead of child elements.""" + from xml.etree import ElementTree as ET + + client = ProverkiClient() + xml_str = '' + element = ET.fromstring(xml_str) + + result = client._parse_xml_record(element) + + self.assertIsNotNone(result) + self.assertEqual(result.inn, "1234567890") + self.assertEqual(result.registration_number, "TEST123") + + def test_parse_xml_record_invalid(self): + """Test parsing invalid XML record returns None.""" + from xml.etree import ElementTree as ET + + client = ProverkiClient() + xml_str = "" + element = ET.fromstring(xml_str) + + result = client._parse_xml_record(element) + + self.assertIsNone(result) + + def test_parse_windows_1251_encoding(self): + """Test parsing XML with Windows-1251 encoding.""" + client = ProverkiClient() + xml_content = """ + + + 1234567890 + TEST001 + Компания + +""".encode( + "windows-1251" + ) + + inspections = client._parse_xml_content(xml_content, None) + + self.assertEqual(len(inspections), 1) + self.assertEqual(inspections[0].organisation_name, "Компания") + + @patch.object(BaseHTTPClient, "download_file") + @patch.object(ProverkiClient, "_discover_data_files") + def test_fetch_inspections_with_file_url(self, mock_discover, mock_download): + """Test fetching inspections with direct file URL.""" + mock_download.return_value = _create_test_xml_inspections() + + with ProverkiClient() as client: + inspections = client.fetch_inspections( + file_url="https://proverki.gov.ru/opendata/test.xml" + ) + + self.assertEqual(len(inspections), 2) + mock_discover.assert_not_called() # Should not discover files when URL provided + + @patch.object(ProverkiClient, "_discover_data_files") + def test_fetch_inspections_no_files(self, mock_discover): + """Test returns empty list when no files found.""" + mock_discover.return_value = [] + + with ProverkiClient() as client: + inspections = client.fetch_inspections(year=2025) + + self.assertEqual(inspections, []) + + +@tag("integration", "slow", "network") +class ProverkiClientIntegrationTest(TestCase): + """ + Интеграционные тесты для клиента proverki.gov.ru. + + ВНИМАНИЕ: Эти тесты делают реальные HTTP запросы к внешним серверам. + Запускать с тегом: python manage.py test --tag=integration + """ + + def test_fetch_inspections_real_data(self): + """ + Интеграционный тест: реальная загрузка проверок с proverki.gov.ru. + """ + try: + with ProverkiClient(timeout=120) as client: + inspections = client.fetch_inspections(year=2025) + + # Проверяем что данные получены + self.assertIsInstance(inspections, list) + + # Если данные есть - проверяем структуру + if inspections: + insp = inspections[0] + self.assertIsInstance(insp, Inspection) + self.assertIsNotNone(insp.registration_number) + self.assertIsNotNone(insp.inn) + + # Логируем для информации + print(f"\n[INTEGRATION] Loaded {len(inspections)} inspections") + print(f"[INTEGRATION] First inspection: {insp.registration_number}") + print(f"[INTEGRATION] Organisation: {insp.organisation_name}") + print(f"[INTEGRATION] Control authority: {insp.control_authority}") + else: + print( + "\n[INTEGRATION] No inspections found " + "(API may be unavailable or data format changed)" + ) + + except HTTPClientError as e: + # API может быть недоступен + self.skipTest(f"External API unavailable: {e}") diff --git a/tests/apps/parsers/test_models.py b/tests/apps/parsers/test_models.py new file mode 100644 index 0000000..56c0e68 --- /dev/null +++ b/tests/apps/parsers/test_models.py @@ -0,0 +1,141 @@ +"""Tests for parsers models.""" + +from django.test import TestCase + +from apps.parsers.models import ( + IndustrialCertificateRecord, + ManufacturerRecord, + ParserLoadLog, + Proxy, +) + +from .factories import ( + IndustrialCertificateRecordFactory, + ManufacturerRecordFactory, + ParserLoadLogFactory, + ProxyFactory, +) + + +class ProxyModelTest(TestCase): + """Tests for Proxy model.""" + + def test_create_proxy(self): + """Test creating proxy record.""" + proxy = ProxyFactory() + + self.assertIsInstance(proxy, Proxy) + self.assertIn("://", proxy.address) # http://, https://, socks5:// + self.assertTrue(proxy.is_active) + self.assertEqual(proxy.fail_count, 0) + + def test_proxy_str(self): + """Test proxy string representation.""" + proxy = ProxyFactory(address="http://proxy:8080", is_active=True) + self.assertEqual(str(proxy), "http://proxy:8080 (active)") + + proxy.is_active = False + self.assertEqual(str(proxy), "http://proxy:8080 (inactive)") + + def test_proxy_ordering(self): + """Test proxy ordering by fail_count.""" + proxy1 = ProxyFactory(fail_count=5) + proxy2 = ProxyFactory(fail_count=0) + proxy3 = ProxyFactory(fail_count=10) + + proxies = list(Proxy.objects.all()) + self.assertEqual(proxies[0], proxy2) # fail_count=0 + self.assertEqual(proxies[1], proxy1) # fail_count=5 + self.assertEqual(proxies[2], proxy3) # fail_count=10 + + def test_proxy_unique_address(self): + """Test proxy address uniqueness.""" + ProxyFactory(address="http://unique:8080") + + from django.db import IntegrityError + + with self.assertRaises(IntegrityError): + ProxyFactory(address="http://unique:8080") + + +class ParserLoadLogModelTest(TestCase): + """Tests for ParserLoadLog model.""" + + def test_create_load_log(self): + """Test creating load log record.""" + log = ParserLoadLogFactory() + + self.assertIsInstance(log, ParserLoadLog) + self.assertIsNotNone(log.batch_id) + self.assertIn(log.source, [c[0] for c in ParserLoadLog.Source.choices]) + + def test_load_log_str(self): + """Test load log string representation.""" + log = ParserLoadLogFactory( + batch_id=42, source=ParserLoadLog.Source.INDUSTRIAL, records_count=100 + ) + self.assertIn("42", str(log)) + self.assertIn("100", str(log)) + + def test_load_log_timestamps(self): + """Test load log has timestamps from mixin.""" + log = ParserLoadLogFactory() + + self.assertIsNotNone(log.created_at) + self.assertIsNotNone(log.updated_at) + + +class IndustrialCertificateRecordModelTest(TestCase): + """Tests for IndustrialCertificateRecord model.""" + + def test_create_certificate(self): + """Test creating certificate record.""" + cert = IndustrialCertificateRecordFactory() + + self.assertIsInstance(cert, IndustrialCertificateRecord) + self.assertIsNotNone(cert.certificate_number) + self.assertIsNotNone(cert.inn) + self.assertIsNotNone(cert.ogrn) + + def test_certificate_str(self): + """Test certificate string representation.""" + cert = IndustrialCertificateRecordFactory( + certificate_number="CERT-123", organisation_name="Test Company LLC" + ) + self.assertIn("CERT-123", str(cert)) + self.assertIn("Test Company", str(cert)) + + def test_certificate_timestamps(self): + """Test certificate has timestamps from mixin.""" + cert = IndustrialCertificateRecordFactory() + + self.assertIsNotNone(cert.created_at) + self.assertIsNotNone(cert.updated_at) + + +class ManufacturerRecordModelTest(TestCase): + """Tests for ManufacturerRecord model.""" + + def test_create_manufacturer(self): + """Test creating manufacturer record.""" + manufacturer = ManufacturerRecordFactory() + + self.assertIsInstance(manufacturer, ManufacturerRecord) + self.assertIsNotNone(manufacturer.full_legal_name) + self.assertIsNotNone(manufacturer.inn) + self.assertIsNotNone(manufacturer.ogrn) + + def test_manufacturer_str(self): + """Test manufacturer string representation.""" + manufacturer = ManufacturerRecordFactory( + inn="1234567890", full_legal_name="Test Manufacturing Company" + ) + self.assertIn("1234567890", str(manufacturer)) + self.assertIn("Test Manufacturing", str(manufacturer)) + + def test_manufacturer_timestamps(self): + """Test manufacturer has timestamps from mixin.""" + manufacturer = ManufacturerRecordFactory() + + self.assertIsNotNone(manufacturer.created_at) + self.assertIsNotNone(manufacturer.updated_at) diff --git a/tests/apps/parsers/test_services.py b/tests/apps/parsers/test_services.py new file mode 100644 index 0000000..0d41de9 --- /dev/null +++ b/tests/apps/parsers/test_services.py @@ -0,0 +1,677 @@ +"""Tests for parsers services.""" + +from django.test import TestCase + +from faker import Faker + +from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer +from apps.parsers.clients.proverki.schemas import Inspection +from apps.parsers.models import ( + IndustrialCertificateRecord, + InspectionRecord, + ManufacturerRecord, + ParserLoadLog, + Proxy, +) +from apps.parsers.services import ( + IndustrialCertificateService, + InspectionService, + ManufacturerService, + ParserLoadLogService, + ProxyService, +) + +from .factories import ( + IndustrialCertificateRecordFactory, + InspectionRecordFactory, + ManufacturerRecordFactory, + ParserLoadLogFactory, + ProxyFactory, +) + +fake = Faker("ru_RU") + + +class ProxyServiceTest(TestCase): + """Tests for ProxyService.""" + + def test_get_active_proxies_empty(self): + """Test getting active proxies when none exist.""" + proxies = ProxyService.get_active_proxies() + self.assertEqual(proxies, []) + + def test_get_active_proxies_with_data(self): + """Test getting active proxies returns only active ones.""" + proxy1 = ProxyFactory(is_active=True) + proxy2 = ProxyFactory(is_active=True) + ProxyFactory(is_active=False) # Inactive - should not be returned + + proxies = ProxyService.get_active_proxies() + + self.assertEqual(len(proxies), 2) + self.assertIn(proxy1.address, proxies) + self.assertIn(proxy2.address, proxies) + + def test_get_active_proxies_or_none_empty(self): + """Test get_active_proxies_or_none returns None when no proxies.""" + result = ProxyService.get_active_proxies_or_none() + self.assertIsNone(result) + + def test_get_active_proxies_or_none_with_data(self): + """Test get_active_proxies_or_none returns list when proxies exist.""" + ProxyFactory(is_active=True) + + result = ProxyService.get_active_proxies_or_none() + + self.assertIsNotNone(result) + self.assertEqual(len(result), 1) + + def test_mark_used(self): + """Test marking proxy as used updates timestamp.""" + proxy = ProxyFactory() + self.assertIsNone(proxy.last_used_at) + + ProxyService.mark_used(proxy.address) + + proxy.refresh_from_db() + self.assertIsNotNone(proxy.last_used_at) + + def test_mark_failed(self): + """Test marking proxy as failed increases fail count.""" + proxy = ProxyFactory(fail_count=0) + + ProxyService.mark_failed(proxy.address) + + proxy.refresh_from_db() + self.assertEqual(proxy.fail_count, 1) + + def test_deactivate(self): + """Test deactivating proxy.""" + proxy = ProxyFactory(is_active=True) + + ProxyService.deactivate(proxy.address) + + proxy.refresh_from_db() + self.assertFalse(proxy.is_active) + + def test_add_proxy(self): + """Test adding new proxy.""" + address = "http://new-proxy:8080" + description = "Test proxy" + + proxy = ProxyService.add_proxy(address, description) + + self.assertEqual(proxy.address, address) + self.assertEqual(proxy.description, description) + self.assertTrue(proxy.is_active) + + def test_add_proxy_idempotent(self): + """Test adding existing proxy returns existing record.""" + address = "http://existing:8080" + existing = ProxyFactory(address=address, description="Original") + + proxy = ProxyService.add_proxy(address, "New description") + + self.assertEqual(proxy.id, existing.id) + self.assertEqual(proxy.description, "Original") # Not updated + + def test_add_proxies(self): + """Test bulk adding proxies.""" + addresses = [ + "http://proxy1:8080", + "http://proxy2:8080", + "http://proxy3:8080", + ] + + created = ProxyService.add_proxies(addresses) + + self.assertEqual(created, 3) + self.assertEqual(Proxy.objects.count(), 3) + + def test_add_proxies_skips_existing(self): + """Test bulk add skips existing proxies.""" + ProxyFactory(address="http://existing:8080") + addresses = [ + "http://existing:8080", # Already exists + "http://new:8080", + ] + + created = ProxyService.add_proxies(addresses) + + self.assertEqual(created, 1) + self.assertEqual(Proxy.objects.count(), 2) + + +class ParserLoadLogServiceTest(TestCase): + """Tests for ParserLoadLogService.""" + + def test_get_next_batch_id_first(self): + """Test getting first batch_id for new source.""" + batch_id = ParserLoadLogService.get_next_batch_id(ParserLoadLog.Source.INDUSTRIAL) + self.assertEqual(batch_id, 1) + + def test_get_next_batch_id_increment(self): + """Test batch_id increments correctly.""" + ParserLoadLogFactory(batch_id=5, source=ParserLoadLog.Source.INDUSTRIAL) + ParserLoadLogFactory(batch_id=3, source=ParserLoadLog.Source.INDUSTRIAL) + + batch_id = ParserLoadLogService.get_next_batch_id(ParserLoadLog.Source.INDUSTRIAL) + self.assertEqual(batch_id, 6) + + def test_get_next_batch_id_per_source(self): + """Test batch_id is tracked per source.""" + ParserLoadLogFactory(batch_id=10, source=ParserLoadLog.Source.INDUSTRIAL) + ParserLoadLogFactory(batch_id=5, source=ParserLoadLog.Source.MANUFACTURES) + + industrial_batch = ParserLoadLogService.get_next_batch_id( + ParserLoadLog.Source.INDUSTRIAL + ) + manufactures_batch = ParserLoadLogService.get_next_batch_id( + ParserLoadLog.Source.MANUFACTURES + ) + + self.assertEqual(industrial_batch, 11) + self.assertEqual(manufactures_batch, 6) + + def test_create_load_log(self): + """Test creating load log.""" + log = ParserLoadLogService.create_load_log( + source=ParserLoadLog.Source.INDUSTRIAL, + batch_id=1, + records_count=100, + status="success", + ) + + self.assertIsInstance(log, ParserLoadLog) + self.assertEqual(log.source, ParserLoadLog.Source.INDUSTRIAL) + self.assertEqual(log.batch_id, 1) + self.assertEqual(log.records_count, 100) + self.assertEqual(log.status, "success") + + def test_mark_failed(self): + """Test marking log as failed.""" + log = ParserLoadLogFactory(status="success") + + ParserLoadLogService.mark_failed(log, "Connection error") + + log.refresh_from_db() + self.assertEqual(log.status, "failed") + self.assertEqual(log.error_message, "Connection error") + + def test_update_records_count(self): + """Test updating records count.""" + log = ParserLoadLogFactory(records_count=0) + + ParserLoadLogService.update_records_count(log, 250) + + log.refresh_from_db() + self.assertEqual(log.records_count, 250) + + +class IndustrialCertificateServiceTest(TestCase): + """Tests for IndustrialCertificateService.""" + + def test_save_certificates_empty(self): + """Test saving empty list returns 0.""" + count = IndustrialCertificateService.save_certificates([], batch_id=1) + self.assertEqual(count, 0) + + def test_save_certificates(self): + """Test saving certificates from dataclass.""" + certificates = [ + IndustrialCertificate( + issue_date="2024-01-01", + certificate_number=f"CERT-{i}", + expiry_date="2025-01-01", + certificate_file_url=f"https://example.com/cert{i}.pdf", + organisation_name=f"Company {i}", + inn=f"123456789{i}", + ogrn=f"123456789012{i}", + ) + for i in range(5) + ] + + count = IndustrialCertificateService.save_certificates(certificates, batch_id=1) + + self.assertEqual(count, 5) + self.assertEqual(IndustrialCertificateRecord.objects.count(), 5) + + def test_save_certificates_with_chunk_size(self): + """Test saving certificates in chunks.""" + certificates = [ + IndustrialCertificate( + issue_date="2024-01-01", + certificate_number=f"CERT-{i}", + expiry_date="2025-01-01", + certificate_file_url=f"https://example.com/cert{i}.pdf", + organisation_name=f"Company {i}", + inn=f"12345678{i:02d}", + ogrn=f"1234567890{i:03d}", + ) + for i in range(10) + ] + + count = IndustrialCertificateService.save_certificates( + certificates, batch_id=1, chunk_size=3 + ) + + self.assertEqual(count, 10) + + def test_find_by_inn(self): + """Test finding certificates by INN.""" + IndustrialCertificateRecordFactory( + inn="1111111111", certificate_number="CERT-A1", load_batch=1 + ) + IndustrialCertificateRecordFactory( + inn="1111111111", certificate_number="CERT-A2", load_batch=2 + ) + IndustrialCertificateRecordFactory( + inn="2222222222", certificate_number="CERT-B1", load_batch=1 + ) + + results = IndustrialCertificateService.find_by_inn("1111111111") + self.assertEqual(results.count(), 2) + + results_batch1 = IndustrialCertificateService.find_by_inn("1111111111", batch_id=1) + self.assertEqual(results_batch1.count(), 1) + + def test_find_by_certificate_number(self): + """Test finding certificate by number.""" + IndustrialCertificateRecordFactory(certificate_number="CERT-UNIQUE") + IndustrialCertificateRecordFactory(certificate_number="CERT-OTHER") + + results = IndustrialCertificateService.find_by_certificate_number("CERT-UNIQUE") + self.assertEqual(results.count(), 1) + + def test_save_certificates_deduplication(self): + """Test saving certificates skips duplicates by certificate_number.""" + # Create initial certificate + initial = [ + IndustrialCertificate( + issue_date="2024-01-01", + certificate_number="CERT-DEDUP-001", + expiry_date="2025-01-01", + certificate_file_url="https://example.com/old.pdf", + organisation_name="Old Company Name", + inn="1234567890", + ogrn="1234567890123", + ) + ] + count1 = IndustrialCertificateService.save_certificates(initial, batch_id=1) + self.assertEqual(count1, 1) + self.assertEqual(IndustrialCertificateRecord.objects.count(), 1) + + # Try to save with same certificate_number - should be skipped + duplicate = [ + IndustrialCertificate( + issue_date="2024-06-01", + certificate_number="CERT-DEDUP-001", # Same number - will be skipped + expiry_date="2026-01-01", + certificate_file_url="https://example.com/new.pdf", + organisation_name="New Company Name", + inn="9999999999", + ogrn="9999999999999", + ) + ] + count2 = IndustrialCertificateService.save_certificates(duplicate, batch_id=2) + + # Should still be 1 record (duplicate skipped) + self.assertEqual(IndustrialCertificateRecord.objects.count(), 1) + + # Verify original data preserved + record = IndustrialCertificateRecord.objects.first() + self.assertEqual(record.organisation_name, "Old Company Name") + self.assertEqual(record.inn, "1234567890") + self.assertEqual(record.load_batch, 1) # Original batch + + +class ManufacturerServiceTest(TestCase): + """Tests for ManufacturerService.""" + + def test_save_manufacturers_empty(self): + """Test saving empty list returns 0.""" + count = ManufacturerService.save_manufacturers([], batch_id=1) + self.assertEqual(count, 0) + + def test_save_manufacturers(self): + """Test saving manufacturers from dataclass.""" + manufacturers = [ + Manufacturer( + full_legal_name=f"Company {i} LLC", + inn=f"123456789{i}", + ogrn=f"123456789012{i}", + address=f"Address {i}", + ) + for i in range(5) + ] + + count = ManufacturerService.save_manufacturers(manufacturers, batch_id=1) + + self.assertEqual(count, 5) + self.assertEqual(ManufacturerRecord.objects.count(), 5) + + def test_save_manufacturers_with_chunk_size(self): + """Test saving manufacturers in chunks.""" + manufacturers = [ + Manufacturer( + full_legal_name=f"Company {i}", + inn=f"12345678{i:02d}", + ogrn=f"1234567890{i:03d}", + address=f"Address {i}", + ) + for i in range(10) + ] + + count = ManufacturerService.save_manufacturers( + manufacturers, batch_id=1, chunk_size=3 + ) + + self.assertEqual(count, 10) + + def test_find_by_inn(self): + """Test finding manufacturers by INN.""" + ManufacturerRecordFactory(inn="1111111111", load_batch=1) + ManufacturerRecordFactory(inn="2222222222", load_batch=1) + ManufacturerRecordFactory(inn="3333333333", load_batch=2) + + results = ManufacturerService.find_by_inn("1111111111") + self.assertEqual(results.count(), 1) + + def test_find_by_inn_with_batch_filter(self): + """Test finding manufacturers by INN with batch filter.""" + ManufacturerRecordFactory(inn="4444444444", load_batch=1) + ManufacturerRecordFactory(inn="5555555555", load_batch=2) + + results_batch1 = ManufacturerService.find_by_inn("4444444444", batch_id=1) + self.assertEqual(results_batch1.count(), 1) + + results_batch2 = ManufacturerService.find_by_inn("4444444444", batch_id=2) + self.assertEqual(results_batch2.count(), 0) + + def test_find_by_ogrn(self): + """Test finding manufacturers by OGRN.""" + ManufacturerRecordFactory(ogrn="1234567890123") + ManufacturerRecordFactory(ogrn="9999999999999") + + results = ManufacturerService.find_by_ogrn("1234567890123") + self.assertEqual(results.count(), 1) + + def test_save_manufacturers_deduplication(self): + """Test saving manufacturers skips duplicates by INN.""" + # Create initial manufacturer + initial = [ + Manufacturer( + full_legal_name="Old Company Name LLC", + inn="7777777777", + ogrn="1234567890123", + address="Old Address", + ) + ] + count1 = ManufacturerService.save_manufacturers(initial, batch_id=1) + self.assertEqual(count1, 1) + self.assertEqual(ManufacturerRecord.objects.count(), 1) + + # Try to save with same INN - should be skipped + duplicate = [ + Manufacturer( + full_legal_name="New Company Name LLC", + inn="7777777777", # Same INN - will be skipped + ogrn="9999999999999", + address="New Address", + ) + ] + count2 = ManufacturerService.save_manufacturers(duplicate, batch_id=2) + + # Should still be 1 record (duplicate skipped) + self.assertEqual(ManufacturerRecord.objects.count(), 1) + + # Verify original data preserved + record = ManufacturerRecord.objects.first() + self.assertEqual(record.full_legal_name, "Old Company Name LLC") + self.assertEqual(record.ogrn, "1234567890123") + self.assertEqual(record.address, "Old Address") + self.assertEqual(record.load_batch, 1) # Original batch + + +class InspectionServiceTest(TestCase): + """Tests for InspectionService.""" + + def test_save_inspections_empty(self): + """Test saving empty list returns 0.""" + count = InspectionService.save_inspections([], batch_id=1) + self.assertEqual(count, 0) + + def test_save_inspections(self): + """Test saving inspections from dataclass.""" + inspections = [ + Inspection( + registration_number=f"77202400000{i}", + inn=f"770{i}234567", + ogrn=f"102770000000{i}", + organisation_name=f"Компания {i}", + control_authority="Роспотребнадзор", + inspection_type="плановая", + inspection_form="документарная", + start_date="2024-01-15", + end_date="2024-01-30", + status="завершена", + legal_basis="294-ФЗ", + result="нарушения не выявлены", + ) + for i in range(5) + ] + + count = InspectionService.save_inspections(inspections, batch_id=1) + + self.assertEqual(count, 5) + self.assertEqual(InspectionRecord.objects.count(), 5) + + def test_save_inspections_with_chunk_size(self): + """Test saving inspections in chunks.""" + inspections = [ + Inspection( + registration_number=f"7720240000{i:02d}", + inn=f"770{i:02d}34567", + ogrn=f"10277000000{i:02d}", + organisation_name=f"Компания {i}", + control_authority="Ростехнадзор", + inspection_type="внеплановая", + inspection_form="выездная", + start_date="2024-02-01", + end_date="2024-02-15", + status="завершена", + legal_basis="248-ФЗ", + ) + for i in range(10) + ] + + count = InspectionService.save_inspections( + inspections, batch_id=1, chunk_size=3 + ) + + self.assertEqual(count, 10) + + def test_find_by_inn(self): + """Test finding inspections by INN.""" + InspectionRecordFactory(inn="1111111111", load_batch=1) + InspectionRecordFactory(inn="1111111111", load_batch=2) + InspectionRecordFactory(inn="2222222222", load_batch=1) + + results = InspectionService.find_by_inn("1111111111") + self.assertEqual(results.count(), 2) + + results_batch1 = InspectionService.find_by_inn("1111111111", batch_id=1) + self.assertEqual(results_batch1.count(), 1) + + def test_find_by_registration_number(self): + """Test finding inspection by registration number.""" + InspectionRecordFactory(registration_number="772024000001") + InspectionRecordFactory(registration_number="772024000002") + + results = InspectionService.find_by_registration_number("772024000001") + self.assertEqual(results.count(), 1) + + def test_find_by_control_authority(self): + """Test finding inspections by control authority.""" + InspectionRecordFactory(control_authority="Роспотребнадзор", load_batch=1) + InspectionRecordFactory( + control_authority="Управление Роспотребнадзора по г. Москве", load_batch=1 + ) + InspectionRecordFactory(control_authority="Ростехнадзор", load_batch=1) + + results = InspectionService.find_by_control_authority("Роспотребнадзор") + self.assertEqual(results.count(), 2) + + results_batch1 = InspectionService.find_by_control_authority( + "Роспотребнадзор", batch_id=1 + ) + self.assertEqual(results_batch1.count(), 2) + + def test_save_inspections_deduplication(self): + """Test saving inspections skips duplicates by registration_number.""" + # Create initial inspection + initial = [ + Inspection( + registration_number="DEDUP-REG-001", + inn="1234567890", + ogrn="1234567890123", + organisation_name="Old Organisation", + control_authority="Роспотребнадзор", + inspection_type="плановая", + inspection_form="документарная", + start_date="2024-01-01", + end_date="2024-01-15", + status="завершена", + legal_basis="294-ФЗ", + result="нарушения не выявлены", + ) + ] + count1 = InspectionService.save_inspections(initial, batch_id=1) + self.assertEqual(count1, 1) + self.assertEqual(InspectionRecord.objects.count(), 1) + + # Try to save with same registration_number - should be skipped + duplicate = [ + Inspection( + registration_number="DEDUP-REG-001", # Same number - will be skipped + inn="9999999999", + ogrn="9999999999999", + organisation_name="New Organisation", + control_authority="Ростехнадзор", + inspection_type="внеплановая", + inspection_form="выездная", + start_date="2024-06-01", + end_date="2024-06-30", + status="в процессе", + legal_basis="248-ФЗ", + result="выявлены нарушения", + ) + ] + count2 = InspectionService.save_inspections(duplicate, batch_id=2) + + # Should still be 1 record (duplicate skipped) + self.assertEqual(InspectionRecord.objects.count(), 1) + + # Verify original data preserved + record = InspectionRecord.objects.first() + self.assertEqual(record.organisation_name, "Old Organisation") + self.assertEqual(record.inn, "1234567890") + self.assertEqual(record.control_authority, "Роспотребнадзор") + self.assertEqual(record.status, "завершена") + self.assertEqual(record.load_batch, 1) # Original batch + + +from django.test import tag + +from apps.parsers.clients.base import HTTPClientError +from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient + + +@tag("integration", "slow", "network", "e2e") +class EndToEndIntegrationTest(TestCase): + """ + End-to-end интеграционные тесты полного flow. + + Тестирует: Загрузка с API -> Парсинг -> Сохранение в БД -> Проверка. + Запуск: uv run python run_tests.py tests.apps.parsers.test_services.EndToEndIntegrationTest + """ + + def test_full_flow_fetch_and_save_certificates(self): + """ + Полный E2E тест: загрузка сертификатов и сохранение в БД. + + 1. Загружаем данные с реального API + 2. Создаём лог загрузки + 3. Сохраняем первые N записей в БД + 4. Проверяем что данные корректно сохранились + """ + try: + # 1. Загружаем данные с API + print("\n[E2E] Step 1: Fetching certificates from API...") + with IndustrialProductionClient(timeout=120) as client: + all_certificates = client.fetch_certificates() + + if not all_certificates: + self.skipTest("No certificates returned from API") + + print(f"[E2E] Loaded {len(all_certificates)} certificates from API") + + # Берём только первые 100 для теста + certificates = all_certificates[:100] + + # 2. Создаём batch_id и лог + print("[E2E] Step 2: Creating load log...") + batch_id = ParserLoadLogService.get_next_batch_id( + ParserLoadLog.Source.INDUSTRIAL + ) + log = ParserLoadLogService.create_load_log( + source=ParserLoadLog.Source.INDUSTRIAL, + batch_id=batch_id, + records_count=0, + ) + print(f"[E2E] Created batch_id={batch_id}") + + # 3. Сохраняем в БД + print("[E2E] Step 3: Saving certificates to database...") + saved_count = IndustrialCertificateService.save_certificates( + certificates, batch_id=batch_id + ) + ParserLoadLogService.update_records_count(log, saved_count) + + print(f"[E2E] Saved {saved_count} certificates") + + # 4. Проверяем результат + print("[E2E] Step 4: Verifying saved data...") + + # Проверяем количество + db_count = IndustrialCertificateRecord.objects.filter( + load_batch=batch_id + ).count() + self.assertEqual(db_count, saved_count) + self.assertEqual(db_count, len(certificates)) + + # Проверяем первую запись + first_cert = certificates[0] + db_record = IndustrialCertificateRecord.objects.filter( + load_batch=batch_id, + certificate_number=first_cert.certificate_number, + ).first() + + self.assertIsNotNone(db_record) + self.assertEqual(db_record.inn, first_cert.inn) + self.assertEqual(db_record.ogrn, first_cert.ogrn) + self.assertEqual(db_record.organisation_name, first_cert.organisation_name) + + # Проверяем лог + log.refresh_from_db() + self.assertEqual(log.records_count, saved_count) + self.assertEqual(log.status, "success") + + print("[E2E] ✅ All checks passed!") + print(f"[E2E] Sample record: {db_record.certificate_number}") + print(f"[E2E] Organisation: {db_record.organisation_name}") + print(f"[E2E] INN: {db_record.inn}, OGRN: {db_record.ogrn}") + + except HTTPClientError as e: + self.skipTest(f"External API unavailable: {e}") +