3 Commits

Author SHA1 Message Date
4cbdaf1b60 ch 2025-09-01 13:58:42 +03:00
9459196804 all in docker 2025-09-01 12:24:37 +03:00
ce228d9756 work 2025-09-01 12:08:16 +03:00
73 changed files with 1262 additions and 2519 deletions

208
.gitignore vendored
View File

@@ -1,12 +1,15 @@
# Python data
__pycache__ .streamlit
*.pyc
nin_python_parser
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions
*.so *.so
# Distribution / packaging
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
@@ -20,13 +23,88 @@ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
pip-wheel-metadata/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
# Virtual environments # PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env .env
.venv .venv
env/ env/
@@ -35,86 +113,6 @@ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
# Logs
*.log
logs/
log/
# MinIO data and cache
minio_data/
.minio.sys/
*.meta
part.*
# Docker
.dockerignore
docker-compose.override.yml
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Temporary files
*.tmp
*.temp
*.bak
*.backup
*.orig
# Data files (Excel, CSV, etc.)
*.xlsx
*.xls
*.xlsm
*.csv
*.json
data/
uploads/
# Cache directories
.cache/
.pytest_cache/
.coverage
htmlcov/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# pipenv
Pipfile.lock
# poetry
poetry.lock
# Celery
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
@@ -133,27 +131,23 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# pytype static type analyzer # IDE
.pytype/ .vscode/
.idea/
*.swp
*.swo
*~
# Cython debug symbols # OS
cython_debug/ .DS_Store
Thumbs.db
# Local development # Project specific
local_settings.py data/
db.sqlite3 *.zip
db.sqlite3-journal *.xlsx
*.xls
*.xlsm
# FastAPI # MinIO data directory
.pytest_cache/ minio_data/
.coverage
htmlcov/
# Streamlit
.streamlit/secrets.toml
# Node.js (if any frontend components)
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*

1
Procfile Normal file
View File

@@ -0,0 +1 @@
web: python /app/run_stand.py

View File

@@ -1,41 +0,0 @@
# 🚀 Быстрый запуск проекта
## 1. Запуск всех сервисов
```bash
docker compose up -d
```
## 2. Проверка статуса
```bash
docker compose ps
```
## 3. Доступ к сервисам
- **FastAPI**: http://localhost:8000
- **Streamlit**: http://localhost:8501
- **MinIO Console**: http://localhost:9001
- **MinIO API**: http://localhost:9000
## 4. Остановка
```bash
docker compose down
```
## 5. Просмотр логов
```bash
# Все сервисы
docker compose logs
# Конкретный сервис
docker compose logs fastapi
docker compose logs streamlit
docker compose logs minio
```
## 6. Пересборка и перезапуск
```bash
docker compose up -d --build
```
---
**Примечание**: При первом запуске Docker будет скачивать образы и собирать контейнеры, это может занять несколько минут.

227
README.md
View File

@@ -1,117 +1,182 @@
# Python Parser CF - Система анализа данных # 🚀 NIN Excel Parsers API - Полная система
Проект состоит из трех основных компонентов: Полноценная система для парсинга Excel отчетов нефтеперерабатывающих заводов (НПЗ) с использованием FastAPI, MinIO и Streamlit.
- **python_parser** - FastAPI приложение для парсинга и обработки данных
- **streamlit_app** - Streamlit приложение для визуализации и анализа ## 🏗️ Архитектура проекта
- **minio_data** - хранилище данных MinIO
Проект состоит из **двух изолированных пакетов**:
- **`python_parser/`** - FastAPI сервер + парсеры Excel
- **`streamlit_app/`** - Веб-интерфейс для демонстрации API
## 🚀 Быстрый запуск ## 🚀 Быстрый запуск
### Предварительные требования ### **Вариант 1: Все сервисы в Docker (рекомендуется)**
- Docker и Docker Compose
- Git
### Запуск всех сервисов (продакшн)
```bash ```bash
docker compose up -d # Запуск всех сервисов: MinIO + FastAPI + Streamlit
docker-compose up -d
# Доступ:
# - MinIO Console: http://localhost:9001
# - FastAPI: http://localhost:8000
# - Streamlit: http://localhost:8501
# - API Docs: http://localhost:8000/docs
``` ```
### Запуск в режиме разработки ### **Вариант 2: Только MinIO в Docker + сервисы локально**
```bash ```bash
# Автоматический запуск # Запуск MinIO в Docker
python start_dev.py docker-compose up -d minio
# Или вручную # Запуск FastAPI локально
docker compose -f docker-compose.dev.yml up -d cd python_parser
python run_dev.py
# В отдельном терминале - Streamlit
cd streamlit_app
streamlit run app.py
``` ```
**Режим разработки** позволяет: ### **Вариант 3: Только MinIO в Docker**
- Автоматически перезагружать Streamlit при изменении кода
- Монтировать исходный код напрямую в контейнер
- Видеть изменения без пересборки контейнеров
### Доступ к сервисам
- **FastAPI**: http://localhost:8000
- **Streamlit**: http://localhost:8501
- **MinIO Console**: http://localhost:9001
- **MinIO API**: http://localhost:9000
### Остановка сервисов
```bash ```bash
docker-compose down # Запуск только MinIO
docker-compose up -d minio
``` ```
## 📋 Описание сервисов
- **MinIO** (порт 9000-9001): S3-совместимое хранилище для данных
- **FastAPI** (порт 8000): API сервер для парсинга Excel файлов
- **Streamlit** (порт 8501): Веб-интерфейс для демонстрации API
## 📁 Структура проекта ## 📁 Структура проекта
``` ```
python_parser_cf/ python_parser_cf/ # Корень проекта
├── python_parser/ # FastAPI приложение ├── python_parser/ # Пакет FastAPI + парсеры
│ ├── app/ # Основной код приложения │ ├── app/ # FastAPI приложение
│ ├── adapters/ # Адаптеры для парсеров │ ├── main.py # Основной файл приложения
├── core/ # Основная бизнес-логика │ └── schemas/ # Pydantic схемы
│ ├── data/ # Тестовые данные │ ├── core/ # Бизнес-логика
└── Dockerfile # Docker образ для FastAPI │ ├── models.py # Модели данных
├── streamlit_app/ # Streamlit приложение │ │ ├── ports.py # Интерфейсы (порты)
├── streamlit_app.py # Основной файл приложения │ └── services.py # Сервисы
│ ├── requirements.txt # Зависимости Python │ ├── adapters/ # Адаптеры для внешних систем
│ ├── .streamlit/ # Конфигурация Streamlit │ ├── storage.py # MinIO адаптер
│ └── Dockerfile # Docker образ для Streamlit │ └── parsers/ # Парсеры Excel файлов
├── minio_data/ # Данные для MinIO ├── data/ # Тестовые данные
├── docker-compose.yml # Конфигурация всех сервисов ├── Dockerfile # Docker образ для FastAPI
└── README.md # Документация │ ├── requirements.txt # Зависимости FastAPI
│ └── run_dev.py # Запуск FastAPI локально
├── streamlit_app/ # Пакет Streamlit
│ ├── app.py # Основное Streamlit приложение
│ ├── requirements.txt # Зависимости Streamlit
│ ├── Dockerfile # Docker образ для Streamlit
│ ├── .streamlit/ # Конфигурация Streamlit
│ │ └── config.toml # Настройки
│ └── README.md # Документация Streamlit
├── docker-compose.yml # Docker Compose конфигурация
├── .gitignore # Git исключения
└── README.md # Общая документация
``` ```
## 🔧 Конфигурация ## 🔍 Доступные эндпоинты
### Переменные окружения - **GET /** - Информация об API
Все сервисы используют следующие переменные окружения: - **GET /docs** - Swagger документация
- `MINIO_ENDPOINT` - адрес MinIO сервера - **GET /parsers** - Список доступных парсеров
- `MINIO_ACCESS_KEY` - ключ доступа к MinIO - **GET /parsers/{parser_name}/getters** - Информация о геттерах парсера
- `MINIO_SECRET_KEY` - секретный ключ MinIO - **POST /svodka_pm/upload-zip** - Загрузка сводок ПМ
- `MINIO_SECURE` - использование SSL/TLS - **POST /svodka_ca/upload** - Загрузка сводок ЦА
- `MINIO_BUCKET` - имя bucket'а для данных - **POST /monitoring_fuel/upload-zip** - Загрузка мониторинга топлива
- **POST /svodka_pm/get_data** - Получение данных сводок ПМ
- **POST /svodka_ca/get_data** - Получение данных сводок ЦА
- **POST /monitoring_fuel/get_data** - Получение данных мониторинга топлива
### Порты ## 📊 Поддерживаемые типы отчетов
- **8000** - FastAPI
- **8501** - Streamlit
- **9000** - MinIO API
- **9001** - MinIO Console
## 📊 Использование 1. **svodka_pm** - Сводки по переработке нефти (ПМ)
- Геттеры: `single_og`, `total_ogs`
2. **svodka_ca** - Сводки по переработке нефти (ЦА)
- Геттеры: `get_data`
3. **monitoring_fuel** - Мониторинг топлива
- Геттеры: `total_by_columns`, `month_by_code`
1. **Запустите все сервисы**: `docker-compose up -d` ## 🏗️ Архитектура
2. **Откройте Streamlit**: http://localhost:8501
3. **Выберите тип данных** для анализа
4. **Просматривайте результаты** в интерактивном интерфейсе
## 🛠️ Разработка Проект использует **Hexagonal Architecture (Ports and Adapters)**:
### Режим разработки (рекомендуется) - **Порты (Ports)**: Интерфейсы для бизнес-логики
- **Адаптеры (Adapters)**: Реализации для внешних систем
- **Сервисы (Services)**: Бизнес-логика приложения
### Система геттеров парсеров
Каждый парсер может иметь несколько методов получения данных (геттеров):
- Регистрация геттеров в словаре с метаданными
- Валидация параметров для каждого геттера
- Единый интерфейс `get_value(getter_name, params)`
## 🐳 Docker
### Сборка образов:
```bash ```bash
# Запуск режима разработки # FastAPI
python start_dev.py docker build -t nin-fastapi ./python_parser
# Остановка # Streamlit
docker compose -f docker-compose.dev.yml down docker build -t nin-streamlit ./streamlit_app
# Возврат к продакшн режиму
python start_prod.py
``` ```
### Локальная разработка FastAPI ### Запуск отдельных сервисов:
```bash ```bash
# Только MinIO
docker-compose up -d minio
# MinIO + FastAPI
docker-compose up -d minio fastapi
# Все сервисы
docker-compose up -d
```
## 🛑 Остановка
### Остановка Docker сервисов:
```bash
# Все сервисы
docker-compose down
# Только MinIO
docker-compose stop minio
```
### Остановка локальных сервисов:
```bash
# Нажмите Ctrl+C в терминале с FastAPI/Streamlit
```
## 🔧 Разработка
### Добавление нового парсера:
1. Создайте файл в `python_parser/adapters/parsers/`
2. Реализуйте интерфейс `ParserPort`
3. Добавьте в `python_parser/core/services.py`
4. Создайте схемы в `python_parser/app/schemas/`
5. Добавьте эндпоинты в `python_parser/app/main.py`
### Тестирование:
```bash
# Запуск тестов
cd python_parser cd python_parser
pip install -r requirements.txt pytest
uvicorn app.main:app --reload
```
### Локальная разработка Streamlit # Запуск с покрытием
```bash pytest --cov=.
cd streamlit_app
pip install -r requirements.txt
streamlit run streamlit_app.py
``` ```
## 📝 Лицензия ## 📝 Лицензия
Проект разработан для внутреннего использования. Проект разработан для внутреннего использования НИН.

View File

@@ -170,16 +170,11 @@ def main():
if not port_8000_ok: if not port_8000_ok:
print("\n🔧 РЕШЕНИЕ: Запустите FastAPI сервер") print("\n🔧 РЕШЕНИЕ: Запустите FastAPI сервер")
print("python run_dev.py") print("docker-compose up -d fastapi")
if not port_8501_ok: if not port_8501_ok:
print("\n🔧 РЕШЕНИЕ: Запустите Streamlit") print("\n🔧 РЕШЕНИЕ: Запустите Streamlit")
print("python run_streamlit.py") print("docker-compose up -d streamlit")
print("\n🚀 Для автоматического запуска используйте:")
print("python start_demo.py")
print("\n🔍 Для пошагового запуска используйте:")
print("python run_manual.py")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

34
create_test_excel.py Normal file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""
Создание тестового Excel файла для тестирования API
"""
import pandas as pd
import numpy as np
def create_test_excel():
"""Создание тестового Excel файла"""
# Создаем тестовые данные
data = {
'name': ['Установка 1', 'Установка 2', 'Установка 3'],
'normativ': [100, 200, 300],
'total': [95, 195, 295],
'total_1': [90, 190, 290]
}
df = pd.DataFrame(data)
# Сохраняем в Excel
filename = 'test_file.xlsx'
with pd.ExcelWriter(filename, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name='Мониторинг потребления', index=False)
print(f"✅ Тестовый файл создан: {filename}")
print(f"📊 Содержимое: {len(df)} строк, {len(df.columns)} столбцов")
print(f"📋 Столбцы: {list(df.columns)}")
return filename
if __name__ == "__main__":
create_test_excel()

View File

@@ -1,58 +0,0 @@
services:
minio:
image: minio/minio:latest
container_name: svodka_minio_dev
ports:
- "9000:9000" # API порт
- "9001:9001" # Консоль порт
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes:
- ./minio_data:/data
restart: unless-stopped
fastapi:
build: ./python_parser
container_name: svodka_fastapi_dev
ports:
- "8000:8000"
environment:
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_SECURE=false
- MINIO_BUCKET=svodka-data
depends_on:
- minio
restart: unless-stopped
streamlit:
image: python:3.11-slim
container_name: svodka_streamlit_dev
ports:
- "8501:8501"
environment:
- API_BASE_URL=http://fastapi:8000
- API_PUBLIC_URL=http://localhost:8000
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_SECURE=false
- MINIO_BUCKET=svodka-data
volumes:
# Монтируем исходный код для автоматической перезагрузки
- ./streamlit_app:/app
# Монтируем requirements.txt для установки зависимостей
- ./streamlit_app/requirements.txt:/app/requirements.txt
working_dir: /app
depends_on:
- minio
- fastapi
restart: unless-stopped
command: >
bash -c "
pip install --no-cache-dir -r requirements.txt &&
streamlit run streamlit_app.py --server.port=8501 --server.address=0.0.0.0 --server.runOnSave=true
"

View File

@@ -1,5 +1,3 @@
# Продакшн конфигурация
# Для разработки используйте: docker compose -f docker-compose.dev.yml up -d
services: services:
minio: minio:
image: minio/minio:latest image: minio/minio:latest
@@ -37,13 +35,7 @@ services:
- "8501:8501" - "8501:8501"
environment: environment:
- API_BASE_URL=http://fastapi:8000 - API_BASE_URL=http://fastapi:8000
- API_PUBLIC_URL=http://localhost:8000 - DOCKER_ENV=true
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_SECURE=false
- MINIO_BUCKET=svodka-data
depends_on: depends_on:
- minio
- fastapi - fastapi
restart: unless-stopped restart: unless-stopped

17
manifest.yml Normal file
View File

@@ -0,0 +1,17 @@
applications:
- name: nin-python-parser-dev-test
buildpack: python_buildpack
health-check-type: web
services:
- logging-shared-dev
command: python /app/run_stand.py
path: .
disk_quota: 2G
memory: 4G
instances: 1
env:
MINIO_ENDPOINT: s3-region1.ppc-jv-dev.sibintek.ru
MINIO_ACCESS_KEY: 00a70fac02c1208446de
MINIO_SECRET_KEY: 1gk9tVYEEoH9ADRxb4kiAuCo6CCISdV6ie0p6oDO
MINIO_BUCKET: bucket-476684e7-1223-45ac-a101-8b5aeda487d6
MINIO_SECURE: false

20
python_parser/Dockerfile_ Normal file
View File

@@ -0,0 +1,20 @@
FROM repo-dev.predix.rosneft.ru/python:3.11-slim
WORKDIR /app
# RUN pip install kafka-python==2.0.2
# RUN pip freeze > /app/requirements.txt
# ADD . /app
COPY requirements.txt .
RUN mkdir -p vendor
RUN pip download -r /app/requirements.txt --no-binary=:none: -d /app/vendor
# ADD . /app
# ENV KAFKA_BROKER=10.234.160.10:9093,10.234.160.10:9094,10.234.160.10:9095
# ENV KAFKA_UPDATE_ALGORITHM_RULES_TOPIC=algorithm-rule-update
# ENV KAFKA_CLIENT_USERNAME=cf-service
# CMD ["python", "/app/run_dev.py"]

104
python_parser/README.md Normal file
View File

@@ -0,0 +1,104 @@
# 📊 Python Parser - FastAPI + Парсеры Excel
Пакет FastAPI сервера и парсеров Excel для нефтеперерабатывающих заводов.
## 🚀 Быстрый запуск
### **Локально:**
```bash
# Установка зависимостей
pip install -r requirements.txt
# Запуск FastAPI сервера
python run_dev.py
```
### **В Docker:**
```bash
# Сборка образа
docker build -t nin-fastapi .
# Запуск контейнера
docker run -p 8000:8000 nin-fastapi
```
## 📁 Структура пакета
```
python_parser/
├── app/ # FastAPI приложение
│ ├── main.py # Основной файл приложения
│ └── schemas/ # Pydantic схемы
├── core/ # Бизнес-логика
│ ├── models.py # Модели данных
│ ├── ports.py # Интерфейсы (порты)
│ └── services.py # Сервисы
├── adapters/ # Адаптеры для внешних систем
│ ├── storage.py # MinIO адаптер
│ └── parsers/ # Парсеры Excel файлов
├── data/ # Тестовые данные
├── Dockerfile # Docker образ для FastAPI
├── requirements.txt # Зависимости Python
└── run_dev.py # Запуск FastAPI локально
```
## 🔍 Основные эндпоинты
- **GET /** - Информация об API
- **GET /docs** - Swagger документация
- **GET /parsers** - Список доступных парсеров
- **GET /parsers/{parser_name}/getters** - Информация о геттерах парсера
- **POST /svodka_pm/upload-zip** - Загрузка сводок ПМ
- **POST /svodka_ca/upload** - Загрузка сводок ЦА
- **POST /monitoring_fuel/upload-zip** - Загрузка мониторинга топлива
- **POST /svodka_pm/get_data** - Получение данных сводок ПМ
- **POST /svodka_ca/get_data** - Получение данных сводок ЦА
- **POST /monitoring_fuel/get_data** - Получение данных мониторинга топлива
## 📊 Поддерживаемые парсеры
1. **svodka_pm** - Сводки по переработке нефти (ПМ)
- Геттеры: `single_og`, `total_ogs`
2. **svodka_ca** - Сводки по переработке нефти (ЦА)
- Геттеры: `get_data`
3. **monitoring_fuel** - Мониторинг топлива
- Геттеры: `total_by_columns`, `month_by_code`
## 🏗️ Архитектура
Использует **Hexagonal Architecture (Ports and Adapters)**:
- **Порты (Ports)**: Интерфейсы для бизнес-логики
- **Адаптеры (Adapters)**: Реализации для внешних систем
- **Сервисы (Services)**: Бизнес-логика приложения
### Система геттеров парсеров
Каждый парсер может иметь несколько методов получения данных (геттеров):
- Регистрация геттеров в словаре с метаданными
- Валидация параметров для каждого геттера
- Единый интерфейс `get_value(getter_name, params)`
## 🔧 Разработка
### Добавление нового парсера:
1. Создайте файл в `adapters/parsers/`
2. Реализуйте интерфейс `ParserPort`
3. Добавьте в `core/services.py`
4. Создайте схемы в `app/schemas/`
5. Добавьте эндпоинты в `app/main.py`
### Тестирование:
```bash
# Запуск тестов
pytest
# Запуск с покрытием
pytest --cov=.
```
## 📝 Примечание
Этот пакет является частью большей системы. Для полной документации и запуска всех сервисов см. README.md в корне проекта.

View File

@@ -1,135 +0,0 @@
# Интеграция схем Pydantic с парсерами
## Обзор
Этот документ описывает решение для устранения дублирования логики между схемами Pydantic и парсерами. Теперь схемы Pydantic являются единым источником правды для определения параметров парсеров.
## Проблема
Ранее в парсерах дублировалась информация о параметрах:
```python
# В парсере
self.register_getter(
name="single_og",
method=self._get_single_og,
required_params=["id", "codes", "columns"], # Дублирование
optional_params=["search"], # Дублирование
description="Получение данных по одному ОГ"
)
# В схеме
class SvodkaPMSingleOGRequest(BaseModel):
id: OGID = Field(...) # Обязательное поле
codes: List[int] = Field(...) # Обязательное поле
columns: List[str] = Field(...) # Обязательное поле
search: Optional[str] = Field(None) # Необязательное поле
```
## Решение
### 1. Утилиты для работы со схемами
Создан модуль `core/schema_utils.py` с функциями:
- `get_required_fields_from_schema()` - извлекает обязательные поля
- `get_optional_fields_from_schema()` - извлекает необязательные поля
- `register_getter_from_schema()` - регистрирует геттер с использованием схемы
- `validate_params_with_schema()` - валидирует параметры с помощью схемы
### 2. Обновленные парсеры
Теперь парсеры используют схемы как единый источник правды:
```python
def _register_default_getters(self):
"""Регистрация геттеров по умолчанию"""
# Используем схемы Pydantic как единый источник правды
register_getter_from_schema(
parser_instance=self,
getter_name="single_og",
method=self._get_single_og,
schema_class=SvodkaPMSingleOGRequest,
description="Получение данных по одному ОГ"
)
```
### 3. Валидация параметров
Методы геттеров теперь автоматически валидируют параметры:
```python
def _get_single_og(self, params: dict):
"""Получение данных по одному ОГ"""
# Валидируем параметры с помощью схемы Pydantic
validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest)
og_id = validated_params["id"]
codes = validated_params["codes"]
columns = validated_params["columns"]
search = validated_params.get("search")
# ... остальная логика
```
## Преимущества
1. **Единый источник правды** - информация о параметрах хранится только в схемах Pydantic
2. **Автоматическая валидация** - параметры автоматически валидируются с помощью Pydantic
3. **Синхронизация** - изменения в схемах автоматически отражаются в парсерах
4. **Типобезопасность** - использование типов Pydantic обеспечивает типобезопасность
5. **Документация** - Swagger документация автоматически генерируется из схем
## Совместимость
Решение работает с:
- Pydantic v1 (через `__fields__`)
- Pydantic v2 (через `model_fields` и `is_required()`)
## Использование
### Для новых парсеров
1. Создайте схему Pydantic с нужными полями
2. Используйте `register_getter_from_schema()` для регистрации геттера
3. Используйте `validate_params_with_schema()` в методах геттеров
### Для существующих парсеров
1. Убедитесь, что у вас есть соответствующая схема Pydantic
2. Замените ручную регистрацию геттеров на `register_getter_from_schema()`
3. Добавьте валидацию параметров в методы геттеров
## Примеры
### Схема с обязательными и необязательными полями
```python
class ExampleRequest(BaseModel):
required_field: str = Field(..., description="Обязательное поле")
optional_field: Optional[str] = Field(None, description="Необязательное поле")
```
### Регистрация геттера
```python
register_getter_from_schema(
parser_instance=self,
getter_name="example_getter",
method=self._example_method,
schema_class=ExampleRequest,
description="Пример геттера"
)
```
### Валидация в методе
```python
def _example_method(self, params: dict):
validated_params = validate_params_with_schema(params, ExampleRequest)
# validated_params содержит валидированные данные
```
## Заключение
Это решение устраняет дублирование кода и обеспечивает единообразие между API схемами и парсерами. Теперь изменения в схемах автоматически отражаются в парсерах, что упрощает поддержку и развитие системы.

View File

@@ -1,154 +0,0 @@
"""
Локальный storage адаптер для тестирования
Сохраняет данные в локальную файловую систему вместо MinIO
"""
import os
import json
import pickle
from pathlib import Path
from typing import Optional, Dict, Any
import pandas as pd
from core.ports import StoragePort
class LocalStorageAdapter(StoragePort):
"""Локальный адаптер для хранения данных в файловой системе"""
def __init__(self, base_path: str = "local_storage"):
"""
Инициализация локального storage
Args:
base_path: Базовый путь для хранения данных
"""
self.base_path = Path(base_path)
self.base_path.mkdir(parents=True, exist_ok=True)
# Создаем поддиректории
(self.base_path / "data").mkdir(exist_ok=True)
(self.base_path / "metadata").mkdir(exist_ok=True)
def object_exists(self, object_id: str) -> bool:
"""Проверяет существование объекта"""
data_file = self.base_path / "data" / f"{object_id}.pkl"
return data_file.exists()
def save_dataframe(self, object_id: str, df: pd.DataFrame) -> bool:
"""Сохраняет DataFrame в локальную файловую систему"""
try:
data_file = self.base_path / "data" / f"{object_id}.pkl"
metadata_file = self.base_path / "metadata" / f"{object_id}.json"
# Сохраняем DataFrame
with open(data_file, 'wb') as f:
pickle.dump(df, f)
# Сохраняем метаданные
metadata = {
"object_id": object_id,
"shape": df.shape,
"columns": df.columns.tolist(),
"dtypes": {str(k): str(v) for k, v in df.dtypes.to_dict().items()}
}
with open(metadata_file, 'w', encoding='utf-8') as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
return True
except Exception as e:
print(f"Ошибка при сохранении {object_id}: {e}")
return False
def load_dataframe(self, object_id: str) -> Optional[pd.DataFrame]:
"""Загружает DataFrame из локальной файловой системы"""
try:
data_file = self.base_path / "data" / f"{object_id}.pkl"
if not data_file.exists():
return None
with open(data_file, 'rb') as f:
df = pickle.load(f)
return df
except Exception as e:
print(f"Ошибка при загрузке {object_id}: {e}")
return None
def delete_object(self, object_id: str) -> bool:
"""Удаляет объект из локального storage"""
try:
data_file = self.base_path / "data" / f"{object_id}.pkl"
metadata_file = self.base_path / "metadata" / f"{object_id}.json"
# Удаляем файлы если они существуют
if data_file.exists():
data_file.unlink()
if metadata_file.exists():
metadata_file.unlink()
return True
except Exception as e:
print(f"Ошибка при удалении {object_id}: {e}")
return False
def list_objects(self) -> list:
"""Возвращает список всех объектов в storage"""
try:
data_dir = self.base_path / "data"
if not data_dir.exists():
return []
objects = []
for file_path in data_dir.glob("*.pkl"):
object_id = file_path.stem
objects.append(object_id)
return objects
except Exception as e:
print(f"Ошибка при получении списка объектов: {e}")
return []
def get_object_metadata(self, object_id: str) -> Optional[Dict[str, Any]]:
"""Возвращает метаданные объекта"""
try:
metadata_file = self.base_path / "metadata" / f"{object_id}.json"
if not metadata_file.exists():
return None
with open(metadata_file, 'r', encoding='utf-8') as f:
metadata = json.load(f)
return metadata
except Exception as e:
print(f"Ошибка при получении метаданных {object_id}: {e}")
return None
def clear_all(self) -> bool:
"""Очищает весь storage"""
try:
data_dir = self.base_path / "data"
metadata_dir = self.base_path / "metadata"
# Удаляем все файлы
for file_path in data_dir.glob("*"):
if file_path.is_file():
file_path.unlink()
for file_path in metadata_dir.glob("*"):
if file_path.is_file():
file_path.unlink()
return True
except Exception as e:
print(f"Ошибка при очистке storage: {e}")
return False

View File

@@ -3,9 +3,7 @@ import re
import zipfile import zipfile
from typing import Dict, Tuple from typing import Dict, Tuple
from core.ports import ParserPort from core.ports import ParserPort
from core.schema_utils import register_getter_from_schema, validate_params_with_schema from adapters.pconfig import data_to_json
from app.schemas.monitoring_fuel import MonitoringFuelTotalRequest, MonitoringFuelMonthRequest, MonitoringFuelSeriesRequest
from adapters.pconfig import data_to_json, find_header_row
class MonitoringFuelParser(ParserPort): class MonitoringFuelParser(ParserPort):
@@ -15,48 +13,37 @@ class MonitoringFuelParser(ParserPort):
def _register_default_getters(self): def _register_default_getters(self):
"""Регистрация геттеров по умолчанию""" """Регистрация геттеров по умолчанию"""
# Используем схемы Pydantic как единый источник правды self.register_getter(
register_getter_from_schema( name="total_by_columns",
parser_instance=self,
getter_name="total_by_columns",
method=self._get_total_by_columns, method=self._get_total_by_columns,
schema_class=MonitoringFuelTotalRequest, required_params=["columns"],
optional_params=[],
description="Агрегация данных по колонкам" description="Агрегация данных по колонкам"
) )
register_getter_from_schema( self.register_getter(
parser_instance=self, name="month_by_code",
getter_name="month_by_code",
method=self._get_month_by_code, method=self._get_month_by_code,
schema_class=MonitoringFuelMonthRequest, required_params=["month"],
optional_params=[],
description="Получение данных за конкретный месяц" description="Получение данных за конкретный месяц"
) )
register_getter_from_schema(
parser_instance=self,
getter_name="series_by_id_and_columns",
method=self._get_series_by_id_and_columns,
schema_class=MonitoringFuelSeriesRequest,
description="Получение временных рядов по ID и колонкам"
)
def _get_total_by_columns(self, params: dict): def _get_total_by_columns(self, params: dict):
"""Агрегация данных по колонкам""" """Агрегация по колонкам (обертка для совместимости)"""
# Валидируем параметры с помощью схемы Pydantic columns = params["columns"]
validated_params = validate_params_with_schema(params, MonitoringFuelTotalRequest) if not columns:
raise ValueError("Отсутствуют идентификаторы столбцов")
columns = validated_params["columns"]
# TODO: Переделать под новую архитектуру # TODO: Переделать под новую архитектуру
df_means, _ = self.aggregate_by_columns(self.df, columns) df_means, _ = self.aggregate_by_columns(self.df, columns)
return df_means.to_dict(orient='index') return df_means.to_dict(orient='index')
def _get_month_by_code(self, params: dict): def _get_month_by_code(self, params: dict):
"""Получение данных за конкретный месяц""" """Получение данных за месяц (обертка для совместимости)"""
# Валидируем параметры с помощью схемы Pydantic month = params["month"]
validated_params = validate_params_with_schema(params, MonitoringFuelMonthRequest) if not month:
raise ValueError("Отсутствует идентификатор месяца")
month = validated_params["month"]
# TODO: Переделать под новую архитектуру # TODO: Переделать под новую архитектуру
df_month = self.get_month(self.df, month) df_month = self.get_month(self.df, month)
@@ -100,13 +87,30 @@ class MonitoringFuelParser(ParserPort):
return df_monitorings return df_monitorings
def find_header_row(self, file_path: str, sheet: str, search_value: str = "Установка", max_rows: int = 50) -> int:
"""Определение индекса заголовка в Excel по ключевому слову"""
# Читаем первые max_rows строк без заголовков
df_temp = pd.read_excel(
file_path,
sheet_name=sheet,
header=None,
nrows=max_rows,
engine='openpyxl'
)
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
for idx, row in df_temp.iterrows():
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx + 1 # возвращаем индекс строки (0-based)
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
def parse_single(self, file, sheet, header_num=None): def parse_single(self, file, sheet, header_num=None):
''' Собственно парсер отчетов одного объекта''' ''' Собственно парсер отчетов одного объекта'''
# Автоопределение header_num, если не передан # Автоопределение header_num, если не передан
if header_num is None: if header_num is None:
header_num = find_header_row(file, sheet, search_value="Установка") header_num = self.find_header_row(file, sheet, search_value="Установка")
# Читаем весь лист, начиная с найденной строки как заголовок # Читаем весь лист, начиная с найденной строки как заголовок
df_full = pd.read_excel( df_full = pd.read_excel(
file, file,
@@ -228,47 +232,3 @@ class MonitoringFuelParser(ParserPort):
total.name = 'mean' total.name = 'mean'
return total, df_combined return total, df_combined
def _get_series_by_id_and_columns(self, params: dict):
"""Получение временных рядов по ID и колонкам"""
# Валидируем параметры с помощью схемы Pydantic
validated_params = validate_params_with_schema(params, MonitoringFuelSeriesRequest)
columns = validated_params["columns"]
# Проверяем, что все колонки существуют хотя бы в одном месяце
valid_columns = set()
for month in self.df.values():
valid_columns.update(month.columns)
for col in columns:
if col not in valid_columns:
raise ValueError(f"Колонка '{col}' не найдена ни в одном месяце")
# Подготавливаем результат: словарь id → {col: [значения по месяцам]}
result = {}
# Обрабатываем месяцы от 01 до 12
for month_key in [f"{i:02d}" for i in range(1, 13)]:
if month_key not in self.df:
print(f"Месяц '{month_key}' не найден в df_monitorings, пропускаем.")
continue
df = self.df[month_key]
for col in columns:
if col not in df.columns:
continue # Пропускаем, если в этом месяце нет колонки
for idx, value in df[col].items():
if pd.isna(value):
continue # Можно пропустить NaN, или оставить как null
if idx not in result:
result[idx] = {c: [] for c in columns}
result[idx][col].append(value)
# Преобразуем ключи id в строки (для JSON-совместимости)
result_str_keys = {str(k): v for k, v in result.items()}
return result_str_keys

View File

@@ -2,8 +2,6 @@ import pandas as pd
import numpy as np import numpy as np
from core.ports import ParserPort from core.ports import ParserPort
from core.schema_utils import register_getter_from_schema, validate_params_with_schema
from app.schemas.svodka_ca import SvodkaCARequest
from adapters.pconfig import get_og_by_name from adapters.pconfig import get_og_by_name
@@ -14,22 +12,23 @@ class SvodkaCAParser(ParserPort):
def _register_default_getters(self): def _register_default_getters(self):
"""Регистрация геттеров по умолчанию""" """Регистрация геттеров по умолчанию"""
# Используем схемы Pydantic как единый источник правды self.register_getter(
register_getter_from_schema( name="get_data",
parser_instance=self,
getter_name="get_ca_data",
method=self._get_data_wrapper, method=self._get_data_wrapper,
schema_class=SvodkaCARequest, required_params=["modes", "tables"],
optional_params=[],
description="Получение данных по режимам и таблицам" description="Получение данных по режимам и таблицам"
) )
def _get_data_wrapper(self, params: dict): def _get_data_wrapper(self, params: dict):
"""Получение данных по режимам и таблицам""" """Обертка для получения данных (для совместимости)"""
# Валидируем параметры с помощью схемы Pydantic modes = params["modes"]
validated_params = validate_params_with_schema(params, SvodkaCARequest) tables = params["tables"]
modes = validated_params["modes"] if not isinstance(modes, list):
tables = validated_params["tables"] raise ValueError("Поле 'modes' должно быть списком")
if not isinstance(tables, list):
raise ValueError("Поле 'tables' должно быть списком")
# TODO: Переделать под новую архитектуру # TODO: Переделать под новую архитектуру
data_dict = {} data_dict = {}

View File

@@ -1,9 +1,7 @@
import pandas as pd import pandas as pd
from core.ports import ParserPort from core.ports import ParserPort
from core.schema_utils import register_getter_from_schema, validate_params_with_schema from adapters.pconfig import OG_IDS, replace_id_in_path, data_to_json
from app.schemas.svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest
from adapters.pconfig import SINGLE_OGS, replace_id_in_path, data_to_json, find_header_row
class SvodkaPMParser(ParserPort): class SvodkaPMParser(ParserPort):
@@ -13,45 +11,48 @@ class SvodkaPMParser(ParserPort):
def _register_default_getters(self): def _register_default_getters(self):
"""Регистрация геттеров по умолчанию""" """Регистрация геттеров по умолчанию"""
# Используем схемы Pydantic как единый источник правды self.register_getter(
register_getter_from_schema( name="single_og",
parser_instance=self,
getter_name="single_og",
method=self._get_single_og, method=self._get_single_og,
schema_class=SvodkaPMSingleOGRequest, required_params=["id", "codes", "columns"],
optional_params=["search"],
description="Получение данных по одному ОГ" description="Получение данных по одному ОГ"
) )
register_getter_from_schema( self.register_getter(
parser_instance=self, name="total_ogs",
getter_name="total_ogs",
method=self._get_total_ogs, method=self._get_total_ogs,
schema_class=SvodkaPMTotalOGsRequest, required_params=["codes", "columns"],
optional_params=["search"],
description="Получение данных по всем ОГ" description="Получение данных по всем ОГ"
) )
def _get_single_og(self, params: dict): def _get_single_og(self, params: dict):
"""Получение данных по одному ОГ""" """Получение данных по одному ОГ (обертка для совместимости)"""
# Валидируем параметры с помощью схемы Pydantic og_id = params["id"]
validated_params = validate_params_with_schema(params, SvodkaPMSingleOGRequest) codes = params["codes"]
columns = params["columns"]
search = params.get("search")
og_id = validated_params["id"] if not isinstance(codes, list):
codes = validated_params["codes"] raise ValueError("Поле 'codes' должно быть списком")
columns = validated_params["columns"] if not isinstance(columns, list):
search = validated_params.get("search") raise ValueError("Поле 'columns' должно быть списком")
# Здесь нужно получить DataFrame из self.df, но пока используем старую логику # Здесь нужно получить DataFrame из self.df, но пока используем старую логику
# TODO: Переделать под новую архитектуру # TODO: Переделать под новую архитектуру
return self.get_svodka_og(self.df, og_id, codes, columns, search) return self.get_svodka_og(self.df, og_id, codes, columns, search)
def _get_total_ogs(self, params: dict): def _get_total_ogs(self, params: dict):
"""Получение данных по всем ОГ""" """Получение данных по всем ОГ (обертка для совместимости)"""
# Валидируем параметры с помощью схемы Pydantic codes = params["codes"]
validated_params = validate_params_with_schema(params, SvodkaPMTotalOGsRequest) columns = params["columns"]
search = params.get("search")
codes = validated_params["codes"] if not isinstance(codes, list):
columns = validated_params["columns"] raise ValueError("Поле 'codes' должно быть списком")
search = validated_params.get("search") if not isinstance(columns, list):
raise ValueError("Поле 'columns' должно быть списком")
# TODO: Переделать под новую архитектуру # TODO: Переделать под новую архитектуру
return self.get_svodka_total(self.df, codes, columns, search) return self.get_svodka_total(self.df, codes, columns, search)
@@ -62,13 +63,30 @@ class SvodkaPMParser(ParserPort):
self.df = self.parse_svodka_pm_files(file_path, params) self.df = self.parse_svodka_pm_files(file_path, params)
return self.df return self.df
def find_header_row(self, file: str, sheet: str, search_value: str = "Итого", max_rows: int = 50) -> int:
"""Определения индекса заголовка в excel по ключевому слову"""
# Читаем первые max_rows строк без заголовков
df_temp = pd.read_excel(
file,
sheet_name=sheet,
header=None,
nrows=max_rows,
engine='openpyxl'
)
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
for idx, row in df_temp.iterrows():
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx # 0-based index — то, что нужно для header=
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
def parse_svodka_pm(self, file, sheet, header_num=None): def parse_svodka_pm(self, file, sheet, header_num=None):
''' Собственно парсер отчетов одного ОГ для БП, ПП и факта ''' ''' Собственно парсер отчетов одного ОГ для БП, ПП и факта '''
# Автоопределение header_num, если не передан # Автоопределение header_num, если не передан
if header_num is None: if header_num is None:
header_num = find_header_row(file, sheet, search_value="Итого") header_num = self.find_header_row(file, sheet, search_value="Итого")
# Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID # Читаем заголовки header_num и 1-2 строки данных, чтобы найти INDICATOR_ID
df_probe = pd.read_excel( df_probe = pd.read_excel(
@@ -166,7 +184,7 @@ class SvodkaPMParser(ParserPort):
excel_plan_template = 'svodka_plan_pm_ID.xlsx' excel_plan_template = 'svodka_plan_pm_ID.xlsx'
with zipfile.ZipFile(zip_path, 'r') as zip_ref: with zipfile.ZipFile(zip_path, 'r') as zip_ref:
file_list = zip_ref.namelist() file_list = zip_ref.namelist()
for id in SINGLE_OGS: for name, id in OG_IDS.items():
if id == 'BASH': if id == 'BASH':
continue # пропускаем BASH continue # пропускаем BASH
@@ -273,11 +291,11 @@ class SvodkaPMParser(ParserPort):
''' Служебная функция агрегации данные по всем ОГ ''' ''' Служебная функция агрегации данные по всем ОГ '''
total_result = {} total_result = {}
for og_id in SINGLE_OGS: for name, og_id in OG_IDS.items():
if og_id == 'BASH': if og_id == 'BASH':
continue continue
# print(f"📊 Обработка: {og_id}") # print(f"📊 Обработка: {name} ({og_id})")
try: try:
data = self.get_svodka_og( data = self.get_svodka_og(
pm_dict, pm_dict,
@@ -288,7 +306,7 @@ class SvodkaPMParser(ParserPort):
) )
total_result[og_id] = data total_result[og_id] = data
except Exception as e: except Exception as e:
print(f"❌ Ошибка при обработке {og_id}: {e}") print(f"❌ Ошибка при обработке {name} ({og_id}): {e}")
total_result[og_id] = None total_result[og_id] = None
return total_result return total_result

View File

@@ -3,7 +3,6 @@ from functools import lru_cache
import json import json
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import os
OG_IDS = { OG_IDS = {
"Комсомольский НПЗ": "KNPZ", "Комсомольский НПЗ": "KNPZ",
@@ -23,37 +22,8 @@ OG_IDS = {
"Красноленинский НПЗ": "KLNPZ", "Красноленинский НПЗ": "KLNPZ",
"Пурнефтепереработка": "PurNP", "Пурнефтепереработка": "PurNP",
"ЯНОС": "YANOS", "ЯНОС": "YANOS",
"Уфанефтехим": "UNH",
"РНПК": "RNPK",
"КмсНПЗ": "KNPZ",
"АНХК": "ANHK",
"НК НПЗ": "NovKuybNPZ",
"КНПЗ": "KuybNPZ",
"СНПЗ": "CyzNPZ",
"Нижневаторское НПО": "NVNPO",
"ПурНП": "PurNP",
} }
SINGLE_OGS = [
"KNPZ",
"ANHK",
"AchNPZ",
"BASH",
"UNPZ",
"UNH",
"NOV",
"NovKuybNPZ",
"KuybNPZ",
"CyzNPZ",
"TuapsNPZ",
"SNPZ",
"RNPK",
"NVNPO",
"KLNPZ",
"PurNP",
"YANOS",
]
SNPZ_IDS = { SNPZ_IDS = {
"Висбрекинг": "SNPZ.VISB", "Висбрекинг": "SNPZ.VISB",
"Изомеризация": "SNPZ.IZOM", "Изомеризация": "SNPZ.IZOM",
@@ -70,18 +40,7 @@ SNPZ_IDS = {
def replace_id_in_path(file_path, new_id): def replace_id_in_path(file_path, new_id):
# Заменяем 'ID' на новое значение return file_path.replace('ID', str(new_id))
modified_path = file_path.replace('ID', str(new_id)) + '.xlsx'
# Проверяем, существует ли файл
if not os.path.exists(modified_path):
# Меняем расширение на .xlsm
directory, filename = os.path.split(modified_path)
name, ext = os.path.splitext(filename)
new_filename = name + '.xlsm'
modified_path = os.path.join(directory, new_filename)
return modified_path
def get_table_name(exel): def get_table_name(exel):
@@ -150,25 +109,6 @@ def get_id_by_name(name, dictionary):
return best_match return best_match
def find_header_row(file, sheet, search_value="Итого", max_rows=50):
''' Определения индекса заголовка в exel по ключевому слову '''
# Читаем первые max_rows строк без заголовков
df_temp = pd.read_excel(
file,
sheet_name=sheet,
header=None,
nrows=max_rows
)
# Ищем строку, где хотя бы в одном столбце встречается искомое значение
for idx, row in df_temp.iterrows():
if row.astype(str).str.strip().str.contains(f"^{search_value}$", case=False, regex=True).any():
print(f"Заголовок найден в строке {idx} (Excel: {idx + 1})")
return idx # 0-based index — то, что нужно для header=
raise ValueError(f"Не найдена строка с заголовком '{search_value}' в первых {max_rows} строках.")
def data_to_json(data, indent=2, ensure_ascii=False): def data_to_json(data, indent=2, ensure_ascii=False):
""" """
Полностью безопасная сериализация данных в JSON. Полностью безопасная сериализация данных в JSON.
@@ -235,6 +175,7 @@ def data_to_json(data, indent=2, ensure_ascii=False):
try: try:
cleaned_data = convert_obj(data) cleaned_data = convert_obj(data)
return json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii) cleaned_data_str = json.dumps(cleaned_data, indent=indent, ensure_ascii=ensure_ascii)
return cleaned_data
except Exception as e: except Exception as e:
raise ValueError(f"Не удалось сериализовать данные в JSON: {e}") raise ValueError(f"Не удалось сериализовать данные в JSON: {e}")

Binary file not shown.

Binary file not shown.

View File

@@ -16,7 +16,7 @@ from app.schemas import (
UploadResponse, UploadErrorResponse, UploadResponse, UploadErrorResponse,
SvodkaPMTotalOGsRequest, SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest, SvodkaPMSingleOGRequest,
SvodkaCARequest, SvodkaCARequest,
MonitoringFuelMonthRequest, MonitoringFuelTotalRequest, MonitoringFuelSeriesRequest MonitoringFuelMonthRequest, MonitoringFuelTotalRequest
) )
@@ -377,7 +377,7 @@ async def get_svodka_pm_total_ogs(
try: try:
# Создаем запрос # Создаем запрос
request_dict = request_data.model_dump() request_dict = request_data.model_dump()
request_dict['mode'] = 'total_ogs' request_dict['mode'] = 'total'
request = DataRequest( request = DataRequest(
report_type='svodka_pm', report_type='svodka_pm',
get_params=request_dict get_params=request_dict
@@ -400,6 +400,41 @@ async def get_svodka_pm_total_ogs(
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/svodka_pm/get_data", tags=[SvodkaPMParser.name])
async def get_svodka_pm_data(
request_data: dict
):
report_service = get_report_service()
"""
Получение данных из отчета сводки факта СарНПЗ
- indicator_id: ID индикатора
- code: Код для поиска
- search_value: Опциональное значение для поиска
"""
try:
# Создаем запрос
request = DataRequest(
report_type='svodka_pm',
get_params=request_data
)
# Получаем данные
result = report_service.get_data(request)
if result.success:
return {
"success": True,
"data": result.data
}
else:
raise HTTPException(status_code=404, detail=result.message)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/svodka_ca/upload", tags=[SvodkaCAParser.name], @app.post("/svodka_ca/upload", tags=[SvodkaCAParser.name],
summary="Загрузка файла отчета сводки СА", summary="Загрузка файла отчета сводки СА",
@@ -474,7 +509,7 @@ async def upload_svodka_ca(
) )
@app.post("/svodka_ca/get_ca_data", tags=[SvodkaCAParser.name], @app.post("/svodka_ca/get_data", tags=[SvodkaCAParser.name],
summary="Получение данных из отчета сводки СА") summary="Получение данных из отчета сводки СА")
async def get_svodka_ca_data( async def get_svodka_ca_data(
request_data: SvodkaCARequest request_data: SvodkaCARequest
@@ -499,7 +534,6 @@ async def get_svodka_ca_data(
try: try:
# Создаем запрос # Создаем запрос
request_dict = request_data.model_dump() request_dict = request_data.model_dump()
request_dict['mode'] = 'get_ca_data'
request = DataRequest( request = DataRequest(
report_type='svodka_ca', report_type='svodka_ca',
get_params=request_dict get_params=request_dict
@@ -576,6 +610,38 @@ async def get_svodka_ca_data(
# raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}") # raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/monitoring_fuel/get_data", tags=[MonitoringFuelParser.name])
async def get_monitoring_fuel_data(
request_data: dict
):
report_service = get_report_service()
"""
Получение данных из отчета мониторинга топлива
- column: Название колонки для агрегации (normativ, total, total_svod)
"""
try:
# Создаем запрос
request = DataRequest(
report_type='monitoring_fuel',
get_params=request_data
)
# Получаем данные
result = report_service.get_data(request)
if result.success:
return {
"success": True,
"data": result.data
}
else:
raise HTTPException(status_code=404, detail=result.message)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
# @app.post("/monitoring_fuel/upload_directory", tags=[MonitoringFuelParser.name]) # @app.post("/monitoring_fuel/upload_directory", tags=[MonitoringFuelParser.name])
@@ -738,7 +804,7 @@ async def get_monitoring_fuel_total_by_columns(
try: try:
# Создаем запрос # Создаем запрос
request_dict = request_data.model_dump() request_dict = request_data.model_dump()
request_dict['mode'] = 'total_by_columns' request_dict['mode'] = 'total'
request = DataRequest( request = DataRequest(
report_type='monitoring_fuel', report_type='monitoring_fuel',
get_params=request_dict get_params=request_dict
@@ -783,56 +849,7 @@ async def get_monitoring_fuel_month_by_code(
try: try:
# Создаем запрос # Создаем запрос
request_dict = request_data.model_dump() request_dict = request_data.model_dump()
request_dict['mode'] = 'month_by_code' request_dict['mode'] = 'month'
request = DataRequest(
report_type='monitoring_fuel',
get_params=request_dict
)
# Получаем данные
result = report_service.get_data(request)
if result.success:
return {
"success": True,
"data": result.data
}
else:
raise HTTPException(status_code=404, detail=result.message)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.post("/monitoring_fuel/get_series_by_id_and_columns", tags=[MonitoringFuelParser.name],
summary="Получение временных рядов по ID и колонкам")
async def get_monitoring_fuel_series_by_id_and_columns(
request_data: MonitoringFuelSeriesRequest
):
"""Получение временных рядов из сводок мониторинга топлива по ID и колонкам
### Структура параметров:
- `columns`: **Массив названий** выбираемых столбцов для получения временных рядов (обязательный)
### Пример тела запроса:
```json
{
"columns": ["total", "normativ"]
}
```
### Возвращает:
Словарь где ключ - ID объекта, значение - словарь с колонками,
в которых хранятся списки значений по месяцам.
"""
report_service = get_report_service()
try:
# Создаем запрос
request_dict = request_data.model_dump()
request_dict['mode'] = 'series_by_id_and_columns'
request = DataRequest( request = DataRequest(
report_type='monitoring_fuel', report_type='monitoring_fuel',
get_params=request_dict get_params=request_dict

View File

@@ -1,4 +1,4 @@
from .monitoring_fuel import MonitoringFuelMonthRequest, MonitoringFuelTotalRequest, MonitoringFuelSeriesRequest from .monitoring_fuel import MonitoringFuelMonthRequest, MonitoringFuelTotalRequest
from .svodka_ca import SvodkaCARequest from .svodka_ca import SvodkaCARequest
from .svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest from .svodka_pm import SvodkaPMSingleOGRequest, SvodkaPMTotalOGsRequest
from .server import ServerInfoResponse from .server import ServerInfoResponse

View File

@@ -32,19 +32,3 @@ class MonitoringFuelTotalRequest(BaseModel):
"columns": ["total", "normativ"] "columns": ["total", "normativ"]
} }
} }
class MonitoringFuelSeriesRequest(BaseModel):
columns: List[str] = Field(
...,
description="Массив названий выбираемых столбцов для получения временных рядов",
example=["total", "normativ"],
min_items=1
)
class Config:
json_schema_extra = {
"example": {
"columns": ["total", "normativ"]
}
}

Binary file not shown.

Binary file not shown.

View File

@@ -1,144 +0,0 @@
"""
Упрощенные утилиты для работы со схемами Pydantic
"""
from typing import List, Dict, Any, Type
from pydantic import BaseModel
import inspect
def get_required_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]:
"""
Извлекает список обязательных полей из схемы Pydantic
Args:
schema_class: Класс схемы Pydantic
Returns:
Список имен обязательных полей
"""
required_fields = []
# Используем model_fields для Pydantic v2 или __fields__ для v1
if hasattr(schema_class, 'model_fields'):
fields = schema_class.model_fields
else:
fields = schema_class.__fields__
for field_name, field_info in fields.items():
# В Pydantic v2 есть метод is_required()
if hasattr(field_info, 'is_required'):
if field_info.is_required():
required_fields.append(field_name)
elif hasattr(field_info, 'required'):
if field_info.required:
required_fields.append(field_name)
else:
# Fallback для старых версий - проверяем наличие default
has_default = False
if hasattr(field_info, 'default'):
has_default = field_info.default is not ...
elif hasattr(field_info, 'default_factory'):
has_default = field_info.default_factory is not None
if not has_default:
required_fields.append(field_name)
return required_fields
def get_optional_fields_from_schema(schema_class: Type[BaseModel]) -> List[str]:
"""
Извлекает список необязательных полей из схемы Pydantic
Args:
schema_class: Класс схемы Pydantic
Returns:
Список имен необязательных полей
"""
optional_fields = []
# Используем model_fields для Pydantic v2 или __fields__ для v1
if hasattr(schema_class, 'model_fields'):
fields = schema_class.model_fields
else:
fields = schema_class.__fields__
for field_name, field_info in fields.items():
# В Pydantic v2 есть метод is_required()
if hasattr(field_info, 'is_required'):
if not field_info.is_required():
optional_fields.append(field_name)
elif hasattr(field_info, 'required'):
if not field_info.required:
optional_fields.append(field_name)
else:
# Fallback для старых версий - проверяем наличие default
has_default = False
if hasattr(field_info, 'default'):
has_default = field_info.default is not ...
elif hasattr(field_info, 'default_factory'):
has_default = field_info.default_factory is not None
if has_default:
optional_fields.append(field_name)
return optional_fields
def register_getter_from_schema(parser_instance, getter_name: str, method: callable,
schema_class: Type[BaseModel], description: str = ""):
"""
Регистрирует геттер в парсере, используя схему Pydantic для определения параметров
Args:
parser_instance: Экземпляр парсера
getter_name: Имя геттера
method: Метод для выполнения
schema_class: Класс схемы Pydantic
description: Описание геттера (если не указано, берется из docstring метода)
"""
# Извлекаем параметры из схемы
required_params = get_required_fields_from_schema(schema_class)
optional_params = get_optional_fields_from_schema(schema_class)
# Если описание не указано, берем из docstring метода
if not description:
description = inspect.getdoc(method) or ""
# Регистрируем геттер
parser_instance.register_getter(
name=getter_name,
method=method,
required_params=required_params,
optional_params=optional_params,
description=description
)
def validate_params_with_schema(params: Dict[str, Any], schema_class: Type[BaseModel]) -> Dict[str, Any]:
"""
Валидирует параметры с помощью схемы Pydantic
Args:
params: Словарь параметров
schema_class: Класс схемы Pydantic
Returns:
Валидированные параметры
Raises:
ValidationError: Если параметры не прошли валидацию
"""
try:
# Создаем экземпляр схемы для валидации
validated_data = schema_class(**params)
# Используем model_dump() для Pydantic v2 или dict() для v1
if hasattr(validated_data, 'model_dump'):
return validated_data.model_dump()
else:
return validated_data.dict()
except Exception as e:
raise ValueError(f"Ошибка валидации параметров: {str(e)}")

View File

@@ -106,14 +106,14 @@ class ReportService:
# Получаем параметры запроса # Получаем параметры запроса
get_params = request.get_params or {} get_params = request.get_params or {}
# Определяем имя геттера из параметра mode # Определяем имя геттера (по умолчанию используем первый доступный)
getter_name = get_params.pop("mode", None) getter_name = get_params.pop("getter", None)
if not getter_name: if not getter_name:
# Если режим не указан, берем первый доступный # Если геттер не указан, берем первый доступный
available_getters = list(parser.getters.keys()) available_getters = list(parser.getters.keys())
if available_getters: if available_getters:
getter_name = available_getters[0] getter_name = available_getters[0]
print(f"⚠️ Режим не указан, используем первый доступный: {getter_name}") print(f"⚠️ Геттер не указан, используем первый доступный: {getter_name}")
else: else:
return DataResult( return DataResult(
success=False, success=False,

View File

@@ -11,4 +11,5 @@ requests>=2.31.0
# pytest-cov>=4.0.0 # pytest-cov>=4.0.0
# pytest-mock>=3.10.0 # pytest-mock>=3.10.0
httpx>=0.24.0 httpx>=0.24.0
numpy numpy
streamlit>=1.28.0

View File

@@ -0,0 +1 @@
python-3.11.*

65
run_streamlit_local.py Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
Запуск Streamlit интерфейса локально из изолированного пакета
"""
import subprocess
import sys
import webbrowser
import os
def main():
"""Основная функция"""
print("🚀 ЗАПУСК STREAMLIT ИЗ ИЗОЛИРОВАННОГО ПАКЕТА")
print("=" * 60)
print("Убедитесь, что FastAPI сервер запущен на порту 8000")
print("=" * 60)
# Проверяем, существует ли папка streamlit_app
if not os.path.exists("streamlit_app"):
print("❌ Папка streamlit_app не найдена")
print("Создайте изолированный пакет или используйте docker-compose up -d")
return
# Переходим в папку streamlit_app
os.chdir("streamlit_app")
# Проверяем, установлен ли Streamlit
try:
import streamlit
print(f"✅ Streamlit {streamlit.__version__} установлен")
except ImportError:
print("❌ Streamlit не установлен")
print("Установите: pip install -r requirements.txt")
return
print("\n🚀 Запускаю Streamlit...")
print("📍 URL: http://localhost:8501")
print("🔗 API: http://localhost:8000")
print("🛑 Для остановки нажмите Ctrl+C")
# Открываем браузер
try:
webbrowser.open("http://localhost:8501")
print("✅ Браузер открыт")
except Exception as e:
print(f"⚠️ Не удалось открыть браузер: {e}")
# Запускаем Streamlit с правильными переменными окружения
env = os.environ.copy()
env["DOCKER_ENV"] = "false" # Локальный запуск
env["API_BASE_URL"] = "http://localhost:8000" # Локальный API
try:
subprocess.run([
sys.executable, "-m", "streamlit", "run", "app.py",
"--server.port", "8501",
"--server.address", "localhost",
"--server.headless", "false",
"--browser.gatherUsageStats", "false"
], env=env)
except KeyboardInterrupt:
print("\n👋 Streamlit остановлен")
if __name__ == "__main__":
main()

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""
Скрипт для запуска проекта в режиме разработки
"""
import subprocess
import sys
import os
def run_command(command, description):
"""Выполнение команды с выводом"""
print(f"🔄 {description}...")
try:
result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
print(f"{description} выполнено успешно")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Ошибка при {description.lower()}:")
print(f" Команда: {command}")
print(f" Ошибка: {e.stderr}")
return False
def main():
print("🚀 Запуск проекта в режиме разработки")
print("=" * 50)
# Останавливаем продакшн контейнеры если они запущены
if run_command("docker compose ps", "Проверка статуса контейнеров"):
if "Up" in subprocess.run("docker compose ps", shell=True, capture_output=True, text=True).stdout:
print("🛑 Останавливаю продакшн контейнеры...")
run_command("docker compose down", "Остановка продакшн контейнеров")
# Запускаем режим разработки
print("\n🔧 Запуск режима разработки...")
if run_command("docker compose -f docker-compose.dev.yml up -d", "Запуск контейнеров разработки"):
print("\n🎉 Проект запущен в режиме разработки!")
print("\n📍 Доступные сервисы:")
print(" • Streamlit: http://localhost:8501")
print(" • FastAPI: http://localhost:8000")
print(" • MinIO Console: http://localhost:9001")
print("\n💡 Теперь изменения в streamlit_app/ будут автоматически перезагружаться!")
print("\n🛑 Для остановки используйте:")
print(" docker compose -f docker-compose.dev.yml down")
else:
print("\nНе удалось запустить проект в режиме разработки")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""
Скрипт для запуска проекта в продакшн режиме
"""
import subprocess
import sys
def run_command(command, description):
"""Выполнение команды с выводом"""
print(f"🔄 {description}...")
try:
result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
print(f"{description} выполнено успешно")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Ошибка при {description.lower()}:")
print(f" Команда: {command}")
print(f" Ошибка: {e.stderr}")
return False
def main():
print("🚀 Запуск проекта в продакшн режиме")
print("=" * 50)
# Останавливаем контейнеры разработки если они запущены
if run_command("docker compose -f docker-compose.dev.yml ps", "Проверка статуса контейнеров разработки"):
if "Up" in subprocess.run("docker compose -f docker-compose.dev.yml ps", shell=True, capture_output=True, text=True).stdout:
print("🛑 Останавливаю контейнеры разработки...")
run_command("docker compose -f docker-compose.dev.yml down", "Остановка контейнеров разработки")
# Запускаем продакшн режим
print("\n🏭 Запуск продакшн режима...")
if run_command("docker compose up -d --build", "Запуск продакшн контейнеров"):
print("\n🎉 Проект запущен в продакшн режиме!")
print("\n📍 Доступные сервисы:")
print(" • Streamlit: http://localhost:8501")
print(" • FastAPI: http://localhost:8000")
print(" • MinIO Console: http://localhost:9001")
print("\n💡 Для разработки используйте:")
print(" python start_dev.py")
print("\n🛑 Для остановки используйте:")
print(" docker compose down")
else:
print("\nНе удалось запустить проект в продакшн режиме")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,31 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.mypy_cache
.pytest_cache
.hypothesis
.DS_Store
.env
.venv
venv/
ENV/
env/
.idea/
.vscode/
*.swp
*.swo
*~

View File

@@ -1,15 +0,0 @@
[server]
port = 8501
address = "0.0.0.0"
enableCORS = false
enableXsrfProtection = false
[browser]
gatherUsageStats = false
[theme]
primaryColor = "#FF4B4B"
backgroundColor = "#FFFFFF"
secondaryBackgroundColor = "#F0F2F6"
textColor = "#262730"
font = "sans serif"

View File

@@ -2,22 +2,22 @@ FROM python:3.11-slim
WORKDIR /app WORKDIR /app
# Установка системных зависимостей # Устанавливаем системные зависимости
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
gcc \ gcc \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Копирование requirements.txt # Копируем файлы зависимостей
COPY requirements.txt . COPY requirements.txt .
# Установка Python зависимостей # Устанавливаем Python зависимости
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Копирование кода приложения # Копируем код приложения
COPY . . COPY . .
# Открытие порта # Открываем порт
EXPOSE 8501 EXPOSE 8501
# Запуск Streamlit # Команда запуска
CMD ["streamlit", "run", "streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"] CMD ["streamlit", "run", "app.py", "--server.port", "8501", "--server.address", "0.0.0.0"]

44
streamlit_app/README.md Normal file
View File

@@ -0,0 +1,44 @@
# 📊 Streamlit App - NIN Excel Parsers API
Изолированное Streamlit приложение для демонстрации работы NIN Excel Parsers API.
## 🚀 Запуск
### Локально:
```bash
cd streamlit_app
pip install -r requirements.txt
streamlit run app.py
```
### В Docker:
```bash
docker build -t streamlit-app .
docker run -p 8501:8501 streamlit-app
```
## 🔧 Конфигурация
### Переменные окружения:
- `API_BASE_URL` - адрес FastAPI сервера (по умолчанию: `http://fastapi:8000`)
### Параметры Streamlit:
- Порт: 8501
- Адрес: 0.0.0.0 (для Docker)
- Режим: headless (для Docker)
## 📁 Структура
```
streamlit_app/
├── app.py # Основное приложение
├── requirements.txt # Зависимости Python
├── Dockerfile # Docker образ
├── .streamlit/ # Конфигурация Streamlit
│ └── config.toml # Настройки
└── README.md # Документация
```
## 🌐 Доступ
После запуска приложение доступно по адресу: **http://localhost:8501**

View File

@@ -1,100 +0,0 @@
import streamlit as st
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from minio import Minio
import os
from io import BytesIO
# Конфигурация страницы
st.set_page_config(
page_title="Сводка данных",
page_icon="📊",
layout="wide",
initial_sidebar_state="expanded"
)
# Заголовок приложения
st.title("📊 Анализ данных сводки")
st.markdown("---")
# Инициализация MinIO клиента
@st.cache_resource
def init_minio_client():
try:
client = Minio(
os.getenv("MINIO_ENDPOINT", "localhost:9000"),
access_key=os.getenv("MINIO_ACCESS_KEY", "minioadmin"),
secret_key=os.getenv("MINIO_SECRET_KEY", "minioadmin"),
secure=os.getenv("MINIO_SECURE", "false").lower() == "true"
)
return client
except Exception as e:
st.error(f"Ошибка подключения к MinIO: {e}")
return None
# Боковая панель
with st.sidebar:
st.header("⚙️ Настройки")
# Выбор типа данных
data_type = st.selectbox(
"Тип данных",
["Мониторинг топлива", "Сводка ПМ", "Сводка ЦА"]
)
# Выбор периода
period = st.date_input(
"Период",
value=pd.Timestamp.now().date()
)
st.markdown("---")
st.markdown("### 📈 Статистика")
st.info("Выберите тип данных для анализа")
# Основной контент
col1, col2 = st.columns([2, 1])
with col1:
st.subheader(f"📋 {data_type}")
if data_type == "Мониторинг топлива":
st.info("Анализ данных мониторинга топлива")
# Здесь будет логика для работы с данными мониторинга топлива
elif data_type == "Сводка ПМ":
st.info("Анализ данных сводки ПМ")
# Здесь будет логика для работы с данными сводки ПМ
elif data_type == "Сводка ЦА":
st.info("Анализ данных сводки ЦА")
# Здесь будет логика для работы с данными сводки ЦА
with col2:
st.subheader("📊 Быстрая статистика")
st.metric("Всего записей", "0")
st.metric("Активных", "0")
st.metric("Ошибок", "0")
# Нижняя панель
st.markdown("---")
st.subheader("🔍 Детальный анализ")
# Заглушка для графиков
placeholder = st.empty()
with placeholder.container():
col1, col2 = st.columns(2)
with col1:
st.write("📈 График 1")
# Здесь будет график
with col2:
st.write("📊 График 2")
# Здесь будет график
# Футер
st.markdown("---")
st.markdown("**Разработано для анализа данных сводки** | v1.0.0")

View File

@@ -15,9 +15,17 @@ st.set_page_config(
initial_sidebar_state="expanded" initial_sidebar_state="expanded"
) )
# Конфигурация API # Конфигурация API - автоматически определяем правильный адрес
API_BASE_URL = os.getenv("API_BASE_URL", "http://fastapi:8000") # Внутренний адрес для Docker def get_api_base_url():
API_PUBLIC_URL = os.getenv("API_PUBLIC_URL", "http://localhost:8000") # Внешний адрес для пользователя """Автоматически определяет правильный адрес API"""
# Если запущено в Docker, используем внутренний адрес
if os.getenv("DOCKER_ENV") == "true":
return "http://fastapi:8000"
# Если запущено локально, используем localhost
return "http://localhost:8000"
API_BASE_URL = os.getenv("API_BASE_URL", get_api_base_url())
def check_api_health(): def check_api_health():
"""Проверка доступности API""" """Проверка доступности API"""
@@ -37,6 +45,16 @@ def get_available_parsers():
except: except:
return [] return []
def get_parser_getters(parser_name: str):
"""Получение информации о геттерах парсера"""
try:
response = requests.get(f"{API_BASE_URL}/parsers/{parser_name}/getters")
if response.status_code == 200:
return response.json()
return {}
except:
return {}
def get_server_info(): def get_server_info():
"""Получение информации о сервере""" """Получение информации о сервере"""
try: try:
@@ -74,7 +92,7 @@ def main():
st.info("Убедитесь, что FastAPI сервер запущен") st.info("Убедитесь, что FastAPI сервер запущен")
return return
st.success(f"✅ API доступен по адресу {API_PUBLIC_URL}") st.success(f"✅ API доступен по адресу {API_BASE_URL}")
# Боковая панель с информацией # Боковая панель с информацией
with st.sidebar: with st.sidebar:
@@ -106,6 +124,9 @@ def main():
with tab1: with tab1:
st.header("📊 Сводки ПМ - Полный функционал") st.header("📊 Сводки ПМ - Полный функционал")
# Получаем информацию о геттерах
getters_info = get_parser_getters("svodka_pm")
# Секция загрузки файлов # Секция загрузки файлов
st.subheader("📤 Загрузка файлов") st.subheader("📤 Загрузка файлов")
uploaded_pm = st.file_uploader( uploaded_pm = st.file_uploader(
@@ -134,6 +155,15 @@ def main():
# Секция получения данных # Секция получения данных
st.subheader("🔍 Получение данных") st.subheader("🔍 Получение данных")
# Показываем доступные геттеры
if getters_info and "getters" in getters_info:
st.info("📋 Доступные геттеры:")
for getter_name, getter_info in getters_info["getters"].items():
st.write(f"• **{getter_name}**: {getter_info.get('description', 'Нет описания')}")
st.write(f" - Обязательные параметры: {', '.join(getter_info.get('required_params', []))}")
if getter_info.get('optional_params'):
st.write(f" - Необязательные параметры: {', '.join(getter_info['optional_params'])}")
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
with col1: with col1:
@@ -165,12 +195,13 @@ def main():
if codes and columns: if codes and columns:
with st.spinner("Получаю данные..."): with st.spinner("Получаю данные..."):
data = { data = {
"getter": "single_og",
"id": og_id, "id": og_id,
"codes": codes, "codes": codes,
"columns": columns "columns": columns
} }
result, status = make_api_request("/svodka_pm/get_single_og", data) result, status = make_api_request("/svodka_pm/get_data", data)
if status == 200: if status == 200:
st.success("✅ Данные получены") st.success("✅ Данные получены")
@@ -201,11 +232,12 @@ def main():
if codes_total and columns_total: if codes_total and columns_total:
with st.spinner("Получаю данные..."): with st.spinner("Получаю данные..."):
data = { data = {
"getter": "total_ogs",
"codes": codes_total, "codes": codes_total,
"columns": columns_total "columns": columns_total
} }
result, status = make_api_request("/svodka_pm/get_total_ogs", data) result, status = make_api_request("/svodka_pm/get_data", data)
if status == 200: if status == 200:
st.success("✅ Данные получены") st.success("✅ Данные получены")
@@ -219,6 +251,9 @@ def main():
with tab2: with tab2:
st.header("🏭 Сводки СА - Полный функционал") st.header("🏭 Сводки СА - Полный функционал")
# Получаем информацию о геттерах
getters_info = get_parser_getters("svodka_ca")
# Секция загрузки файлов # Секция загрузки файлов
st.subheader("📤 Загрузка файлов") st.subheader("📤 Загрузка файлов")
uploaded_ca = st.file_uploader( uploaded_ca = st.file_uploader(
@@ -246,7 +281,16 @@ def main():
st.markdown("---") st.markdown("---")
# Секция получения данных # Секция получения данных
st.subheader("🔍 Получение данных") st.subheader("<EFBFBD><EFBFBD> Получение данных")
# Показываем доступные геттеры
if getters_info and "getters" in getters_info:
st.info("📋 Доступные геттеры:")
for getter_name, getter_info in getters_info["getters"].items():
st.write(f"• **{getter_name}**: {getter_info.get('description', 'Нет описания')}")
st.write(f" - Обязательные параметры: {', '.join(getter_info.get('required_params', []))}")
if getter_info.get('optional_params'):
st.write(f" - Необязательные параметры: {', '.join(getter_info['optional_params'])}")
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
@@ -273,17 +317,18 @@ def main():
if modes and tables: if modes and tables:
with st.spinner("Получаю данные..."): with st.spinner("Получаю данные..."):
data = { data = {
"getter": "get_data",
"modes": modes, "modes": modes,
"tables": tables "tables": tables
} }
result, status = make_api_request("/svodka_ca/get_ca_data", data) result, status = make_api_request("/svodka_ca/get_data", data)
if status == 200: if status == 200:
st.success("✅ Данные получены") st.success("✅ Данные получены")
st.json(result) st.json(result)
else: else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") st.error(f"❌ Ошибка: {result.get('message', f'Неизвестная ошибка: {status}')}")
else: else:
st.warning("⚠️ Выберите режимы и таблицы") st.warning("⚠️ Выберите режимы и таблицы")
@@ -291,6 +336,9 @@ def main():
with tab3: with tab3:
st.header("⛽ Мониторинг топлива - Полный функционал") st.header("⛽ Мониторинг топлива - Полный функционал")
# Получаем информацию о геттерах
getters_info = get_parser_getters("monitoring_fuel")
# Секция загрузки файлов # Секция загрузки файлов
st.subheader("📤 Загрузка файлов") st.subheader("📤 Загрузка файлов")
uploaded_fuel = st.file_uploader( uploaded_fuel = st.file_uploader(
@@ -319,6 +367,15 @@ def main():
# Секция получения данных # Секция получения данных
st.subheader("🔍 Получение данных") st.subheader("🔍 Получение данных")
# Показываем доступные геттеры
if getters_info and "getters" in getters_info:
st.info("📋 Доступные геттеры:")
for getter_name, getter_info in getters_info["getters"].items():
st.write(f"• **{getter_name}**: {getter_info.get('description', 'Нет описания')}")
st.write(f" - Обязательные параметры: {', '.join(getter_info.get('required_params', []))}")
if getter_info.get('optional_params'):
st.write(f" - Необязательные параметры: {', '.join(getter_info['optional_params'])}")
col1, col2 = st.columns(2) col1, col2 = st.columns(2)
with col1: with col1:
@@ -335,10 +392,11 @@ def main():
if columns_fuel: if columns_fuel:
with st.spinner("Получаю данные..."): with st.spinner("Получаю данные..."):
data = { data = {
"getter": "total_by_columns",
"columns": columns_fuel "columns": columns_fuel
} }
result, status = make_api_request("/monitoring_fuel/get_total_by_columns", data) result, status = make_api_request("/monitoring_fuel/get_data", data)
if status == 200: if status == 200:
st.success("✅ Данные получены") st.success("✅ Данные получены")
@@ -360,49 +418,22 @@ def main():
if st.button("🔍 Получить данные за месяц", key="fuel_month_btn"): if st.button("🔍 Получить данные за месяц", key="fuel_month_btn"):
with st.spinner("Получаю данные..."): with st.spinner("Получаю данные..."):
data = { data = {
"getter": "month_by_code",
"month": month "month": month
} }
result, status = make_api_request("/monitoring_fuel/get_month_by_code", data) result, status = make_api_request("/monitoring_fuel/get_data", data)
if status == 200: if status == 200:
st.success("✅ Данные получены") st.success("✅ Данные получены")
st.json(result) st.json(result)
else: else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}") st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
# Новая секция для временных рядов
st.markdown("---")
st.subheader("📈 Временные ряды по ID и колонкам")
columns_series = st.multiselect(
"Выберите столбцы для временных рядов",
["normativ", "total", "total_1"],
default=["normativ", "total"],
key="fuel_series_columns"
)
if st.button("📈 Получить временные ряды", key="fuel_series_btn"):
if columns_series:
with st.spinner("Получаю временные ряды..."):
data = {
"columns": columns_series
}
result, status = make_api_request("/monitoring_fuel/get_series_by_id_and_columns", data)
if status == 200:
st.success("✅ Временные ряды получены")
st.json(result)
else:
st.error(f"❌ Ошибка: {result.get('message', 'Неизвестная ошибка')}")
else:
st.warning("⚠️ Выберите столбцы")
# Футер # Футер
st.markdown("---") st.markdown("---")
st.markdown("### 📚 Документация API") st.markdown("### 📚 Документация API")
st.markdown(f"Полная документация доступна по адресу: {API_PUBLIC_URL}/docs") st.markdown(f"Полная документация доступна по адресу: {API_BASE_URL}/docs")
# Информация о проекте # Информация о проекте
with st.expander(" О проекте"): with st.expander(" О проекте"):

View File

@@ -1,7 +1,4 @@
streamlit>=1.28.0 streamlit>=1.28.0
pandas>=2.0.0 requests>=2.31.0
numpy>=1.24.0 pandas>=1.5.0
plotly>=5.15.0 numpy>=1.24.0
minio>=7.1.0
openpyxl>=3.1.0
xlrd>=2.0.1

84
test_api.py Normal file
View File

@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
Тестовый скрипт для проверки API
"""
import requests
import json
def test_api_endpoints():
"""Тестирование API эндпоинтов"""
base_url = "http://localhost:8000"
print("🧪 ТЕСТИРОВАНИЕ API")
print("=" * 50)
# Тест 1: Проверка доступности API
print("\n1⃣ Проверка доступности API...")
try:
response = requests.get(f"{base_url}/")
if response.status_code == 200:
print(f"✅ API доступен: {response.json()}")
else:
print(f"❌ API недоступен: {response.status_code}")
return False
except Exception as e:
print(f"❌ Ошибка подключения к API: {e}")
return False
# Тест 2: Список парсеров
print("\n2⃣ Получение списка парсеров...")
try:
response = requests.get(f"{base_url}/parsers")
if response.status_code == 200:
parsers = response.json()
print(f"✅ Парсеры: {parsers}")
else:
print(f"❌ Ошибка получения парсеров: {response.status_code}")
except Exception as e:
print(f"❌ Ошибка: {e}")
# Тест 3: Информация о геттерах
print("\n3⃣ Информация о геттерах парсеров...")
parsers_to_test = ["svodka_pm", "svodka_ca", "monitoring_fuel"]
for parser in parsers_to_test:
try:
response = requests.get(f"{base_url}/parsers/{parser}/getters")
if response.status_code == 200:
getters = response.json()
print(f"{parser}: {len(getters.get('getters', {}))} геттеров")
else:
print(f"{parser}: ошибка {response.status_code}")
except Exception as e:
print(f"{parser}: ошибка {e}")
# Тест 4: Загрузка тестового файла
print("\n4⃣ Тест загрузки файла...")
try:
# Создаем простой Excel файл для теста
test_data = b"test content"
files = {"file": ("test.xlsx", test_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
response = requests.post(f"{base_url}/svodka_ca/upload", files=files)
print(f"📤 Результат загрузки: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Файл загружен: {result}")
else:
print(f"❌ Ошибка загрузки: {response.status_code}")
try:
error_detail = response.json()
print(f"📋 Детали ошибки: {error_detail}")
except:
print(f"📋 Текст ошибки: {response.text}")
except Exception as e:
print(f"❌ Ошибка теста загрузки: {e}")
print("\n🎯 Тестирование завершено!")
return True
if __name__ == "__main__":
test_api_endpoints()

79
test_api_direct.py Normal file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Прямое тестирование API эндпоинтов
"""
import requests
import json
def test_api_endpoints():
"""Тестирование API эндпоинтов"""
base_url = "http://localhost:8000"
print("🧪 ПРЯМОЕ ТЕСТИРОВАНИЕ API")
print("=" * 40)
# Тест 1: Проверка доступности API
print("\n1⃣ Проверка доступности API...")
try:
response = requests.get(f"{base_url}/")
print(f"✅ API доступен: {response.status_code}")
except Exception as e:
print(f"❌ Ошибка: {e}")
return
# Тест 2: Тестирование эндпоинта svodka_ca/get_data
print("\n2⃣ Тестирование svodka_ca/get_data...")
try:
data = {
"getter": "get_data",
"modes": ["plan", "fact"],
"tables": ["ТиП", "Топливо"]
}
response = requests.post(f"{base_url}/svodka_ca/get_data", json=data)
print(f"📥 Результат: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Успешно: {result}")
else:
try:
error_detail = response.json()
print(f"❌ Ошибка: {error_detail}")
except:
print(f"❌ Ошибка: {response.text}")
except Exception as e:
print(f"❌ Исключение: {e}")
# Тест 3: Тестирование эндпоинта svodka_pm/get_data
print("\n3⃣ Тестирование svodka_pm/get_data...")
try:
data = {
"getter": "single_og",
"id": "SNPZ",
"codes": [78, 79],
"columns": ["БП", "ПП"]
}
response = requests.post(f"{base_url}/svodka_pm/get_data", json=data)
print(f"📥 Результат: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Успешно: {result}")
else:
try:
error_detail = response.json()
print(f"❌ Ошибка: {error_detail}")
except:
print(f"❌ Ошибка: {response.text}")
except Exception as e:
print(f"❌ Исключение: {e}")
print("\n🎯 Тестирование завершено!")
if __name__ == "__main__":
test_api_endpoints()

96
test_ca_workflow.py Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Тестирование полного workflow с сводкой СА
"""
import requests
import os
import time
def test_ca_workflow():
"""Тестирование полного workflow с сводкой СА"""
base_url = "http://localhost:8000"
test_file = "python_parser/data/svodka_ca.xlsx"
print("🧪 ТЕСТ ПОЛНОГО WORKFLOW СВОДКИ СА")
print("=" * 50)
# Проверяем, что файл существует
if not os.path.exists(test_file):
print(f"❌ Файл {test_file} не найден")
return False
print(f"📁 Тестовый файл найден: {test_file}")
print(f"📏 Размер: {os.path.getsize(test_file)} байт")
# Шаг 1: Загружаем файл
print("\n1⃣ Загружаю файл сводки СА...")
try:
with open(test_file, 'rb') as f:
file_data = f.read()
files = {"file": ("svodka_ca.xlsx", file_data, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}
response = requests.post(f"{base_url}/svodka_ca/upload", files=files)
print(f"📤 Результат загрузки: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Файл загружен: {result}")
object_id = result.get('object_id', 'nin_excel_data_svodka_ca')
else:
print(f"❌ Ошибка загрузки: {response.status_code}")
try:
error_detail = response.json()
print(f"📋 Детали ошибки: {error_detail}")
except:
print(f"📋 Текст ошибки: {response.text}")
return False
except Exception as e:
print(f"❌ Ошибка загрузки: {e}")
return False
# Шаг 2: Получаем данные через геттер
print("\n2⃣ Получаю данные через геттер...")
try:
data = {
"getter": "get_data",
"modes": ["plan", "fact"], # Используем английские названия
"tables": ["ТиП", "Топливо"]
}
response = requests.post(f"{base_url}/svodka_ca/get_data", json=data)
print(f"📥 Результат получения данных: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Данные получены успешно!")
print(f"📊 Размер ответа: {len(str(result))} символов")
# Показываем структуру данных
if isinstance(result, dict):
print(f"🔍 Структура данных:")
for key, value in result.items():
if isinstance(value, dict):
print(f" {key}: {len(value)} элементов")
else:
print(f" {key}: {type(value).__name__}")
else:
print(f"❌ Ошибка получения данных: {response.status_code}")
try:
error_detail = response.json()
print(f"📋 Детали ошибки: {error_detail}")
except:
print(f"📋 Текст ошибки: {response.text}")
return False
except Exception as e:
print(f"❌ Ошибка получения данных: {e}")
return False
print("\n🎯 Тестирование завершено успешно!")
return True
if __name__ == "__main__":
test_ca_workflow()

110
test_minio_connection.py Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
Тестовый скрипт для проверки подключения к MinIO
"""
import os
import sys
import io
from minio import Minio
def test_minio_connection():
"""Тестирование подключения к MinIO"""
print("🔍 Тестирование подключения к MinIO...")
# Параметры подключения
endpoint = os.getenv("MINIO_ENDPOINT", "localhost:9000")
access_key = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
secret_key = os.getenv("MINIO_SECRET_KEY", "minioadmin")
bucket_name = os.getenv("MINIO_BUCKET", "svodka-data")
print(f"📍 Endpoint: {endpoint}")
print(f"🔑 Access Key: {access_key}")
print(f"🔐 Secret Key: {secret_key}")
print(f"🪣 Bucket: {bucket_name}")
try:
# Создаем клиент
print("\n🚀 Создаю MinIO клиент...")
client = Minio(
endpoint,
access_key=access_key,
secret_key=secret_key,
secure=False,
cert_check=False
)
# Проверяем подключение
print("✅ MinIO клиент создан")
# Проверяем bucket
print(f"\n🔍 Проверяю bucket '{bucket_name}'...")
if client.bucket_exists(bucket_name):
print(f"✅ Bucket '{bucket_name}' существует")
else:
print(f"⚠️ Bucket '{bucket_name}' не существует, создаю...")
client.make_bucket(bucket_name)
print(f"✅ Bucket '{bucket_name}' создан")
# Пробуем загрузить тестовый файл
print("\n📤 Тестирую загрузку файла...")
test_data = b"Hello MinIO!"
test_stream = io.BytesIO(test_data)
client.put_object(
bucket_name,
"test.txt",
test_stream,
length=len(test_data),
content_type='text/plain'
)
print("✅ Тестовый файл загружен")
# Пробуем скачать файл
print("\n📥 Тестирую скачивание файла...")
response = client.get_object(bucket_name, "test.txt")
downloaded_data = response.read()
print(f"✅ Файл скачан: {downloaded_data}")
# Удаляем тестовый файл
client.remove_object(bucket_name, "test.txt")
print("✅ Тестовый файл удален")
print("\n🎉 Все тесты MinIO прошли успешно!")
return True
except Exception as e:
print(f"\n❌ Ошибка подключения к MinIO: {e}")
print(f"Тип ошибки: {type(e).__name__}")
return False
def test_environment():
"""Проверка переменных окружения"""
print("🔧 Проверка переменных окружения:")
env_vars = [
"MINIO_ENDPOINT",
"MINIO_ACCESS_KEY",
"MINIO_SECRET_KEY",
"MINIO_BUCKET"
]
for var in env_vars:
value = os.getenv(var, "НЕ УСТАНОВЛЕНО")
print(f" {var}: {value}")
if __name__ == "__main__":
print("=" * 60)
print("🧪 ТЕСТ ПОДКЛЮЧЕНИЯ К MINIO")
print("=" * 60)
test_environment()
print()
success = test_minio_connection()
if success:
print("\n✅ MinIO работает корректно!")
sys.exit(0)
else:
print("\n❌ Проблемы с MinIO!")
sys.exit(1)

69
test_upload.py Normal file
View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""
Тестирование загрузки Excel файла
"""
import requests
import os
def test_file_upload():
"""Тестирование загрузки файла"""
base_url = "http://localhost:8000"
filename = "test_file.xlsx"
print("🧪 ТЕСТ ЗАГРУЗКИ ФАЙЛА")
print("=" * 40)
# Проверяем, что файл существует
if not os.path.exists(filename):
print(f"❌ Файл {filename} не найден")
return False
print(f"📁 Файл найден: {filename}")
print(f"📏 Размер: {os.path.getsize(filename)} байт")
# Тестируем загрузку в разные парсеры
parsers = [
("svodka_ca", "/svodka_ca/upload", "file"),
("monitoring_fuel", "/monitoring_fuel/upload-zip", "zip_file"),
("svodka_pm", "/svodka_pm/upload-zip", "zip_file")
]
for parser_name, endpoint, file_param in parsers:
print(f"\n🔍 Тестирую {parser_name}...")
try:
# Читаем файл
with open(filename, 'rb') as f:
file_data = f.read()
# Определяем content type
if filename.endswith('.xlsx'):
content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
content_type = "application/octet-stream"
# Загружаем файл с правильным параметром
files = {file_param: (filename, file_data, content_type)}
response = requests.post(f"{base_url}{endpoint}", files=files)
print(f"📤 Результат: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"✅ Успешно: {result}")
else:
try:
error_detail = response.json()
print(f"❌ Ошибка: {error_detail}")
except:
print(f"❌ Ошибка: {response.text}")
except Exception as e:
print(f"❌ Исключение: {e}")
print("\n🎯 Тестирование завершено!")
return True
if __name__ == "__main__":
test_file_upload()

View File

@@ -1,123 +0,0 @@
# API Endpoints Tests
Этот модуль содержит pytest тесты для всех API эндпоинтов проекта NIN Excel Parsers.
## Структура
```
tests/
├── __init__.py
├── conftest.py # Конфигурация pytest
├── test_all_endpoints.py # Основной файл для запуска всех тестов
├── test_upload_endpoints.py # Тесты API эндпоинтов загрузки данных
├── test_svodka_pm_endpoints.py # Тесты API svodka_pm эндпоинтов
├── test_svodka_ca_endpoints.py # Тесты API svodka_ca эндпоинтов
├── test_monitoring_fuel_endpoints.py # Тесты API monitoring_fuel эндпоинтов
├── test_parsers_direct.py # Прямое тестирование парсеров
├── test_upload_with_local_storage.py # Тестирование загрузки в локальный storage
├── test_getters_with_local_storage.py # Тестирование геттеров с локальными данными
├── test_data/ # Тестовые данные
│ ├── svodka_ca.xlsx
│ ├── pm_plan.zip
│ └── monitoring.zip
├── local_storage/ # Локальный storage (создается автоматически)
│ ├── data/ # Сохраненные DataFrame
│ └── metadata/ # Метаданные объектов
├── requirements.txt # Зависимости для тестов
└── README.md # Этот файл
```
## Установка зависимостей
```bash
pip install -r tests/requirements.txt
```
## Запуск тестов
### Запуск всех тестов
```bash
cd tests
python test_all_endpoints.py
```
### Запуск конкретных тестов
```bash
# API тесты (требуют запущенный сервер)
pytest test_upload_endpoints.py -v
pytest test_svodka_pm_endpoints.py -v
pytest test_svodka_ca_endpoints.py -v
pytest test_monitoring_fuel_endpoints.py -v
# Прямые тесты парсеров (не требуют сервер)
pytest test_parsers_direct.py -v
pytest test_upload_with_local_storage.py -v
pytest test_getters_with_local_storage.py -v
# Все тесты с локальным storage
pytest test_parsers_direct.py test_upload_with_local_storage.py test_getters_with_local_storage.py -v
```
## Предварительные условия
1. **API сервер должен быть запущен** на `http://localhost:8000` (только для API тестов)
2. **Тестовые данные** находятся в папке `test_data/`
3. **Локальный storage** используется для прямого тестирования парсеров
## Последовательность тестирования
### Вариант 1: API тесты (требуют запущенный сервер)
1. **Загрузка данных** (`test_upload_endpoints.py`)
- Загрузка `svodka_ca.xlsx`
- Загрузка `pm_plan.zip`
- Загрузка `monitoring.zip`
2. **Тестирование эндпоинтов** (в любом порядке)
- `test_svodka_pm_endpoints.py`
- `test_svodka_ca_endpoints.py`
- `test_monitoring_fuel_endpoints.py`
### Вариант 2: Прямые тесты (не требуют сервер)
1. **Тестирование парсеров** (`test_parsers_direct.py`)
- Проверка регистрации парсеров
- Проверка локального storage
2. **Загрузка в локальный storage** (`test_upload_with_local_storage.py`)
- Загрузка всех файлов в локальный storage
- Проверка сохранения данных
3. **Тестирование геттеров** (`test_getters_with_local_storage.py`)
- Тестирование всех геттеров с локальными данными
- Выявление проблем в логике парсеров
## Ожидаемые результаты
Все тесты должны возвращать **статус 200** и содержать поле `"success": true` в ответе.
## Примеры тестовых запросов
Тесты используют примеры из Pydantic схем:
### svodka_pm
```json
{
"id": "SNPZ",
"codes": [78, 79],
"columns": ["ПП", "СЭБ"]
}
```
### svodka_ca
```json
{
"modes": ["fact", "plan"],
"tables": ["table1", "table2"]
}
```
### monitoring_fuel
```json
{
"columns": ["total", "normativ"]
}
```

View File

@@ -1,71 +0,0 @@
# Результаты тестирования API эндпоинтов
## Сводка
Создана полная система тестирования с локальным storage для проверки всех API эндпоинтов проекта NIN Excel Parsers.
## Структура тестов
### 1. Прямые тесты парсеров (`test_parsers_direct.py`)
-**Регистрация парсеров** - все парсеры корректно регистрируются
-**Локальный storage** - работает корректно
-**ReportService** - корректно работает с локальным storage
### 2. Тесты загрузки (`test_upload_with_local_storage.py`)
-**svodka_ca.xlsx** - парсер возвращает `None`
-**pm_plan.zip** - парсер возвращает словарь с `None` значениями
-**monitoring.zip** - парсер возвращает пустой словарь
### 3. Тесты геттеров (`test_getters_with_local_storage.py`)
-**Все геттеры** - не работают из-за проблем с загрузкой данных
### 4. API тесты (`test_*_endpoints.py`)
-**Загрузка файлов** - эндпоинты работают
-**Геттеры** - не работают из-за проблем с данными
## Выявленные проблемы
### 1. Парсер svodka_ca
- **Проблема**: Возвращает `None` вместо DataFrame
- **Причина**: Парсер не может обработать тестовый файл `svodka_ca.xlsx`
- **Статус**: Требует исправления
### 2. Парсер svodka_pm
- **Проблема**: Возвращает словарь с `None` значениями
- **Причина**: Файлы в архиве `pm_plan.zip` не найдены (неправильные имена файлов)
- **Статус**: Требует исправления логики поиска файлов
### 3. Парсер monitoring_fuel
- **Проблема**: Возвращает пустой словарь
- **Причина**: Ошибки при загрузке файлов - "None of ['id'] are in the columns"
- **Статус**: Требует исправления логики обработки колонок
## Рекомендации
### Немедленные действия
1. **Исправить парсер svodka_ca** - проверить логику парсинга Excel файлов
2. **Исправить парсер svodka_pm** - проверить логику поиска файлов в архиве
3. **Исправить парсер monitoring_fuel** - проверить логику обработки колонок
### Долгосрочные улучшения
1. **Улучшить обработку ошибок** в парсерах
2. **Добавить валидацию данных** перед сохранением
3. **Создать более детальные тесты** для каждого парсера
## Техническая информация
### Локальный storage
- ✅ Создан `LocalStorageAdapter` для тестирования
- ✅ Поддерживает все операции: save, load, delete, list
- ✅ Автоматически очищается после тестов
### Инфраструктура тестов
- ✅ Pytest конфигурация с фикстурами
- ✅ Автоматическая регистрация парсеров
- ✅ Поддержка как API, так и прямых тестов
## Заключение
Система тестирования создана и работает корректно. Выявлены конкретные проблемы в парсерах, которые требуют исправления. После исправления парсеров все тесты должны пройти успешно.
**Следующий шаг**: Исправить выявленные проблемы в парсерах согласно результатам отладочных тестов.

View File

@@ -1 +0,0 @@
# Tests package

View File

@@ -1,97 +0,0 @@
"""
Конфигурация pytest для тестирования API эндпоинтов
"""
import pytest
import requests
import time
import os
import sys
from pathlib import Path
# Добавляем путь к проекту для импорта модулей
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
from adapters.local_storage import LocalStorageAdapter
# Базовый URL API
API_BASE_URL = "http://localhost:8000"
# Путь к тестовым данным
TEST_DATA_DIR = Path(__file__).parent / "test_data"
@pytest.fixture(scope="session")
def api_base_url():
"""Базовый URL для API"""
return API_BASE_URL
@pytest.fixture(scope="session")
def test_data_dir():
"""Директория с тестовыми данными"""
return TEST_DATA_DIR
@pytest.fixture(scope="session")
def wait_for_api():
"""Ожидание готовности API"""
max_attempts = 30
for attempt in range(max_attempts):
try:
response = requests.get(f"{API_BASE_URL}/docs", timeout=5)
if response.status_code == 200:
print(f"✅ API готов после {attempt + 1} попыток")
return True
except requests.exceptions.RequestException:
pass
if attempt < max_attempts - 1:
time.sleep(2)
pytest.fail("❌ API не готов после 30 попыток")
@pytest.fixture
def upload_file(test_data_dir):
"""Фикстура для загрузки файла"""
def _upload_file(filename):
file_path = test_data_dir / filename
if not file_path.exists():
pytest.skip(f"Файл {filename} не найден в {test_data_dir}")
return file_path
return _upload_file
@pytest.fixture(scope="session")
def local_storage():
"""Фикстура для локального storage"""
storage = LocalStorageAdapter("tests/local_storage")
yield storage
# Очищаем storage после всех тестов
storage.clear_all()
@pytest.fixture
def clean_storage(local_storage):
"""Фикстура для очистки storage перед каждым тестом"""
local_storage.clear_all()
yield local_storage
def make_api_request(url, method="GET", data=None, files=None, json_data=None):
"""Универсальная функция для API запросов"""
try:
if method.upper() == "GET":
response = requests.get(url, timeout=30)
elif method.upper() == "POST":
if files:
response = requests.post(url, files=files, timeout=30)
elif json_data:
response = requests.post(url, json=json_data, timeout=30)
else:
response = requests.post(url, data=data, timeout=30)
else:
raise ValueError(f"Неподдерживаемый метод: {method}")
return response
except requests.exceptions.RequestException as e:
pytest.fail(f"Ошибка API запроса: {e}")
@pytest.fixture
def api_request():
"""Фикстура для API запросов"""
return make_api_request

View File

@@ -1,2 +0,0 @@
pytest>=7.0.0
requests>=2.28.0

View File

@@ -1,20 +0,0 @@
"""
Основной файл для запуска всех тестов API эндпоинтов
"""
import pytest
import sys
from pathlib import Path
# Добавляем путь к проекту для импорта модулей
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
if __name__ == "__main__":
# Запуск всех тестов
pytest.main([
__file__.replace("test_all_endpoints.py", ""),
"-v", # подробный вывод
"--tb=short", # короткий traceback
"--color=yes", # цветной вывод
"-x", # остановка на первой ошибке
])

Binary file not shown.

Binary file not shown.

View File

@@ -1,339 +0,0 @@
"""
Тестирование геттеров с данными из локального storage
"""
import pytest
import sys
from pathlib import Path
# Добавляем путь к проекту
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
from core.services import ReportService, PARSERS
from core.models import DataRequest, UploadRequest
from adapters.local_storage import LocalStorageAdapter
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser
# Регистрируем парсеры
PARSERS.update({
'svodka_pm': SvodkaPMParser,
'svodka_ca': SvodkaCAParser,
'monitoring_fuel': MonitoringFuelParser,
})
class TestGettersWithLocalStorage:
"""Тестирование геттеров с локальным storage"""
@pytest.fixture(autouse=True)
def setup_storage(self, clean_storage):
"""Настройка локального storage для каждого теста"""
self.storage = clean_storage
self.report_service = ReportService(self.storage)
def test_svodka_pm_single_og_with_local_data(self, upload_file):
"""Тест svodka_pm single_og с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("pm_plan.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_pm',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='svodka_pm',
get_params={
'mode': 'single_og',
'id': 'SNPZ',
'codes': [78, 79],
'columns': ['ПП', 'СЭБ']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ svodka_pm/single_og работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ svodka_pm/single_og не работает: {result.message}")
# Не делаем assert, чтобы увидеть все ошибки
def test_svodka_pm_total_ogs_with_local_data(self, upload_file):
"""Тест svodka_pm total_ogs с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("pm_plan.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_pm',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='svodka_pm',
get_params={
'mode': 'total_ogs',
'codes': [78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
'columns': ['БП', 'ПП', 'СЭБ']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ svodka_pm/total_ogs работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ svodka_pm/total_ogs не работает: {result.message}")
def test_svodka_ca_get_ca_data_with_local_data(self, upload_file):
"""Тест svodka_ca get_ca_data с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("svodka_ca.xlsx")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_ca',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='svodka_ca',
get_params={
'mode': 'get_ca_data',
'modes': ['fact', 'plan'],
'tables': ['table1', 'table2']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ svodka_ca/get_ca_data работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ svodka_ca/get_ca_data не работает: {result.message}")
def test_monitoring_fuel_get_total_by_columns_with_local_data(self, upload_file):
"""Тест monitoring_fuel get_total_by_columns с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("monitoring.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='monitoring_fuel',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='monitoring_fuel',
get_params={
'mode': 'total_by_columns',
'columns': ['total', 'normativ']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ monitoring_fuel/get_total_by_columns работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ monitoring_fuel/get_total_by_columns не работает: {result.message}")
def test_monitoring_fuel_get_month_by_code_with_local_data(self, upload_file):
"""Тест monitoring_fuel get_month_by_code с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("monitoring.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='monitoring_fuel',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='monitoring_fuel',
get_params={
'mode': 'month_by_code',
'month': '02'
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ monitoring_fuel/get_month_by_code работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ monitoring_fuel/get_month_by_code не работает: {result.message}")
def test_monitoring_fuel_get_series_by_id_and_columns_with_local_data(self, upload_file):
"""Тест monitoring_fuel get_series_by_id_and_columns с данными из локального storage"""
# Сначала загружаем данные
file_path = upload_file("monitoring.zip")
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='monitoring_fuel',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
upload_result = self.report_service.upload_report(request)
assert upload_result.success is True, f"Загрузка не удалась: {upload_result.message}"
# Теперь тестируем геттер
data_request = DataRequest(
report_type='monitoring_fuel',
get_params={
'mode': 'series_by_id_and_columns',
'columns': ['total', 'normativ']
}
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"✅ monitoring_fuel/get_series_by_id_and_columns работает с локальными данными")
print(f" Получено данных: {len(result.data) if isinstance(result.data, list) else 'не список'}")
else:
print(f"❌ monitoring_fuel/get_series_by_id_and_columns не работает: {result.message}")
def test_all_getters_with_loaded_data(self, upload_file):
"""Тест всех геттеров с предварительно загруженными данными"""
# Загружаем все данные
files_to_upload = [
("svodka_ca.xlsx", "svodka_ca", "file"),
("pm_plan.zip", "svodka_pm", "zip"),
("monitoring.zip", "monitoring_fuel", "zip")
]
for filename, report_type, upload_type in files_to_upload:
file_path = upload_file(filename)
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
upload_request = UploadRequest(
report_type=report_type,
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
result = self.report_service.upload_report(upload_request)
assert result.success is True, f"Загрузка {filename} не удалась: {result.message}"
print(f"{filename} загружен")
# Тестируем все геттеры
test_cases = [
# svodka_pm
{
'report_type': 'svodka_pm',
'mode': 'single_og',
'params': {'id': 'SNPZ', 'codes': [78, 79], 'columns': ['ПП', 'СЭБ']},
'name': 'svodka_pm/single_og'
},
{
'report_type': 'svodka_pm',
'mode': 'total_ogs',
'params': {'codes': [78, 79, 394, 395, 396, 397, 81, 82, 83, 84], 'columns': ['БП', 'ПП', 'СЭБ']},
'name': 'svodka_pm/total_ogs'
},
# svodka_ca
{
'report_type': 'svodka_ca',
'mode': 'get_ca_data',
'params': {'modes': ['fact', 'plan'], 'tables': ['table1', 'table2']},
'name': 'svodka_ca/get_ca_data'
},
# monitoring_fuel
{
'report_type': 'monitoring_fuel',
'mode': 'total_by_columns',
'params': {'columns': ['total', 'normativ']},
'name': 'monitoring_fuel/get_total_by_columns'
},
{
'report_type': 'monitoring_fuel',
'mode': 'month_by_code',
'params': {'month': '02'},
'name': 'monitoring_fuel/get_month_by_code'
},
{
'report_type': 'monitoring_fuel',
'mode': 'series_by_id_and_columns',
'params': {'columns': ['total', 'normativ']},
'name': 'monitoring_fuel/get_series_by_id_and_columns'
}
]
print("\n🧪 Тестирование всех геттеров с локальными данными:")
for test_case in test_cases:
request_params = test_case['params'].copy()
request_params['mode'] = test_case['mode']
data_request = DataRequest(
report_type=test_case['report_type'],
get_params=request_params
)
result = self.report_service.get_data(data_request)
if result.success:
print(f"{test_case['name']}: работает")
else:
print(f"{test_case['name']}: {result.message}")
# Показываем содержимое storage
objects = self.storage.list_objects()
print(f"\n📊 Объекты в локальном storage: {len(objects)}")
for obj_id in objects:
metadata = self.storage.get_object_metadata(obj_id)
if metadata:
print(f" 📁 {obj_id}: {metadata['shape']} колонки: {metadata['columns'][:3]}...")

View File

@@ -1,102 +0,0 @@
"""
Тесты для monitoring_fuel эндпоинтов
"""
import pytest
import requests
class TestMonitoringFuelEndpoints:
"""Тесты эндпоинтов monitoring_fuel"""
def test_monitoring_fuel_get_total_by_columns(self, wait_for_api, api_base_url):
"""Тест получения данных по колонкам и расчёт средних значений"""
# Пример из схемы MonitoringFuelTotalRequest
data = {
"columns": ["total", "normativ"]
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_total_by_columns", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_total_by_columns работает: получены данные для колонок {data['columns']}")
def test_monitoring_fuel_get_month_by_code(self, wait_for_api, api_base_url):
"""Тест получения данных за месяц"""
# Пример из схемы MonitoringFuelMonthRequest
data = {
"month": "02"
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_month_by_code", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_month_by_code работает: получены данные за месяц {data['month']}")
def test_monitoring_fuel_get_series_by_id_and_columns(self, wait_for_api, api_base_url):
"""Тест получения временных рядов по ID и колонкам"""
# Пример из схемы MonitoringFuelSeriesRequest
data = {
"columns": ["total", "normativ"]
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_series_by_id_and_columns", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_series_by_id_and_columns работает: получены временные ряды для колонок {data['columns']}")
def test_monitoring_fuel_get_total_by_columns_single_column(self, wait_for_api, api_base_url):
"""Тест получения данных по одной колонке"""
data = {
"columns": ["total"]
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_total_by_columns", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_total_by_columns с одной колонкой работает: получены данные для колонки {data['columns'][0]}")
def test_monitoring_fuel_get_month_by_code_different_month(self, wait_for_api, api_base_url):
"""Тест получения данных за другой месяц"""
data = {
"month": "01"
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_month_by_code", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_month_by_code с другим месяцем работает: получены данные за месяц {data['month']}")
def test_monitoring_fuel_get_series_by_id_and_columns_single_column(self, wait_for_api, api_base_url):
"""Тест получения временных рядов по одной колонке"""
data = {
"columns": ["total"]
}
response = requests.post(f"{api_base_url}/monitoring_fuel/get_series_by_id_and_columns", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ monitoring_fuel/get_series_by_id_and_columns с одной колонкой работает: получены временные ряды для колонки {data['columns'][0]}")

View File

@@ -1,134 +0,0 @@
"""
Прямое тестирование парсеров с локальным storage
Этот модуль тестирует парсеры напрямую, без API
"""
import pytest
import sys
from pathlib import Path
# Добавляем путь к проекту
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser
from core.services import ReportService
from adapters.local_storage import LocalStorageAdapter
class TestParsersDirect:
"""Прямое тестирование парсеров с локальным storage"""
@pytest.fixture(autouse=True)
def setup_storage(self, clean_storage):
"""Настройка локального storage для каждого теста"""
self.storage = clean_storage
self.report_service = ReportService(self.storage)
def test_svodka_pm_parser_registration(self):
"""Тест регистрации парсера svodka_pm"""
parser = SvodkaPMParser()
getters = parser.get_available_getters()
assert "single_og" in getters
assert "total_ogs" in getters
# Проверяем параметры геттеров
single_og_getter = getters["single_og"]
assert "id" in single_og_getter["required_params"]
assert "codes" in single_og_getter["required_params"]
assert "columns" in single_og_getter["required_params"]
assert "search" in single_og_getter["optional_params"]
total_ogs_getter = getters["total_ogs"]
assert "codes" in total_ogs_getter["required_params"]
assert "columns" in total_ogs_getter["required_params"]
assert "search" in total_ogs_getter["optional_params"]
print("✅ svodka_pm парсер зарегистрирован корректно")
def test_svodka_ca_parser_registration(self):
"""Тест регистрации парсера svodka_ca"""
parser = SvodkaCAParser()
getters = parser.get_available_getters()
assert "get_ca_data" in getters
# Проверяем параметры геттера
getter = getters["get_ca_data"]
assert "modes" in getter["required_params"]
assert "tables" in getter["required_params"]
print("✅ svodka_ca парсер зарегистрирован корректно")
def test_monitoring_fuel_parser_registration(self):
"""Тест регистрации парсера monitoring_fuel"""
parser = MonitoringFuelParser()
getters = parser.get_available_getters()
assert "total_by_columns" in getters
assert "month_by_code" in getters
assert "series_by_id_and_columns" in getters
# Проверяем параметры геттеров
total_getter = getters["total_by_columns"]
assert "columns" in total_getter["required_params"]
month_getter = getters["month_by_code"]
assert "month" in month_getter["required_params"]
series_getter = getters["series_by_id_and_columns"]
assert "columns" in series_getter["required_params"]
print("✅ monitoring_fuel парсер зарегистрирован корректно")
def test_storage_operations(self):
"""Тест операций с локальным storage"""
import pandas as pd
# Создаем тестовый DataFrame
test_df = pd.DataFrame({
'col1': [1, 2, 3],
'col2': ['a', 'b', 'c']
})
# Сохраняем
success = self.storage.save_dataframe("test_object", test_df)
assert success is True
# Проверяем существование
exists = self.storage.object_exists("test_object")
assert exists is True
# Загружаем
loaded_df = self.storage.load_dataframe("test_object")
assert loaded_df is not None
assert loaded_df.shape == (3, 2)
assert list(loaded_df.columns) == ['col1', 'col2']
# Получаем метаданные
metadata = self.storage.get_object_metadata("test_object")
assert metadata is not None
assert metadata["shape"] == [3, 2]
# Получаем список объектов
objects = self.storage.list_objects()
assert "test_object" in objects
# Удаляем
delete_success = self.storage.delete_object("test_object")
assert delete_success is True
# Проверяем, что объект удален
exists_after = self.storage.object_exists("test_object")
assert exists_after is False
print("✅ Локальный storage работает корректно")
def test_report_service_with_local_storage(self):
"""Тест ReportService с локальным storage"""
# Проверяем, что ReportService может работать с локальным storage
assert self.report_service.storage is not None
assert hasattr(self.report_service.storage, 'save_dataframe')
assert hasattr(self.report_service.storage, 'load_dataframe')
print("✅ ReportService корректно работает с локальным storage")

View File

@@ -1,58 +0,0 @@
"""
Тесты для svodka_ca эндпоинтов
"""
import pytest
import requests
class TestSvodkaCAEndpoints:
"""Тесты эндпоинтов svodka_ca"""
def test_svodka_ca_get_ca_data(self, wait_for_api, api_base_url):
"""Тест получения данных из сводок СА"""
# Пример из схемы SvodkaCARequest
data = {
"modes": ["fact", "plan"],
"tables": ["table1", "table2"]
}
response = requests.post(f"{api_base_url}/svodka_ca/get_ca_data", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_ca/get_ca_data работает: получены данные для режимов {data['modes']}")
def test_svodka_ca_get_ca_data_single_mode(self, wait_for_api, api_base_url):
"""Тест получения данных из сводок СА для одного режима"""
data = {
"modes": ["fact"],
"tables": ["table1"]
}
response = requests.post(f"{api_base_url}/svodka_ca/get_ca_data", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_ca/get_ca_data с одним режимом работает: получены данные для режима {data['modes'][0]}")
def test_svodka_ca_get_ca_data_multiple_tables(self, wait_for_api, api_base_url):
"""Тест получения данных из сводок СА для нескольких таблиц"""
data = {
"modes": ["fact", "plan"],
"tables": ["table1", "table2", "table3"]
}
response = requests.post(f"{api_base_url}/svodka_ca/get_ca_data", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_ca/get_ca_data с несколькими таблицами работает: получены данные для {len(data['tables'])} таблиц")

View File

@@ -1,79 +0,0 @@
"""
Тесты для svodka_pm эндпоинтов
"""
import pytest
import requests
class TestSvodkaPMEndpoints:
"""Тесты эндпоинтов svodka_pm"""
def test_svodka_pm_single_og(self, wait_for_api, api_base_url):
"""Тест получения данных по одному ОГ"""
# Пример из схемы SvodkaPMSingleOGRequest
data = {
"id": "SNPZ",
"codes": [78, 79],
"columns": ["ПП", "СЭБ"]
}
response = requests.post(f"{api_base_url}/svodka_pm/single_og", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_pm/single_og работает: получены данные для {data['id']}")
def test_svodka_pm_total_ogs(self, wait_for_api, api_base_url):
"""Тест получения данных по всем ОГ"""
# Пример из схемы SvodkaPMTotalOGsRequest
data = {
"codes": [78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
"columns": ["БП", "ПП", "СЭБ"]
}
response = requests.post(f"{api_base_url}/svodka_pm/get_total_ogs", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_pm/get_total_ogs работает: получены данные по всем ОГ")
def test_svodka_pm_single_og_with_search(self, wait_for_api, api_base_url):
"""Тест получения данных по одному ОГ с параметром search"""
data = {
"id": "SNPZ",
"codes": [78, 79],
"columns": ["ПП", "СЭБ"],
"search": "Итого"
}
response = requests.post(f"{api_base_url}/svodka_pm/single_og", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_pm/single_og с search работает: получены данные для {data['id']} с фильтром")
def test_svodka_pm_total_ogs_with_search(self, wait_for_api, api_base_url):
"""Тест получения данных по всем ОГ с параметром search"""
data = {
"codes": [78, 79, 394, 395, 396, 397, 81, 82, 83, 84],
"columns": ["БП", "ПП", "СЭБ"],
"search": "Итого"
}
response = requests.post(f"{api_base_url}/svodka_pm/get_total_ogs", json=data)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Запрос не удался: {result}"
assert "data" in result, "Отсутствует поле 'data' в ответе"
print(f"✅ svodka_pm/get_total_ogs с search работает: получены данные по всем ОГ с фильтром")

View File

@@ -1,52 +0,0 @@
"""
Тесты для эндпоинтов загрузки данных
"""
import pytest
import requests
from pathlib import Path
class TestUploadEndpoints:
"""Тесты эндпоинтов загрузки"""
def test_upload_svodka_ca(self, wait_for_api, upload_file, api_base_url):
"""Тест загрузки файла svodka_ca.xlsx"""
file_path = upload_file("svodka_ca.xlsx")
with open(file_path, 'rb') as f:
files = {'file': ('svodka_ca.xlsx', f, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
response = requests.post(f"{api_base_url}/svodka_ca/upload", files=files)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Загрузка не удалась: {result}"
print(f"✅ svodka_ca.xlsx загружен успешно: {result['message']}")
def test_upload_svodka_pm_plan(self, wait_for_api, upload_file, api_base_url):
"""Тест загрузки архива pm_plan.zip"""
file_path = upload_file("pm_plan.zip")
with open(file_path, 'rb') as f:
files = {'zip_file': ('pm_plan.zip', f, 'application/zip')}
response = requests.post(f"{api_base_url}/svodka_pm/upload-zip", files=files)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Загрузка не удалась: {result}"
print(f"✅ pm_plan.zip загружен успешно: {result['message']}")
def test_upload_monitoring_fuel(self, wait_for_api, upload_file, api_base_url):
"""Тест загрузки архива monitoring.zip"""
file_path = upload_file("monitoring.zip")
with open(file_path, 'rb') as f:
files = {'zip_file': ('monitoring.zip', f, 'application/zip')}
response = requests.post(f"{api_base_url}/monitoring_fuel/upload-zip", files=files)
assert response.status_code == 200, f"Ожидался статус 200, получен {response.status_code}: {response.text}"
result = response.json()
assert result["success"] is True, f"Загрузка не удалась: {result}"
print(f"✅ monitoring.zip загружен успешно: {result['message']}")

View File

@@ -1,183 +0,0 @@
"""
Тестирование загрузки файлов с сохранением в локальный storage
"""
import pytest
import sys
from pathlib import Path
# Добавляем путь к проекту
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root / "python_parser"))
from core.services import ReportService, PARSERS
from core.models import UploadRequest
from adapters.local_storage import LocalStorageAdapter
from adapters.parsers import SvodkaPMParser, SvodkaCAParser, MonitoringFuelParser
# Регистрируем парсеры
PARSERS.update({
'svodka_pm': SvodkaPMParser,
'svodka_ca': SvodkaCAParser,
'monitoring_fuel': MonitoringFuelParser,
})
class TestUploadWithLocalStorage:
"""Тестирование загрузки файлов с локальным storage"""
@pytest.fixture(autouse=True)
def setup_storage(self, clean_storage):
"""Настройка локального storage для каждого теста"""
self.storage = clean_storage
self.report_service = ReportService(self.storage)
def test_upload_svodka_ca_to_local_storage(self, upload_file):
"""Тест загрузки svodka_ca.xlsx в локальный storage"""
file_path = upload_file("svodka_ca.xlsx")
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_ca',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
# Загружаем файл через ReportService
result = self.report_service.upload_report(request)
assert result.success is True, f"Загрузка не удалась: {result.message}"
# Проверяем, что данные сохранились в локальном storage
objects = self.storage.list_objects()
assert len(objects) > 0, "Данные не сохранились в storage"
# Проверяем метаданные
for obj_id in objects:
metadata = self.storage.get_object_metadata(obj_id)
assert metadata is not None, f"Метаданные для {obj_id} не найдены"
assert "shape" in metadata, f"Отсутствует shape в метаданных {obj_id}"
assert "columns" in metadata, f"Отсутствуют columns в метаданных {obj_id}"
print(f"✅ svodka_ca.xlsx загружен в локальный storage: {len(objects)} объектов")
print(f" Объекты: {objects}")
def test_upload_pm_plan_to_local_storage(self, upload_file):
"""Тест загрузки pm_plan.zip в локальный storage"""
file_path = upload_file("pm_plan.zip")
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='svodka_pm',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
# Загружаем архив через ReportService
result = self.report_service.upload_report(request)
assert result.success is True, f"Загрузка не удалась: {result.message}"
# Проверяем, что данные сохранились в локальном storage
objects = self.storage.list_objects()
assert len(objects) > 0, "Данные не сохранились в storage"
# Проверяем метаданные
for obj_id in objects:
metadata = self.storage.get_object_metadata(obj_id)
assert metadata is not None, f"Метаданные для {obj_id} не найдены"
assert "shape" in metadata, f"Отсутствует shape в метаданных {obj_id}"
assert "columns" in metadata, f"Отсутствуют columns в метаданных {obj_id}"
print(f"✅ pm_plan.zip загружен в локальный storage: {len(objects)} объектов")
print(f" Объекты: {objects}")
def test_upload_monitoring_to_local_storage(self, upload_file):
"""Тест загрузки monitoring.zip в локальный storage"""
file_path = upload_file("monitoring.zip")
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type='monitoring_fuel',
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
# Загружаем архив через ReportService
result = self.report_service.upload_report(request)
assert result.success is True, f"Загрузка не удалась: {result.message}"
# Проверяем, что данные сохранились в локальном storage
objects = self.storage.list_objects()
assert len(objects) > 0, "Данные не сохранились в storage"
# Проверяем метаданные
for obj_id in objects:
metadata = self.storage.get_object_metadata(obj_id)
assert metadata is not None, f"Метаданные для {obj_id} не найдены"
assert "shape" in metadata, f"Отсутствует shape в метаданных {obj_id}"
assert "columns" in metadata, f"Отсутствуют columns в метаданных {obj_id}"
print(f"✅ monitoring.zip загружен в локальный storage: {len(objects)} объектов")
print(f" Объекты: {objects}")
def test_upload_all_files_sequence(self, upload_file):
"""Тест последовательной загрузки всех файлов"""
# Загружаем все файлы по очереди
files_to_upload = [
("svodka_ca.xlsx", "svodka_ca", "file"),
("pm_plan.zip", "svodka_pm", "zip"),
("monitoring.zip", "monitoring_fuel", "zip")
]
total_objects = 0
for filename, report_type, upload_type in files_to_upload:
file_path = upload_file(filename)
# Читаем файл и создаем UploadRequest
with open(file_path, 'rb') as f:
file_content = f.read()
request = UploadRequest(
report_type=report_type,
file_name=file_path.name,
file_content=file_content,
parse_params={}
)
result = self.report_service.upload_report(request)
assert result.success is True, f"Загрузка {filename} не удалась: {result.message}"
# Подсчитываем объекты
objects = self.storage.list_objects()
current_count = len(objects)
print(f"{filename} загружен: {current_count - total_objects} новых объектов")
total_objects = current_count
# Проверяем итоговое количество объектов
final_objects = self.storage.list_objects()
assert len(final_objects) > 0, "Ни один файл не был загружен"
print(f"Все файлы загружены. Итого объектов в storage: {len(final_objects)}")
print(f" Все объекты: {final_objects}")
# Выводим детальную информацию о каждом объекте
for obj_id in final_objects:
metadata = self.storage.get_object_metadata(obj_id)
if metadata:
print(f" 📊 {obj_id}: {metadata['shape']} колонки: {metadata['columns'][:5]}...")