From 73ce06deec78a9d5252245b7e8d4b34212366e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D1=88=D0=BA=D0=B8=D0=BD=20=D0=A1=D0=B5=D1=80?= =?UTF-8?q?=D0=B3=D0=B5=D0=B9?= Date: Wed, 12 Nov 2025 12:46:08 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=BA=D0=BE=D0=BD=D1=87=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7.=20=D0=A2=D0=B5=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D1=8C=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B5=D0=BB=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.prod | 3 - ASYNC_CHANGES_SUMMARY.md | 396 --- ASYNC_LYNGSAT_GUIDE.md | 420 --- CHANGES_SUMMARY.md | 133 - INSTALLATION_GUIDE.md | 220 +- LYNGSAT_FILL_GUIDE.md | 78 - dbapp/dbapp/settings/__init__.py | 3 - dbapp/dbapp/settings/development.py | 103 +- .../templates/mainapp/objitem_list.html | 36 + dbapp/mainapp/views.py | 2770 +++++++++-------- dbapp/pyproject.toml | 1 + dbapp/uv.lock | 15 + docker-compose.yaml | 44 +- nginx/conf.d/default.conf | 17 + nginx/nginx.conf | 39 + 15 files changed, 1585 insertions(+), 2693 deletions(-) delete mode 100644 ASYNC_CHANGES_SUMMARY.md delete mode 100644 ASYNC_LYNGSAT_GUIDE.md delete mode 100644 CHANGES_SUMMARY.md delete mode 100644 LYNGSAT_FILL_GUIDE.md create mode 100644 nginx/conf.d/default.conf create mode 100644 nginx/nginx.conf diff --git a/.env.prod b/.env.prod index f79bf3c..d12fca5 100644 --- a/.env.prod +++ b/.env.prod @@ -1,6 +1,3 @@ -# Production Environment Variables -# ВАЖНО: Измените все значения перед деплоем! - # Django Settings DEBUG=False ENVIRONMENT=production diff --git a/ASYNC_CHANGES_SUMMARY.md b/ASYNC_CHANGES_SUMMARY.md deleted file mode 100644 index 5c2ee37..0000000 --- a/ASYNC_CHANGES_SUMMARY.md +++ /dev/null @@ -1,396 +0,0 @@ -# Сводка изменений: Асинхронная обработка данных Lyngsat - -## Обзор - -Реализована полная асинхронная обработка данных Lyngsat с использованием Celery, Redis и детальным логированием. - -## Ключевые улучшения - -### 1. ✅ Асинхронная обработка -- Задачи выполняются в фоновом режиме -- Веб-интерфейс не блокируется -- Можно обрабатывать несколько задач одновременно - -### 2. ✅ Отслеживание прогресса -- Прогресс-бар в реальном времени -- Текущий статус обработки -- Процент выполнения - -### 3. ✅ Детальное логирование -- Логи на уровне задачи -- Логи на уровне спутника -- Логи на уровне источника -- Все ошибки записываются в лог - -### 4. ✅ Результаты и статистика -- Количество обработанных спутников -- Количество обработанных источников -- Количество созданных/обновленных записей -- Список всех ошибок - -## Новые файлы - -### Backend -1. **dbapp/dbapp/celery.py** - конфигурация Celery -2. **dbapp/dbapp/__init__.py** - инициализация Celery app -3. **dbapp/lyngsatapp/tasks.py** - асинхронная задача заполнения данных -4. **dbapp/start_celery_worker.sh** - скрипт запуска worker - -### Frontend -5. **dbapp/mainapp/templates/mainapp/lyngsat_task_status.html** - страница отслеживания прогресса - -### Документация -6. **ASYNC_LYNGSAT_GUIDE.md** - полное руководство -7. **QUICKSTART_ASYNC.md** - быстрый старт -8. **ASYNC_CHANGES_SUMMARY.md** - этот файл - -## Измененные файлы - -### Конфигурация -1. **dbapp/requirements.txt** - - Добавлено: `celery>=5.4.0` - - Добавлено: `django-celery-results>=2.5.1` - -2. **dbapp/dbapp/settings/base.py** - - Добавлено: `django_celery_results` в INSTALLED_APPS - - Добавлено: полная конфигурация Celery (брокер, результаты, таймауты, логирование) - -3. **docker-compose.yaml** - - Добавлено: сервис Redis - - Добавлено: сервис FlareSolver - - Добавлено: volume для Redis - -### Backend логика -4. **dbapp/lyngsatapp/utils.py** - - Добавлено: параметр `task_id` для логирования - - Добавлено: параметр `update_progress` для обновления прогресса - - Добавлено: детальное логирование на всех уровнях - - Добавлено: логирование каждые 10 источников - - Улучшено: обработка ошибок с логированием - -5. **dbapp/mainapp/views.py** - - Изменено: `FillLyngsatDataView` теперь запускает асинхронную задачу - - Добавлено: `LyngsatTaskStatusView` - страница отслеживания - - Добавлено: `LyngsatTaskStatusAPIView` - API для проверки статуса - -6. **dbapp/mainapp/urls.py** - - Добавлено: `/lyngsat-task-status/` - страница статуса - - Добавлено: `/lyngsat-task-status//` - статус конкретной задачи - - Добавлено: `/api/lyngsat-task-status//` - API endpoint - -## Технические детали - -### Архитектура - -``` -User Request → Django View → Celery Task → Redis Broker - ↓ - Celery Worker - ↓ - ┌───────────┴───────────┐ - ↓ ↓ - LyngSat Parser PostgreSQL - ↓ ↓ - FlareSolver Save Results -``` - -### Поток данных - -1. **Пользователь отправляет форму** - - Django view получает данные - - Создается асинхронная задача Celery - - Возвращается task_id - - Перенаправление на страницу статуса - -2. **Celery Worker обрабатывает задачу** - - Логирует начало обработки - - Вызывает `fill_lyngsat_data` с callback - - Обновляет прогресс через `update_state` - - Логирует каждый шаг - - Сохраняет результат в кеш - -3. **Страница статуса отслеживает прогресс** - - JavaScript опрашивает API каждые 2 секунды - - Обновляет прогресс-бар - - Показывает текущий статус - - Отображает результаты при завершении - -### Логирование - -#### Уровни логирования -- **INFO**: Основные события (начало, завершение, прогресс) -- **DEBUG**: Детальная информация (каждая запись) -- **WARNING**: Некритичные ошибки (спутник не найден) -- **ERROR**: Критичные ошибки (с traceback) - -#### Формат логов -``` -[Timestamp: Level/Process][Task Name(Task ID)] [Task ID] Message -``` - -Пример: -``` -[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Начало обработки данных Lyngsat -[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Спутники: Astra 4A, Hotbird 13G -[2024-01-15 10:30:46: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Получено данных по 2 спутникам -[2024-01-15 10:31:00: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Обработка спутника 1/2: Astra 4A -[2024-01-15 10:31:00: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Найдено 150 источников для Astra 4A -[2024-01-15 10:31:05: DEBUG/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Создана запись для Astra 4A 11766.0 МГц -[2024-01-15 10:31:10: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Обработано 10/150 источников для Astra 4A -``` - -### API Endpoints - -#### GET /api/lyngsat-task-status// - -**Ответ при выполнении:** -```json -{ - "task_id": "abc123", - "state": "PROGRESS", - "status": "Обработка Astra 4A...", - "current": 1, - "total": 2, - "percent": 50 -} -``` - -**Ответ при успехе:** -```json -{ - "task_id": "abc123", - "state": "SUCCESS", - "status": "Задача завершена успешно", - "result": { - "total_satellites": 2, - "total_sources": 300, - "created": 250, - "updated": 50, - "errors": [] - } -} -``` - -**Ответ при ошибке:** -```json -{ - "task_id": "abc123", - "state": "FAILURE", - "status": "Ошибка при выполнении задачи", - "error": "Connection timeout" -} -``` - -## Настройки Celery - -### Основные параметры -```python -CELERY_BROKER_URL = 'redis://localhost:6379/0' -CELERY_RESULT_BACKEND = 'django-db' -CELERY_TASK_TRACK_STARTED = True -CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут -``` - -### Переменные окружения -Можно переопределить через `.env`: -```bash -CELERY_BROKER_URL=redis://redis:6379/0 -``` - -## Зависимости - -### Обязательные сервисы -1. **Redis** - брокер сообщений Celery -2. **FlareSolver** - обход Cloudflare -3. **PostgreSQL** - хранение данных и результатов - -### Python пакеты -- `celery>=5.4.0` - асинхронная обработка -- `django-celery-results>=2.5.1` - хранение результатов -- `redis>=6.4.0` - клиент Redis - -## Команды для работы - -### Запуск сервисов -```bash -# Redis и FlareSolver -docker-compose up -d redis flaresolverr - -# Celery Worker -celery -A dbapp worker --loglevel=info - -# Celery Worker в фоне -celery -A dbapp worker --loglevel=info --logfile=logs/celery_worker.log --detach -``` - -### Мониторинг -```bash -# Просмотр логов -tail -f dbapp/logs/celery_worker.log - -# Flower (веб-интерфейс) -pip install flower -celery -A dbapp flower -# Откройте http://localhost:5555 -``` - -### Отладка -```bash -# Проверка Redis -redis-cli ping - -# Проверка FlareSolver -curl http://localhost:8191/v1 - -# Django shell -python manage.py shell ->>> from celery.result import AsyncResult ->>> task = AsyncResult('task_id') ->>> print(task.state, task.info) -``` - -## Производственное развертывание - -### Systemd сервис -```bash -sudo systemctl enable celery-worker -sudo systemctl start celery-worker -sudo systemctl status celery-worker -``` - -### Supervisor -```bash -sudo supervisorctl start celery-worker -sudo supervisorctl status celery-worker -``` - -### Docker -Можно добавить Celery worker в docker-compose.yaml: -```yaml -celery-worker: - build: ./dbapp - command: celery -A dbapp worker --loglevel=info - depends_on: - - redis - - db - environment: - - CELERY_BROKER_URL=redis://redis:6379/0 -``` - -## Тестирование - -### Проверка системы -```bash -# 1. Проверка Django -python manage.py check - -# 2. Проверка миграций -python manage.py migrate --check - -# 3. Проверка Celery -celery -A dbapp inspect ping - -# 4. Проверка Redis -redis-cli ping - -# 5. Проверка FlareSolver -curl http://localhost:8191/v1 -``` - -### Тестовый запуск -```python -# Django shell -python manage.py shell - -from lyngsatapp.tasks import fill_lyngsat_data_task - -# Запуск задачи -task = fill_lyngsat_data_task.delay(['Astra 4A'], ['europe']) -print(f"Task ID: {task.id}") - -# Проверка статуса -print(task.state) -print(task.info) -``` - -## Метрики и мониторинг - -### Что отслеживать -- Количество активных workers -- Количество задач в очереди -- Среднее время выполнения задачи -- Количество ошибок -- Использование памяти Redis - -### Инструменты -- **Flower** - веб-интерфейс для Celery -- **Redis Commander** - GUI для Redis -- **Prometheus + Grafana** - метрики и дашборды - -## Безопасность - -### Рекомендации -1. Используйте пароль для Redis в production -2. Ограничьте доступ к Redis только для localhost -3. Используйте SSL для Redis в production -4. Ограничьте время выполнения задач -5. Логируйте все действия - -### Пример конфигурации Redis с паролем -```python -CELERY_BROKER_URL = 'redis://:password@localhost:6379/0' -``` - -## Масштабирование - -### Горизонтальное масштабирование -Запустите несколько workers: -```bash -# Worker 1 -celery -A dbapp worker --loglevel=info -n worker1@%h - -# Worker 2 -celery -A dbapp worker --loglevel=info -n worker2@%h - -# Worker 3 -celery -A dbapp worker --loglevel=info -n worker3@%h -``` - -### Приоритеты задач -Можно настроить разные очереди для разных типов задач: -```python -@shared_task(queue='high_priority') -def urgent_task(): - pass - -@shared_task(queue='low_priority') -def background_task(): - pass -``` - -## Следующие шаги - -1. ✅ Применить миграции -2. ✅ Запустить Redis и FlareSolver -3. ✅ Запустить Celery Worker -4. ✅ Протестировать через веб-интерфейс -5. ⏳ Настроить production окружение -6. ⏳ Добавить периодические задачи (Celery Beat) -7. ⏳ Настроить email уведомления -8. ⏳ Настроить мониторинг (Flower) - -## Заключение - -Система асинхронной обработки данных Lyngsat обеспечивает: -- ✅ Неблокирующий веб-интерфейс -- ✅ Отслеживание прогресса в реальном времени -- ✅ Детальное логирование всех операций -- ✅ Масштабируемость (несколько workers) -- ✅ Надежность (retry при ошибках) -- ✅ Мониторинг и отладка -- ✅ Production-ready решение - -Для получения дополнительной помощи: -- Полное руководство: `ASYNC_LYNGSAT_GUIDE.md` -- Быстрый старт: `QUICKSTART_ASYNC.md` -- Документация Celery: https://docs.celeryproject.org/ diff --git a/ASYNC_LYNGSAT_GUIDE.md b/ASYNC_LYNGSAT_GUIDE.md deleted file mode 100644 index 5a57cf0..0000000 --- a/ASYNC_LYNGSAT_GUIDE.md +++ /dev/null @@ -1,420 +0,0 @@ -# Руководство по асинхронному заполнению данных Lyngsat - -## Обзор - -Система заполнения данных Lyngsat теперь работает асинхронно с использованием Celery. Это позволяет: -- Не блокировать веб-интерфейс во время долгих операций -- Отслеживать прогресс выполнения задачи в реальном времени -- Просматривать детальные логи обработки -- Получать уведомления о завершении задачи - -## Архитектура - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Django │─────▶│ Celery │─────▶│ Redis │ -│ Web App │ │ Worker │ │ Broker │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ │ - │ ▼ - │ ┌─────────────┐ - └─────────────▶│ PostgreSQL │ - │ Database │ - └─────────────┘ -``` - -## Установка и настройка - -### 1. Установка зависимостей - -```bash -pip install -r requirements.txt -``` - -Новые зависимости: -- `celery>=5.4.0` - асинхронная обработка задач -- `django-celery-results>=2.5.1` - хранение результатов в БД - -### 2. Применение миграций - -```bash -cd dbapp -python manage.py migrate -``` - -Это создаст таблицы для хранения результатов Celery. - -### 3. Запуск Redis - -Redis используется как брокер сообщений для Celery. - -#### Вариант 1: Docker Compose (рекомендуется) -```bash -docker-compose up -d redis -``` - -#### Вариант 2: Локальная установка -```bash -# Ubuntu/Debian -sudo apt-get install redis-server -sudo systemctl start redis - -# macOS -brew install redis -brew services start redis - -# Проверка -redis-cli ping -# Должно вернуть: PONG -``` - -### 4. Запуск FlareSolver - -FlareSolver необходим для обхода защиты Cloudflare. - -```bash -docker-compose up -d flaresolverr -``` - -Или отдельно: -```bash -docker run -d -p 8191:8191 --name flaresolverr ghcr.io/flaresolverr/flaresolverr:latest -``` - -### 5. Запуск Celery Worker - -#### Вариант 1: Используя скрипт -```bash -cd dbapp -./start_celery_worker.sh -``` - -#### Вариант 2: Напрямую -```bash -cd dbapp -celery -A dbapp worker --loglevel=info -``` - -#### Вариант 3: В фоновом режиме (Linux/macOS) -```bash -cd dbapp -celery -A dbapp worker --loglevel=info --logfile=logs/celery_worker.log --detach -``` - -## Использование - -### 1. Запуск задачи через веб-интерфейс - -1. Откройте страницу действий: `http://localhost:8000/actions/` -2. Нажмите "Заполнить данные Lyngsat" -3. Выберите спутники и регионы -4. Нажмите "Заполнить данные" -5. Вы будете перенаправлены на страницу отслеживания прогресса - -### 2. Отслеживание прогресса - -На странице статуса задачи вы увидите: -- **Прогресс-бар** с процентом выполнения -- **Текущий статус** (например, "Обработка Astra 4A...") -- **Состояние задачи** (PENDING, PROGRESS, SUCCESS, FAILURE) -- **Результаты** после завершения: - - Количество обработанных спутников - - Количество обработанных источников - - Количество созданных записей - - Количество обновленных записей - - Список ошибок (если есть) - -Страница автоматически обновляется каждые 2 секунды. - -### 3. Просмотр логов - -Логи Celery worker содержат детальную информацию о процессе: - -```bash -# Просмотр логов в реальном времени -tail -f dbapp/logs/celery_worker.log - -# Поиск по логам -grep "Task" dbapp/logs/celery_worker.log -grep "ERROR" dbapp/logs/celery_worker.log -``` - -Формат логов: -``` -[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Начало обработки данных Lyngsat -[2024-01-15 10:30:45: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Спутники: Astra 4A, Hotbird 13G -[2024-01-15 10:30:46: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Получено данных по 2 спутникам -[2024-01-15 10:31:00: INFO/MainProcess][lyngsatapp.fill_lyngsat_data_async(abc123)] [Task abc123] Обработка спутника 1/2: Astra 4A -``` - -## Технические детали - -### Структура задачи - -**Файл**: `dbapp/lyngsatapp/tasks.py` - -```python -@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_async') -def fill_lyngsat_data_task(self, target_sats, regions=None): - # Логирование начала - # Обновление прогресса - # Вызов функции заполнения - # Сохранение результата в кеш - # Обработка ошибок -``` - -### Обновление прогресса - -Функция `fill_lyngsat_data` теперь принимает callback `update_progress`: - -```python -def update_progress(current, total, status): - self.update_state( - state='PROGRESS', - meta={ - 'current': current, - 'total': total, - 'status': status - } - ) -``` - -### API для проверки статуса - -**Endpoint**: `/api/lyngsat-task-status//` - -**Ответ**: -```json -{ - "task_id": "abc123", - "state": "PROGRESS", - "status": "Обработка Astra 4A...", - "current": 1, - "total": 2, - "percent": 50 -} -``` - -### Логирование - -Используется стандартный модуль `logging` Python: - -```python -import logging -logger = logging.getLogger(__name__) - -logger.info(f"[Task {task_id}] Начало обработки") -logger.debug(f"[Task {task_id}] Детальная информация") -logger.warning(f"[Task {task_id}] Предупреждение") -logger.error(f"[Task {task_id}] Ошибка", exc_info=True) -``` - -## Настройки Celery - -**Файл**: `dbapp/dbapp/settings/base.py` - -```python -# Брокер сообщений -CELERY_BROKER_URL = 'redis://localhost:6379/0' - -# Хранение результатов -CELERY_RESULT_BACKEND = 'django-db' - -# Таймауты -CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут -CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 25 минут - -# Отслеживание прогресса -CELERY_TASK_TRACK_STARTED = True -``` - -## Мониторинг и отладка - -### Flower - веб-интерфейс для мониторинга Celery - -Установка: -```bash -pip install flower -``` - -Запуск: -```bash -celery -A dbapp flower -``` - -Откройте: `http://localhost:5555` - -### Проверка статуса задачи через Django shell - -```python -python manage.py shell - -from celery.result import AsyncResult - -task_id = 'abc123' -task = AsyncResult(task_id) - -print(f"State: {task.state}") -print(f"Info: {task.info}") -print(f"Result: {task.result}") -``` - -### Очистка старых результатов - -```bash -# Удалить результаты старше 1 дня -python manage.py celery_results_cleanup --days=1 -``` - -## Решение проблем - -### Проблема: Worker не запускается - -**Решение**: -1. Проверьте, что Redis запущен: `redis-cli ping` -2. Проверьте настройки в `.env`: `CELERY_BROKER_URL` -3. Проверьте логи: `tail -f logs/celery_worker.log` - -### Проблема: Задача зависла в состоянии PENDING - -**Решение**: -1. Проверьте, что worker запущен: `ps aux | grep celery` -2. Перезапустите worker -3. Проверьте соединение с Redis - -### Проблема: Задача завершается с ошибкой - -**Решение**: -1. Проверьте логи worker -2. Проверьте, что FlareSolver запущен: `curl http://localhost:8191/v1` -3. Проверьте, что спутники существуют в базе данных - -### Проблема: Прогресс не обновляется - -**Решение**: -1. Откройте консоль браузера (F12) и проверьте ошибки -2. Проверьте, что API endpoint доступен: `/api/lyngsat-task-status//` -3. Очистите кеш браузера - -## Производственное развертывание - -### Systemd сервис для Celery Worker - -Создайте файл `/etc/systemd/system/celery-worker.service`: - -```ini -[Unit] -Description=Celery Worker for Django Lyngsat -After=network.target redis.service - -[Service] -Type=forking -User=www-data -Group=www-data -WorkingDirectory=/path/to/dbapp -Environment="PATH=/path/to/venv/bin" -ExecStart=/path/to/venv/bin/celery -A dbapp worker --loglevel=info --logfile=/var/log/celery/worker.log --detach -ExecStop=/path/to/venv/bin/celery -A dbapp control shutdown -Restart=always - -[Install] -WantedBy=multi-user.target -``` - -Запуск: -```bash -sudo systemctl daemon-reload -sudo systemctl enable celery-worker -sudo systemctl start celery-worker -sudo systemctl status celery-worker -``` - -### Supervisor (альтернатива) - -Установка: -```bash -sudo apt-get install supervisor -``` - -Конфигурация `/etc/supervisor/conf.d/celery.conf`: -```ini -[program:celery-worker] -command=/path/to/venv/bin/celery -A dbapp worker --loglevel=info -directory=/path/to/dbapp -user=www-data -autostart=true -autorestart=true -stdout_logfile=/var/log/celery/worker.log -stderr_logfile=/var/log/celery/worker_error.log -``` - -Запуск: -```bash -sudo supervisorctl reread -sudo supervisorctl update -sudo supervisorctl start celery-worker -``` - -## Дополнительные возможности - -### Периодические задачи (Celery Beat) - -Для автоматического обновления данных по расписанию: - -1. Установите `django-celery-beat`: -```bash -pip install django-celery-beat -``` - -2. Добавьте в `INSTALLED_APPS`: -```python -INSTALLED_APPS = [ - ... - 'django_celery_beat', -] -``` - -3. Примените миграции: -```bash -python manage.py migrate django_celery_beat -``` - -4. Создайте периодическую задачу через админ-панель Django - -5. Запустите beat scheduler: -```bash -celery -A dbapp beat --loglevel=info -``` - -### Уведомления по email - -Добавьте в задачу отправку email при завершении: - -```python -from django.core.mail import send_mail - -@shared_task(bind=True) -def fill_lyngsat_data_task(self, target_sats, regions=None): - # ... обработка ... - - # Отправка email - send_mail( - 'Задача Lyngsat завершена', - f'Обработано {stats["total_satellites"]} спутников', - 'noreply@example.com', - ['admin@example.com'], - ) -``` - -## Заключение - -Асинхронная обработка данных Lyngsat обеспечивает: -- ✅ Неблокирующий веб-интерфейс -- ✅ Отслеживание прогресса в реальном времени -- ✅ Детальное логирование -- ✅ Масштабируемость (можно запустить несколько workers) -- ✅ Надежность (автоматический retry при ошибках) - -Для получения дополнительной помощи обратитесь к документации: -- [Celery Documentation](https://docs.celeryproject.org/) -- [Django Celery Results](https://django-celery-results.readthedocs.io/) diff --git a/CHANGES_SUMMARY.md b/CHANGES_SUMMARY.md deleted file mode 100644 index 9df16da..0000000 --- a/CHANGES_SUMMARY.md +++ /dev/null @@ -1,133 +0,0 @@ -# Сводка изменений: Модернизация функциональности Lyngsat - -## Обзор - -Реализована новая функциональность для заполнения данных о транспондерах спутников с сайта Lyngsat через веб-интерфейс. - -## Основные изменения - -### 1. Удалена карточка с картами 2D/3D -- **Файл**: `dbapp/mainapp/templates/mainapp/actions.html` -- **Изменение**: Заменена карточка "Карты" на карточку "Заполнение данных Lyngsat" - -### 2. Создана новая форма для заполнения данных -- **Файл**: `dbapp/mainapp/forms.py` -- **Добавлено**: Класс `FillLyngsatDataForm` с полями: - - `satellites` - мультивыбор спутников из базы данных - - `regions` - мультивыбор регионов (Europe, Asia, America, Atlantic) - -### 3. Создан новый view для обработки формы -- **Файл**: `dbapp/mainapp/views.py` -- **Добавлено**: Класс `FillLyngsatDataView` для обработки запросов -- **Функциональность**: - - Валидация формы - - Вызов функции заполнения данных - - Отображение статистики и ошибок - -### 4. Добавлен новый URL -- **Файл**: `dbapp/mainapp/urls.py` -- **Добавлено**: `path('fill-lyngsat-data/', views.FillLyngsatDataView.as_view(), name='fill_lyngsat_data')` - -### 5. Создан новый шаблон -- **Файл**: `dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html` -- **Содержимое**: - - Форма с мультивыбором спутников и регионов - - Информационные блоки - - Валидация на стороне клиента - -### 6. Доработана функция fill_lyngsat_data -- **Файл**: `dbapp/lyngsatapp/utils.py` -- **Изменения**: - - Добавлен параметр `regions` для выбора регионов - - Реализовано частичное заполнение данных - - Добавлена детальная статистика обработки: - - Количество обработанных спутников - - Количество обработанных источников - - Количество созданных записей - - Количество обновленных записей - - Список ошибок - - Улучшена обработка ошибок (процесс не прерывается при ошибке) - - Добавлена валидация данных перед сохранением - -### 7. Исправлен parser.py -- **Файл**: `dbapp/lyngsatapp/parser.py` -- **Изменение**: Удален тестовый код выполнения в конце файла - -### 8. Добавлено приложение lyngsatapp в настройки -- **Файл**: `dbapp/dbapp/settings/base.py` -- **Изменение**: Добавлено `'lyngsatapp'` в `INSTALLED_APPS` - -### 9. Исправлен admin для LyngSat -- **Файл**: `dbapp/lyngsatapp/admin.py` -- **Изменение**: Обновлены поля в `list_display`, `search_fields`, `ordering` в соответствии с моделью - -### 10. Создана миграция для LyngSat -- **Файл**: `dbapp/lyngsatapp/migrations/0001_initial.py` -- **Содержимое**: Создание модели LyngSat - -## Новые файлы - -1. `dbapp/mainapp/templates/mainapp/fill_lyngsat_data.html` - шаблон формы -2. `dbapp/lyngsatapp/migrations/0001_initial.py` - миграция базы данных -3. `LYNGSAT_FILL_GUIDE.md` - руководство пользователя -4. `CHANGES_SUMMARY.md` - этот файл - -## Измененные файлы - -1. `dbapp/mainapp/forms.py` - добавлена форма `FillLyngsatDataForm` -2. `dbapp/mainapp/views.py` - добавлен view `FillLyngsatDataView` -3. `dbapp/mainapp/urls.py` - добавлен URL для новой функциональности -4. `dbapp/mainapp/templates/mainapp/actions.html` - заменена карточка -5. `dbapp/lyngsatapp/utils.py` - доработана функция `fill_lyngsat_data` -6. `dbapp/lyngsatapp/parser.py` - удален тестовый код -7. `dbapp/lyngsatapp/admin.py` - исправлены поля админки -8. `dbapp/dbapp/settings/base.py` - добавлено приложение в INSTALLED_APPS - -## Технические детали - -### Зависимости -- FlareSolver должен быть запущен на `http://localhost:8191` -- Спутники должны быть предварительно добавлены в базу данных - -### Модель данных -Модель `LyngSat` содержит следующие поля: -- `id_satellite` - связь со спутником -- `frequency` - частота в МГц -- `polarization` - поляризация сигнала -- `modulation` - тип модуляции -- `standard` - стандарт передачи -- `sym_velocity` - символьная скорость -- `last_update` - дата последнего обновления -- `channel_info` - информация о канале -- `fec` - коэффициент коррекции ошибок -- `url` - ссылка на страницу Lyngsat - -### Процесс работы -1. Пользователь выбирает спутники и регионы -2. Система подключается к Lyngsat через FlareSolver -3. Парсит данные для каждого спутника -4. Создает или обновляет записи в базе данных -5. Возвращает статистику обработки - -## Тестирование - -Выполнены следующие проверки: -- ✅ `python manage.py check` - нет ошибок -- ✅ `python manage.py makemigrations` - миграция создана -- ✅ Проверка диагностики кода - нет критических ошибок -- ✅ Проверка импортов - все импорты корректны - -## Следующие шаги - -Для полного тестирования необходимо: -1. Применить миграции: `python manage.py migrate` -2. Запустить FlareSolver: `docker run -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest` -3. Добавить спутники в базу данных (если еще не добавлены) -4. Протестировать форму заполнения данных через веб-интерфейс - -## Примечания - -- Процесс заполнения может занять продолжительное время (несколько минут на спутник) -- Рекомендуется начинать с небольшого количества спутников -- Все ошибки логируются и отображаются пользователю -- Существующие записи обновляются, новые создаются diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 9373fc7..f326859 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -1,18 +1,3 @@ -# Руководство по установке асинхронной системы Lyngsat - -## Вариант 1: Полная установка с Celery (рекомендуется) - -### Шаг 1: Установка зависимостей - -```bash -pip install -r dbapp/requirements.txt -``` - -Это установит: -- `celery>=5.4.0` -- `django-celery-results>=2.5.1` -- И все остальные зависимости - ### Шаг 2: Применение миграций ```bash @@ -58,57 +43,6 @@ celery -A dbapp worker --loglevel=info --- -## Вариант 2: Базовая установка без Celery - -Если вы не хотите использовать асинхронную обработку, система будет работать в синхронном режиме. - -### Шаг 1: Установка базовых зависимостей - -```bash -# Установите все зависимости кроме Celery -pip install -r dbapp/requirements.txt --ignore-installed celery django-celery-results -``` - -Или вручную удалите из `requirements.txt`: -- `celery>=5.4.0` -- `django-celery-results>=2.5.1` - -Затем: -```bash -pip install -r dbapp/requirements.txt -``` - -### Шаг 2: Применение миграций - -```bash -cd dbapp -python manage.py migrate -``` - -### Шаг 3: Запуск FlareSolver - -```bash -docker-compose up -d flaresolverr -``` - -### Шаг 4: Запуск Django - -```bash -cd dbapp -python manage.py runserver -``` - -### Ограничения базовой установки - -⚠️ **Внимание**: В синхронном режиме: -- Веб-интерфейс будет заблокирован во время обработки -- Нет отслеживания прогресса в реальном времени -- Нет детального логирования -- Обработка может занять много времени - ---- - -## Проверка установки ### Проверка Django ```bash @@ -136,25 +70,6 @@ curl http://localhost:8191/v1 --- -## Решение проблем при установке - -### Проблема: ModuleNotFoundError: No module named 'celery' - -**Решение 1**: Установите Celery -```bash -pip install celery django-celery-results -``` - -**Решение 2**: Используйте базовую установку (см. Вариант 2) - -### Проблема: Redis connection refused - -**Решение**: Запустите Redis -```bash -docker-compose up -d redis -# или -sudo systemctl start redis -``` ### Проблема: FlareSolver не отвечает @@ -165,72 +80,6 @@ docker-compose up -d flaresolverr docker run -d -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest ``` -### Проблема: Миграции не применяются - -**Решение**: Проверьте подключение к базе данных -```bash -# Проверьте .env файл -cat dbapp/.env - -# Проверьте PostgreSQL -docker-compose up -d db -docker-compose logs db -``` - ---- - -## Переменные окружения - -Создайте файл `dbapp/.env` (если еще не создан): - -```bash -# Database -DB_ENGINE=django.contrib.gis.db.backends.postgis -DB_NAME=geodb -DB_USER=geralt -DB_PASSWORD=123456 -DB_HOST=localhost -DB_PORT=5432 - -# Django -SECRET_KEY=your-secret-key-here -DEBUG=True -ALLOWED_HOSTS=localhost,127.0.0.1 - -# Celery (опционально) -CELERY_BROKER_URL=redis://localhost:6379/0 - -# FlareSolver -FLARESOLVERR_URL=http://localhost:8191/v1 -``` - ---- - -## Следующие шаги - -После успешной установки: - -1. **Прочитайте документацию**: - - `QUICKSTART_ASYNC.md` - быстрый старт - - `ASYNC_LYNGSAT_GUIDE.md` - полное руководство - - `ASYNC_CHANGES_SUMMARY.md` - технические детали - -2. **Настройте production окружение** (если необходимо): - - Настройте Systemd/Supervisor для Celery - - Настройте Nginx/Apache - - Настройте SSL - - Настройте мониторинг - -3. **Добавьте данные**: - - Добавьте спутники через админ-панель - - Запустите заполнение данных Lyngsat - -4. **Настройте мониторинг**: - - Установите Flower для мониторинга Celery - - Настройте логирование - - Настройте алерты - ---- ## Дополнительные инструменты @@ -263,39 +112,6 @@ docker run -d -p 5050:80 --name pgadmin \ --- -## Обновление системы - -### Обновление зависимостей - -```bash -pip install --upgrade -r dbapp/requirements.txt -``` - -### Применение новых миграций - -```bash -cd dbapp -python manage.py migrate -``` - -### Перезапуск сервисов - -```bash -# Перезапуск Docker контейнеров -docker-compose restart - -# Перезапуск Celery Worker -# Найдите PID процесса -ps aux | grep celery -# Остановите процесс -kill -# Запустите снова -celery -A dbapp worker --loglevel=info -``` - ---- - -## Удаление системы ### Остановка сервисов @@ -321,27 +137,31 @@ find dbapp -path "*/migrations/*.py" -not -name "__init__.py" -delete find dbapp -path "*/migrations/*.pyc" -delete ``` ---- +# Systemd service для запуска с хоста + +[Unit] +Description=Django Application +After=network.target + +[Service] +Type=notify +User=www-data +Group=www-data +WorkingDirectory=/path/to/your/app +Environment=PATH=/path/to/venv/bin +Environment=DATABASE_URL=postgresql://user:pass@localhost/geodb +ExecStart=/path/to/venv/bin/python manage.py runserver 0.0.0.0:8000 +ExecReload=/bin/kill -s HUP $MAINPID +TimeoutSec=300 +Restart=on-failure + +[Install] +WantedBy=multi-user.target ## Поддержка -Если у вас возникли проблемы: - 1. Проверьте логи: - Django: консоль где запущен runserver - Celery: `dbapp/logs/celery_worker.log` - Docker: `docker-compose logs` -2. Проверьте документацию: - - `ASYNC_LYNGSAT_GUIDE.md` - - `QUICKSTART_ASYNC.md` - - `ASYNC_CHANGES_SUMMARY.md` - -3. Проверьте статус сервисов: - ```bash - docker-compose ps - ps aux | grep celery - redis-cli ping - ``` - -4. Создайте issue в репозитории с описанием проблемы и логами diff --git a/LYNGSAT_FILL_GUIDE.md b/LYNGSAT_FILL_GUIDE.md deleted file mode 100644 index 4f849a6..0000000 --- a/LYNGSAT_FILL_GUIDE.md +++ /dev/null @@ -1,78 +0,0 @@ -# Руководство по заполнению данных Lyngsat - -## Описание - -Новая функциональность позволяет автоматически загружать данные о транспондерах спутников с сайта Lyngsat. - -## Как использовать - -1. **Перейдите на страницу действий** - - Откройте главную страницу приложения - - Нажмите на "Действия" в меню навигации - -2. **Откройте форму заполнения данных Lyngsat** - - На странице действий найдите карточку "Заполнение данных Lyngsat" - - Нажмите кнопку "Заполнить данные Lyngsat" - -3. **Заполните форму** - - **Выберите спутники**: Выберите один или несколько спутников из списка (удерживайте Ctrl/Cmd для множественного выбора) - - **Выберите регионы**: Выберите регионы для парсинга (Europe, Asia, America, Atlantic) - -4. **Запустите процесс** - - Нажмите кнопку "Заполнить данные" - - Дождитесь завершения процесса (может занять несколько минут) - -## Что происходит при заполнении - -1. Система подключается к сайту Lyngsat через FlareSolver (требуется запущенный сервис) -2. Парсит данные о транспондерах для выбранных спутников -3. Создает или обновляет записи в базе данных: - - Частота - - Поляризация - - Модуляция - - Стандарт (DVB-S, DVB-S2 и т.д.) - - Символьная скорость - - FEC (коэффициент коррекции ошибок) - - Информация о канале - - Дата последнего обновления - -## Требования - -- **FlareSolver**: Должен быть запущен на `http://localhost:8191` -- **Спутники в базе**: Спутники должны быть предварительно добавлены в базу данных -- **Интернет-соединение**: Требуется для доступа к сайту Lyngsat - -## Результаты - -После завершения процесса вы увидите: -- Количество обработанных спутников -- Количество обработанных источников -- Количество созданных записей -- Количество обновленных записей -- Список ошибок (если есть) - -## Технические детали - -### Функция `fill_lyngsat_data` - -Функция была доработана для поддержки: -- Частичного заполнения данных -- Выбора регионов -- Детальной статистики обработки -- Обработки ошибок без прерывания процесса - -### Изменения в коде - -1. **Новая форма**: `FillLyngsatDataForm` в `mainapp/forms.py` -2. **Новый view**: `FillLyngsatDataView` в `mainapp/views.py` -3. **Новый URL**: `/fill-lyngsat-data/` в `mainapp/urls.py` -4. **Новый шаблон**: `fill_lyngsat_data.html` -5. **Обновленная функция**: `fill_lyngsat_data` в `lyngsatapp/utils.py` -6. **Обновленный шаблон**: `actions.html` (заменена карточка с картами) - -## Примечания - -- Процесс может занять продолжительное время в зависимости от количества выбранных спутников -- Рекомендуется выбирать небольшое количество спутников для первого запуска -- Существующие записи будут обновлены, новые - созданы -- Все ошибки логируются и отображаются пользователю diff --git a/dbapp/dbapp/settings/__init__.py b/dbapp/dbapp/settings/__init__.py index 797a622..bef4d1f 100644 --- a/dbapp/dbapp/settings/__init__.py +++ b/dbapp/dbapp/settings/__init__.py @@ -10,11 +10,8 @@ import os from dotenv import load_dotenv -# Load environment variables from .env file load_dotenv() -# Determine the environment from DJANGO_ENVIRONMENT variable -# Defaults to 'development' for safety ENVIRONMENT = os.getenv('DJANGO_ENVIRONMENT', 'development').lower() if ENVIRONMENT == 'production': diff --git a/dbapp/dbapp/settings/development.py b/dbapp/dbapp/settings/development.py index 3577cbf..3e0e387 100644 --- a/dbapp/dbapp/settings/development.py +++ b/dbapp/dbapp/settings/development.py @@ -1,48 +1,55 @@ -""" -Development-specific settings. -""" - -from .base import * - -# ============================================================================ -# DEBUG CONFIGURATION -# ============================================================================ - -DEBUG = True - -# ============================================================================ -# ALLOWED HOSTS -# ============================================================================ - -# Allow all hosts in development -ALLOWED_HOSTS = ['*'] - -# ============================================================================ -# INSTALLED APPS - Development additions -# ============================================================================ - -INSTALLED_APPS += [ - 'debug_toolbar', -] - -# ============================================================================ -# MIDDLEWARE - Development additions -# ============================================================================ - -# Add debug toolbar middleware at the beginning -MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE - -# ============================================================================ -# DEBUG TOOLBAR CONFIGURATION -# ============================================================================ - -INTERNAL_IPS = [ - '127.0.0.1', -] - -# ============================================================================ -# EMAIL CONFIGURATION -# ============================================================================ - -# Use console backend for development -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' \ No newline at end of file +""" +Development-specific settings. +""" + +from .base import * + +# ============================================================================ +# DEBUG CONFIGURATION +# ============================================================================ + +DEBUG = True + +# ============================================================================ +# ALLOWED HOSTS +# ============================================================================ + +# Allow all hosts in development +ALLOWED_HOSTS = ['*'] + +# ============================================================================ +# INSTALLED APPS - Development additions +# ============================================================================ + +INSTALLED_APPS += [ + 'debug_toolbar', +] + +# ============================================================================ +# MIDDLEWARE - Development additions +# ============================================================================ + +# Add debug toolbar middleware at the beginning +MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE + +# ============================================================================ +# DEBUG TOOLBAR CONFIGURATION +# ============================================================================ + +INTERNAL_IPS = [ + '127.0.0.1', +] + +# ============================================================================ +# EMAIL CONFIGURATION +# ============================================================================ + +# Use console backend for development +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# ============================================================================ +# STATIC FILES CONFIGURATION FOR DEVELOPMENT +# ============================================================================ + +# Define STATIC_ROOT for collectstatic command to work in development +STATIC_ROOT = BASE_DIR.parent / "staticfiles" \ No newline at end of file diff --git a/dbapp/mainapp/templates/mainapp/objitem_list.html b/dbapp/mainapp/templates/mainapp/objitem_list.html index 4c1ad77..234ed3f 100644 --- a/dbapp/mainapp/templates/mainapp/objitem_list.html +++ b/dbapp/mainapp/templates/mainapp/objitem_list.html @@ -408,6 +408,40 @@ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+
+
@@ -759,6 +793,8 @@ setupRadioLikeCheckboxes('has_kupsat'); setupRadioLikeCheckboxes('has_valid'); + setupRadioLikeCheckboxes('has_source_type'); + setupRadioLikeCheckboxes('has_sigma'); // Date range quick selection functions window.setDateRange = function (period) { diff --git a/dbapp/mainapp/views.py b/dbapp/mainapp/views.py index 3b31bea..4018fc1 100644 --- a/dbapp/mainapp/views.py +++ b/dbapp/mainapp/views.py @@ -1,1377 +1,1393 @@ -# Standard library imports -from collections import defaultdict -from io import BytesIO - -# Django imports -from django.utils import timezone -from django.contrib import messages -from django.contrib.admin.views.decorators import staff_member_required -from django.contrib.auth import logout -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.paginator import Paginator -from django.db import models -from django.db.models import F -from django.http import HttpResponse, JsonResponse -from django.shortcuts import redirect, render -from django.urls import reverse_lazy -from django.utils.decorators import method_decorator -from django.views import View -from django.views.generic import ( - CreateView, - DeleteView, - FormView, - UpdateView, -) - -# Third-party imports -import pandas as pd - -# Local imports -from .clusters import get_clusters -from .forms import ( - GeoForm, - LoadCsvData, - LoadExcelData, - NewEventForm, - ObjItemForm, - ParameterForm, - UploadFileForm, - UploadVchLoad, - VchLinkForm, - FillLyngsatDataForm, - LinkLyngsatForm, -) -from .mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin -from .models import Geo, Modulation, ObjItem, Polarization, Satellite -from .utils import ( - add_satellite_list, - compare_and_link_vch_load, - fill_data_from_df, - get_points_from_csv, - get_vch_load_from_html, - kub_report, - parse_pagination_params, -) -from mapsapp.utils import parse_transponders_from_xml - - -class AddSatellitesView(LoginRequiredMixin, View): - def get(self, request): - add_satellite_list() - return redirect("mainapp:home") - - -# class AddTranspondersView(View): -# def get(self, request): -# try: -# parse_transponders_from_json(BASE_DIR / "transponders.json") -# except FileNotFoundError: -# print("Файл не найден") -# return redirect('home') - - -class AddTranspondersView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/transponders_upload.html" - form_class = UploadFileForm - success_message = "Файл успешно обработан" - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - uploaded_file = self.request.FILES["file"] - try: - content = uploaded_file.read() - parse_transponders_from_xml(BytesIO(content)) - except ValueError as e: - messages.error(self.request, f"Ошибка при чтении таблиц: {e}") - return redirect("mainapp:add_trans") - except Exception as e: - messages.error(self.request, f"Неизвестная ошибка: {e}") - return redirect("mainapp:add_trans") - return super().form_valid(form) - - def get_success_url(self): - return reverse_lazy("mainapp:add_trans") - - -from django.views.generic import View - - -class ActionsPageView(View): - def get(self, request): - if request.user.is_authenticated: - return render(request, "mainapp/actions.html") - else: - return render(request, "mainapp/login_required.html") - - -class HomePageView(View): - def get(self, request): - if request.user.is_authenticated: - # Redirect to objitem list if authenticated - return redirect("mainapp:objitem_list") - else: - return render(request, "mainapp/login_required.html") - - -class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/add_data_from_excel.html" - form_class = LoadExcelData - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - uploaded_file = self.request.FILES["file"] - selected_sat = form.cleaned_data["sat_choice"] - number = form.cleaned_data["number_input"] - - try: - import io - - df = pd.read_excel(io.BytesIO(uploaded_file.read())) - if number > 0: - df = df.head(number) - result = fill_data_from_df(df, selected_sat, self.request.user.customuser) - - messages.success( - self.request, f"Данные успешно загружены! Обработано строк: {result}" - ) - except Exception as e: - messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") - - return redirect("mainapp:load_excel_data") - - def get_success_url(self): - return reverse_lazy("mainapp:load_excel_data") - - -class GetLocationsView(LoginRequiredMixin, View): - def get(self, request, sat_id): - locations = ( - ObjItem.objects.filter(parameter_obj__id_satellite=sat_id) - .select_related( - "geo_obj", - "parameter_obj", - "parameter_obj__polarization", - ) - ) - - if not locations.exists(): - return JsonResponse({"error": "Объектов не найдено"}, status=404) - - features = [] - for loc in locations: - if not hasattr(loc, "geo_obj") or not loc.geo_obj or not loc.geo_obj.coords: - continue - - param = getattr(loc, 'parameter_obj', None) - if not param: - continue - - features.append( - { - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [loc.geo_obj.coords[0], loc.geo_obj.coords[1]], - }, - "properties": { - "pol": param.polarization.name if param.polarization else "-", - "freq": param.frequency * 1000000 if param.frequency else 0, - "name": loc.name or "-", - "id": loc.geo_obj.id, - }, - } - ) - - return JsonResponse({"type": "FeatureCollection", "features": features}) - - -class LoadCsvDataView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/add_data_from_csv.html" - form_class = LoadCsvData - success_message = "Данные успешно загружены!" - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - uploaded_file = self.request.FILES["file"] - try: - content = uploaded_file.read() - if isinstance(content, bytes): - content = content.decode("utf-8") - - get_points_from_csv(content, self.request.user.customuser) - except Exception as e: - messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") - return redirect("mainapp:load_csv_data") - - return super().form_valid(form) - - def get_success_url(self): - return reverse_lazy("mainapp:load_csv_data") - - -@method_decorator(staff_member_required, name="dispatch") -class ShowMapView(RoleRequiredMixin, View): - required_roles = ["admin", "moderator"] - - def get(self, request): - ids = request.GET.get("ids", "") - points = [] - if ids: - id_list = [int(x) for x in ids.split(",") if x.isdigit()] - locations = ObjItem.objects.filter(id__in=id_list).select_related( - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - "geo_obj", - ) - for obj in locations: - if ( - not hasattr(obj, "geo_obj") - or not obj.geo_obj - or not obj.geo_obj.coords - ): - continue - param = getattr(obj, 'parameter_obj', None) - if not param: - continue - points.append( - { - "name": f"{obj.name}", - "freq": f"{param.frequency} [{param.freq_range}] МГц", - "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), - } - ) - else: - return redirect("admin") - grouped = defaultdict(list) - for p in points: - grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) - - groups = [ - {"name": name, "points": coords_list} - for name, coords_list in grouped.items() - ] - - context = { - "groups": groups, - } - return render(request, "admin/map_custom.html", context) - - -class ShowSelectedObjectsMapView(LoginRequiredMixin, View): - def get(self, request): - ids = request.GET.get("ids", "") - points = [] - if ids: - id_list = [int(x) for x in ids.split(",") if x.isdigit()] - locations = ObjItem.objects.filter(id__in=id_list).select_related( - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - "geo_obj", - ) - for obj in locations: - if ( - not hasattr(obj, "geo_obj") - or not obj.geo_obj - or not obj.geo_obj.coords - ): - continue - param = getattr(obj, 'parameter_obj', None) - if not param: - continue - points.append( - { - "name": f"{obj.name}", - "freq": f"{param.frequency} [{param.freq_range}] МГц", - "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), - } - ) - else: - return redirect("mainapp:objitem_list") - - # Group points by object name - from collections import defaultdict - - grouped = defaultdict(list) - for p in points: - grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) - - groups = [ - {"name": name, "points": coords_list} - for name, coords_list in grouped.items() - ] - - context = { - "groups": groups, - } - return render(request, "mainapp/objitem_map.html", context) - - -class ClusterTestView(LoginRequiredMixin, View): - def get(self, request): - objs = ObjItem.objects.filter( - name__icontains="! Astra 4A 12654,040 [1,962] МГц H" - ) - coords = [] - for obj in objs: - if hasattr(obj, "geo_obj") and obj.geo_obj and obj.geo_obj.coords: - coords.append( - (obj.geo_obj.coords.coords[1], obj.geo_obj.coords.coords[0]) - ) - get_clusters(coords) - - return JsonResponse({"success": "ок"}) - - -def custom_logout(request): - logout(request) - return redirect("mainapp:home") - - -class UploadVchLoadView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/upload_html.html" - form_class = UploadVchLoad - success_message = "Файл успешно обработан" - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - selected_sat = form.cleaned_data["sat_choice"] - uploaded_file = self.request.FILES["file"] - try: - get_vch_load_from_html(uploaded_file, selected_sat) - except ValueError as e: - messages.error(self.request, f"Ошибка при чтении таблиц: {e}") - return redirect("mainapp:vch_load") - except Exception as e: - messages.error(self.request, f"Неизвестная ошибка: {e}") - return redirect("mainapp:vch_load") - - return super().form_valid(form) - - def get_success_url(self): - return reverse_lazy("mainapp:vch_load") - - -class LinkVchSigmaView(LoginRequiredMixin, FormView): - template_name = "mainapp/link_vch.html" - form_class = VchLinkForm - - def form_valid(self, form): - # value1 больше не используется - погрешность частоты определяется автоматически - freq_range = form.cleaned_data["value2"] - sat_id = form.cleaned_data["sat_choice"] - - # Передаём 0 для eps_freq и ku_range, так как они не используются - count_all, link_count = compare_and_link_vch_load(sat_id, 0, freq_range, 0) - - messages.success( - self.request, f"Привязано {link_count} из {count_all} объектов" - ) - return redirect("mainapp:link_vch_sigma") - - def form_invalid(self, form): - return self.render_to_response(self.get_context_data(form=form)) - - -class LinkLyngsatSourcesView(LoginRequiredMixin, FormMessageMixin, FormView): - """Представление для привязки источников LyngSat к объектам""" - template_name = "mainapp/link_lyngsat.html" - form_class = LinkLyngsatForm - success_message = "Привязка источников LyngSat завершена" - error_message = "Ошибка при привязке источников" - - def form_valid(self, form): - from lyngsatapp.models import LyngSat - - satellites = form.cleaned_data.get("satellites") - frequency_tolerance = form.cleaned_data.get("frequency_tolerance", 0.5) - - # Если спутники не выбраны, обрабатываем все - if satellites: - objitems = ObjItem.objects.filter( - parameter_obj__id_satellite__in=satellites - ).select_related('parameter_obj', 'parameter_obj__polarization') - else: - objitems = ObjItem.objects.filter( - parameter_obj__isnull=False - ).select_related('parameter_obj', 'parameter_obj__polarization') - - linked_count = 0 - total_count = objitems.count() - - for objitem in objitems: - if not hasattr(objitem, 'parameter_obj') or not objitem.parameter_obj: - continue - - param = objitem.parameter_obj - - # Округляем частоту объекта - if param.frequency: - rounded_freq = round(param.frequency, 0) # Округление до целого - - # Ищем подходящий источник LyngSat - # Сравниваем по округленной частоте и поляризации - lyngsat_sources = LyngSat.objects.filter( - id_satellite=param.id_satellite, - polarization=param.polarization, - frequency__gte=rounded_freq - frequency_tolerance, - frequency__lte=rounded_freq + frequency_tolerance - ).order_by('frequency') - - if lyngsat_sources.exists(): - # Берем первый подходящий источник - objitem.lyngsat_source = lyngsat_sources.first() - objitem.save(update_fields=['lyngsat_source']) - linked_count += 1 - - messages.success( - self.request, - f"Привязано {linked_count} из {total_count} объектов к источникам LyngSat" - ) - return redirect("mainapp:link_lyngsat") - - def form_invalid(self, form): - return self.render_to_response(self.get_context_data(form=form)) - - -class LyngsatDataAPIView(LoginRequiredMixin, View): - """API для получения данных LyngSat источника""" - - def get(self, request, lyngsat_id): - from lyngsatapp.models import LyngSat - - try: - lyngsat = LyngSat.objects.select_related( - 'id_satellite', - 'polarization', - 'modulation', - 'standard' - ).get(id=lyngsat_id) - - # Форматируем дату с учетом локального времени - last_update_str = '-' - if lyngsat.last_update: - local_time = timezone.localtime(lyngsat.last_update) - last_update_str = local_time.strftime("%d.%m.%Y") - - data = { - 'id': lyngsat.id, - 'satellite': lyngsat.id_satellite.name if lyngsat.id_satellite else '-', - 'frequency': f"{lyngsat.frequency:.3f}" if lyngsat.frequency else '-', - 'polarization': lyngsat.polarization.name if lyngsat.polarization else '-', - 'modulation': lyngsat.modulation.name if lyngsat.modulation else '-', - 'standard': lyngsat.standard.name if lyngsat.standard else '-', - 'sym_velocity': f"{lyngsat.sym_velocity:.0f}" if lyngsat.sym_velocity else '-', - 'fec': lyngsat.fec or '-', - 'channel_info': lyngsat.channel_info or '-', - 'last_update': last_update_str, - 'url': lyngsat.url or None, - } - - return JsonResponse(data) - except LyngSat.DoesNotExist: - return JsonResponse({'error': 'Источник LyngSat не найден'}, status=404) - except Exception as e: - return JsonResponse({'error': str(e)}, status=500) - - -class SigmaParameterDataAPIView(LoginRequiredMixin, View): - """API для получения данных SigmaParameter""" - - def get(self, request, parameter_id): - from .models import Parameter - - try: - parameter = Parameter.objects.select_related( - 'id_satellite', - 'polarization', - 'modulation', - 'standard' - ).prefetch_related( - 'sigma_parameter__mark', - 'sigma_parameter__id_satellite', - 'sigma_parameter__polarization', - 'sigma_parameter__modulation', - 'sigma_parameter__standard' - ).get(id=parameter_id) - - # Получаем все связанные SigmaParameter - sigma_params = parameter.sigma_parameter.all() - - sigma_data = [] - for sigma in sigma_params: - # Получаем отметки - marks = [] - for mark in sigma.mark.all().order_by('-timestamp'): - mark_str = '+' if mark.mark else '-' - date_str = '-' - if mark.timestamp: - local_time = timezone.localtime(mark.timestamp) - date_str = local_time.strftime("%d.%m.%Y %H:%M") - marks.append({ - 'mark': mark_str, - 'date': date_str - }) - - # Форматируем даты начала и окончания - datetime_begin_str = '-' - if sigma.datetime_begin: - local_time = timezone.localtime(sigma.datetime_begin) - datetime_begin_str = local_time.strftime("%d.%m.%Y %H:%M") - - datetime_end_str = '-' - if sigma.datetime_end: - local_time = timezone.localtime(sigma.datetime_end) - datetime_end_str = local_time.strftime("%d.%m.%Y %H:%M") - - sigma_data.append({ - 'id': sigma.id, - 'satellite': sigma.id_satellite.name if sigma.id_satellite else '-', - 'frequency': f"{sigma.frequency:.3f}" if sigma.frequency else '-', - 'transfer_frequency': f"{sigma.transfer_frequency:.3f}" if sigma.transfer_frequency else '-', - 'freq_range': f"{sigma.freq_range:.3f}" if sigma.freq_range else '-', - 'polarization': sigma.polarization.name if sigma.polarization else '-', - 'modulation': sigma.modulation.name if sigma.modulation else '-', - 'standard': sigma.standard.name if sigma.standard else '-', - 'bod_velocity': f"{sigma.bod_velocity:.0f}" if sigma.bod_velocity else '-', - 'snr': f"{sigma.snr:.1f}" if sigma.snr is not None else '-', - 'power': f"{sigma.power:.1f}" if sigma.power is not None else '-', - 'status': sigma.status or '-', - 'packets': 'Да' if sigma.packets else 'Нет' if sigma.packets is not None else '-', - 'datetime_begin': datetime_begin_str, - 'datetime_end': datetime_end_str, - 'marks': marks - }) - - return JsonResponse({ - 'parameter_id': parameter.id, - 'sigma_parameters': sigma_data - }) - except Parameter.DoesNotExist: - return JsonResponse({'error': 'Parameter не найден'}, status=404) - except Exception as e: - return JsonResponse({'error': str(e)}, status=500) - - -class ProcessKubsatView(LoginRequiredMixin, FormMessageMixin, FormView): - template_name = "mainapp/process_kubsat.html" - form_class = NewEventForm - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - uploaded_file = self.request.FILES["file"] - try: - content = uploaded_file.read() - df = kub_report(BytesIO(content)) - output = BytesIO() - with pd.ExcelWriter(output, engine="openpyxl") as writer: - df.to_excel(writer, index=False, sheet_name="Результат") - output.seek(0) - - response = HttpResponse( - output.getvalue(), - content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ) - response["Content-Disposition"] = ( - 'attachment; filename="kubsat_report.xlsx"' - ) - - messages.success(self.request, "Событие успешно обработано!") - return response - except Exception as e: - messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") - return redirect("mainapp:kubsat_excel") - - -class DeleteSelectedObjectsView(RoleRequiredMixin, View): - required_roles = ["admin", "moderator"] - - def post(self, request): - ids = request.POST.get("ids", "") - if not ids: - return JsonResponse({"error": "Нет ID для удаления"}, status=400) - - try: - id_list = [int(x) for x in ids.split(",") if x.isdigit()] - deleted_count, _ = ObjItem.objects.filter(id__in=id_list).delete() - - return JsonResponse( - { - "success": True, - "message": "Объект успешно удалён", - "deleted_count": deleted_count, - } - ) - except Exception as e: - return JsonResponse({"error": f"Ошибка при удалении: {str(e)}"}, status=500) - - -class ObjItemListView(LoginRequiredMixin, View): - def get(self, request): - satellites = ( - Satellite.objects.filter(parameters__objitem__isnull=False) - .distinct() - .only("id", "name") - .order_by("name") - ) - - selected_sat_id = request.GET.get("satellite_id") - page_number, items_per_page = parse_pagination_params(request) - sort_param = request.GET.get("sort", "") - - freq_min = request.GET.get("freq_min") - freq_max = request.GET.get("freq_max") - range_min = request.GET.get("range_min") - range_max = request.GET.get("range_max") - snr_min = request.GET.get("snr_min") - snr_max = request.GET.get("snr_max") - bod_min = request.GET.get("bod_min") - bod_max = request.GET.get("bod_max") - search_query = request.GET.get("search") - selected_modulations = request.GET.getlist("modulation") - selected_polarizations = request.GET.getlist("polarization") - selected_satellites = request.GET.getlist("satellite_id") - has_kupsat = request.GET.get("has_kupsat") - has_valid = request.GET.get("has_valid") - date_from = request.GET.get("date_from") - date_to = request.GET.get("date_to") - - objects = ObjItem.objects.none() - - if selected_satellites or selected_sat_id: - if selected_sat_id and not selected_satellites: - try: - selected_sat_id_single = int(selected_sat_id) - selected_satellites = [selected_sat_id_single] - except ValueError: - selected_satellites = [] - - if selected_satellites: - objects = ( - ObjItem.objects.select_related( - "geo_obj", - "updated_by__user", - "created_by__user", - "lyngsat_source", - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - ) - .prefetch_related( - "parameter_obj__sigma_parameter", - "parameter_obj__sigma_parameter__polarization", - ) - .filter(parameter_obj__id_satellite_id__in=selected_satellites) - ) - else: - objects = ObjItem.objects.select_related( - "geo_obj", - "updated_by__user", - "created_by__user", - "lyngsat_source", - "parameter_obj", - "parameter_obj__id_satellite", - "parameter_obj__polarization", - "parameter_obj__modulation", - "parameter_obj__standard", - ).prefetch_related( - "parameter_obj__sigma_parameter", - "parameter_obj__sigma_parameter__polarization", - ) - - if freq_min is not None and freq_min.strip() != "": - try: - freq_min_val = float(freq_min) - objects = objects.filter( - parameter_obj__frequency__gte=freq_min_val - ) - except ValueError: - pass - if freq_max is not None and freq_max.strip() != "": - try: - freq_max_val = float(freq_max) - objects = objects.filter( - parameter_obj__frequency__lte=freq_max_val - ) - except ValueError: - pass - - if range_min is not None and range_min.strip() != "": - try: - range_min_val = float(range_min) - objects = objects.filter( - parameter_obj__freq_range__gte=range_min_val - ) - except ValueError: - pass - if range_max is not None and range_max.strip() != "": - try: - range_max_val = float(range_max) - objects = objects.filter( - parameter_obj__freq_range__lte=range_max_val - ) - except ValueError: - pass - - if snr_min is not None and snr_min.strip() != "": - try: - snr_min_val = float(snr_min) - objects = objects.filter(parameter_obj__snr__gte=snr_min_val) - except ValueError: - pass - if snr_max is not None and snr_max.strip() != "": - try: - snr_max_val = float(snr_max) - objects = objects.filter(parameter_obj__snr__lte=snr_max_val) - except ValueError: - pass - - if bod_min is not None and bod_min.strip() != "": - try: - bod_min_val = float(bod_min) - objects = objects.filter( - parameter_obj__bod_velocity__gte=bod_min_val - ) - except ValueError: - pass - if bod_max is not None and bod_max.strip() != "": - try: - bod_max_val = float(bod_max) - objects = objects.filter( - parameter_obj__bod_velocity__lte=bod_max_val - ) - except ValueError: - pass - - if selected_modulations: - objects = objects.filter( - parameter_obj__modulation__id__in=selected_modulations - ) - - if selected_polarizations: - objects = objects.filter( - parameter_obj__polarization__id__in=selected_polarizations - ) - - if has_kupsat == "1": - objects = objects.filter(geo_obj__coords_kupsat__isnull=False) - elif has_kupsat == "0": - objects = objects.filter(geo_obj__coords_kupsat__isnull=True) - - if has_valid == "1": - objects = objects.filter(geo_obj__coords_valid__isnull=False) - elif has_valid == "0": - objects = objects.filter(geo_obj__coords_valid__isnull=True) - - # Date filter for geo_obj timestamp - date_from = request.GET.get("date_from") - date_to = request.GET.get("date_to") - - if date_from and date_from.strip(): - try: - from datetime import datetime - date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") - objects = objects.filter(geo_obj__timestamp__gte=date_from_obj) - except (ValueError, TypeError): - pass - - if date_to and date_to.strip(): - try: - from datetime import datetime, timedelta - date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") - # Add one day to include the entire end date - date_to_obj = date_to_obj + timedelta(days=1) - objects = objects.filter(geo_obj__timestamp__lt=date_to_obj) - except (ValueError, TypeError): - pass - - if search_query: - search_query = search_query.strip() - if search_query: - objects = objects.filter( - models.Q(name__icontains=search_query) - | models.Q(geo_obj__location__icontains=search_query) - ) - else: - selected_sat_id = None - - objects = objects.annotate( - first_param_freq=F("parameter_obj__frequency"), - first_param_range=F("parameter_obj__freq_range"), - first_param_snr=F("parameter_obj__snr"), - first_param_bod=F("parameter_obj__bod_velocity"), - first_param_sat_name=F("parameter_obj__id_satellite__name"), - first_param_pol_name=F("parameter_obj__polarization__name"), - first_param_mod_name=F("parameter_obj__modulation__name"), - ) - - valid_sort_fields = { - "name": "name", - "-name": "-name", - "updated_at": "updated_at", - "-updated_at": "-updated_at", - "created_at": "created_at", - "-created_at": "-created_at", - "updated_by": "updated_by__user__username", - "-updated_by": "-updated_by__user__username", - "created_by": "created_by__user__username", - "-created_by": "-created_by__user__username", - "geo_timestamp": "geo_obj__timestamp", - "-geo_timestamp": "-geo_obj__timestamp", - "frequency": "first_param_freq", - "-frequency": "-first_param_freq", - "freq_range": "first_param_range", - "-freq_range": "-first_param_range", - "snr": "first_param_snr", - "-snr": "-first_param_snr", - "bod_velocity": "first_param_bod", - "-bod_velocity": "-first_param_bod", - "satellite": "first_param_sat_name", - "-satellite": "-first_param_sat_name", - "polarization": "first_param_pol_name", - "-polarization": "-first_param_pol_name", - "modulation": "first_param_mod_name", - "-modulation": "-first_param_mod_name", - } - - if sort_param in valid_sort_fields: - objects = objects.order_by(valid_sort_fields[sort_param]) - - paginator = Paginator(objects, items_per_page) - page_obj = paginator.get_page(page_number) - - processed_objects = [] - for obj in page_obj: - param = getattr(obj, 'parameter_obj', None) - - geo_coords = "-" - geo_timestamp = "-" - geo_location = "-" - kupsat_coords = "-" - valid_coords = "-" - distance_geo_kup = "-" - distance_geo_valid = "-" - distance_kup_valid = "-" - - if hasattr(obj, "geo_obj") and obj.geo_obj: - geo_timestamp = obj.geo_obj.timestamp - geo_location = obj.geo_obj.location - - if obj.geo_obj.coords: - longitude = obj.geo_obj.coords.coords[0] - latitude = obj.geo_obj.coords.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - geo_coords = f"{lat} {lon}" - - if obj.geo_obj.coords_kupsat: - longitude = obj.geo_obj.coords_kupsat.coords[0] - latitude = obj.geo_obj.coords_kupsat.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - kupsat_coords = f"{lat} {lon}" - elif obj.geo_obj.coords_kupsat is not None: - kupsat_coords = "-" - - if obj.geo_obj.coords_valid: - longitude = obj.geo_obj.coords_valid.coords[0] - latitude = obj.geo_obj.coords_valid.coords[1] - lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" - lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" - valid_coords = f"{lat} {lon}" - elif obj.geo_obj.coords_valid is not None: - valid_coords = "-" - - if obj.geo_obj.distance_coords_kup is not None: - distance_geo_kup = f"{obj.geo_obj.distance_coords_kup:.3f}" - - if obj.geo_obj.distance_coords_valid is not None: - distance_geo_valid = f"{obj.geo_obj.distance_coords_valid:.3f}" - - if obj.geo_obj.distance_kup_valid is not None: - distance_kup_valid = f"{obj.geo_obj.distance_kup_valid:.3f}" - - satellite_name = "-" - frequency = "-" - freq_range = "-" - polarization_name = "-" - bod_velocity = "-" - modulation_name = "-" - snr = "-" - standard_name = "-" - comment = "-" - is_average = "-" - - if param: - if hasattr(param, "id_satellite") and param.id_satellite: - satellite_name = ( - param.id_satellite.name - if hasattr(param.id_satellite, "name") - else "-" - ) - - frequency = ( - f"{param.frequency:.3f}" if param.frequency is not None else "-" - ) - freq_range = ( - f"{param.freq_range:.3f}" if param.freq_range is not None else "-" - ) - bod_velocity = ( - f"{param.bod_velocity:.0f}" - if param.bod_velocity is not None - else "-" - ) - snr = f"{param.snr:.0f}" if param.snr is not None else "-" - - if hasattr(param, "polarization") and param.polarization: - polarization_name = ( - param.polarization.name - if hasattr(param.polarization, "name") - else "-" - ) - - if hasattr(param, "modulation") and param.modulation: - modulation_name = ( - param.modulation.name - if hasattr(param.modulation, "name") - else "-" - ) - - if hasattr(param, "standard") and param.standard: - standard_name = ( - param.standard.name - if hasattr(param.standard, "name") - else "-" - ) - - if hasattr(obj, "geo_obj") and obj.geo_obj: - comment = obj.geo_obj.comment or "-" - is_average = "Да" if obj.geo_obj.is_average else "Нет" if obj.geo_obj.is_average is not None else "-" - - # Check if LyngSat source is linked - source_type = "ТВ" if obj.lyngsat_source else "-" - - # Check if SigmaParameter is linked - has_sigma = False - sigma_info = "-" - if param: - sigma_count = param.sigma_parameter.count() - if sigma_count > 0: - has_sigma = True - # Get first sigma parameter for preview - first_sigma = param.sigma_parameter.first() - if first_sigma: - sigma_freq = f"{first_sigma.frequency:.3f}" if first_sigma.frequency else "-" - sigma_range = f"{first_sigma.freq_range:.3f}" if first_sigma.freq_range else "-" - sigma_pol = first_sigma.polarization.name if first_sigma.polarization else "-" - # Сокращаем поляризацию - sigma_pol_short = sigma_pol[0] if sigma_pol and sigma_pol != "-" else "-" - sigma_info = f"{sigma_freq}/{sigma_range}/{sigma_pol_short}" - - processed_objects.append( - { - "id": obj.id, - "name": obj.name or "-", - "satellite_name": satellite_name, - "frequency": frequency, - "freq_range": freq_range, - "polarization": polarization_name, - "bod_velocity": bod_velocity, - "modulation": modulation_name, - "snr": snr, - "geo_timestamp": geo_timestamp, - "geo_location": geo_location, - "geo_coords": geo_coords, - "kupsat_coords": kupsat_coords, - "valid_coords": valid_coords, - "distance_geo_kup": distance_geo_kup, - "distance_geo_valid": distance_geo_valid, - "distance_kup_valid": distance_kup_valid, - "updated_by": obj.updated_by if obj.updated_by else "-", - "comment": comment, - "is_average": is_average, - "source_type": source_type, - "standard": standard_name, - "has_sigma": has_sigma, - "sigma_info": sigma_info, - "obj": obj, - } - ) - - modulations = Modulation.objects.all() - polarizations = Polarization.objects.all() - - context = { - "satellites": satellites, - "selected_satellite_id": selected_sat_id, - "page_obj": page_obj, - "processed_objects": processed_objects, - "items_per_page": items_per_page, - "available_items_per_page": [50, 100, 500, 1000], - "freq_min": freq_min, - "freq_max": freq_max, - "range_min": range_min, - "range_max": range_max, - "snr_min": snr_min, - "snr_max": snr_max, - "bod_min": bod_min, - "bod_max": bod_max, - "search_query": search_query, - "selected_modulations": [ - int(x) for x in selected_modulations if x.isdigit() - ], - "selected_polarizations": [ - int(x) for x in selected_polarizations if x.isdigit() - ], - "selected_satellites": [int(x) for x in selected_satellites if x.isdigit()], - "has_kupsat": has_kupsat, - "has_valid": has_valid, - "date_from": date_from, - "date_to": date_to, - "modulations": modulations, - "polarizations": polarizations, - "full_width_page": True, - "sort": sort_param, - } - - return render(request, "mainapp/objitem_list.html", context) - - -class ObjItemFormView( - RoleRequiredMixin, CoordinateProcessingMixin, FormMessageMixin, UpdateView -): - """ - Базовый класс для создания и редактирования ObjItem. - - Содержит общую логику обработки форм, координат и параметров. - """ - - model = ObjItem - form_class = ObjItemForm - template_name = "mainapp/objitem_form.html" - success_url = reverse_lazy("mainapp:home") - required_roles = ["admin", "moderator"] - - def get_success_url(self): - """Возвращает URL с сохраненными параметрами фильтров.""" - # Получаем все параметры из GET запроса и сохраняем их в URL - if self.request.GET: - from urllib.parse import urlencode - query_string = urlencode(self.request.GET) - return reverse_lazy("mainapp:objitem_list") + '?' + query_string - return reverse_lazy("mainapp:objitem_list") - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context["LEAFLET_CONFIG"] = { - "DEFAULT_CENTER": (55.75, 37.62), - "DEFAULT_ZOOM": 5, - } - - # Сохраняем параметры возврата для кнопки "Назад" - context["return_params"] = self.request.GET.get('return_params', '') - - # Работаем с одной формой параметра вместо formset - if self.object and hasattr(self.object, "parameter_obj") and self.object.parameter_obj: - context["parameter_form"] = ParameterForm( - instance=self.object.parameter_obj, prefix="parameter" - ) - else: - context["parameter_form"] = ParameterForm(prefix="parameter") - - if self.object and hasattr(self.object, "geo_obj") and self.object.geo_obj: - context["geo_form"] = GeoForm( - instance=self.object.geo_obj, prefix="geo" - ) - else: - context["geo_form"] = GeoForm(prefix="geo") - - return context - - def form_valid(self, form): - # Получаем форму параметра - if self.object and hasattr(self.object, "parameter_obj") and self.object.parameter_obj: - parameter_form = ParameterForm( - self.request.POST, - instance=self.object.parameter_obj, - prefix="parameter" - ) - else: - parameter_form = ParameterForm(self.request.POST, prefix="parameter") - - if self.object and hasattr(self.object, "geo_obj") and self.object.geo_obj: - geo_form = GeoForm(self.request.POST, instance=self.object.geo_obj, prefix="geo") - else: - geo_form = GeoForm(self.request.POST, prefix="geo") - - # Сохраняем основной объект - self.object = form.save(commit=False) - self.set_user_fields() - self.object.save() - - # Сохраняем связанный параметр - if parameter_form.is_valid(): - self.save_parameter(parameter_form) - else: - context = self.get_context_data() - context.update({ - 'form': form, - 'parameter_form': parameter_form, - 'geo_form': geo_form, - }) - return self.render_to_response(context) - - # Сохраняем геоданные - if geo_form.is_valid(): - self.save_geo_data(geo_form) - else: - context = self.get_context_data() - context.update({ - 'form': form, - 'parameter_form': parameter_form, - 'geo_form': geo_form, - }) - return self.render_to_response(context) - - return super().form_valid(form) - - def set_user_fields(self): - """Устанавливает поля пользователя для объекта.""" - raise NotImplementedError("Subclasses must implement set_user_fields()") - - def save_parameter(self, parameter_form): - """Сохраняет параметр объекта через OneToOne связь.""" - if parameter_form.is_valid(): - instance = parameter_form.save(commit=False) - instance.objitem = self.object - instance.save() - - def save_geo_data(self, geo_form): - """Сохраняет геоданные объекта.""" - geo_instance = self.get_or_create_geo_instance() - - # Обновляем поля из geo_form - if geo_form.is_valid(): - geo_instance.location = geo_form.cleaned_data["location"] - geo_instance.comment = geo_form.cleaned_data["comment"] - geo_instance.is_average = geo_form.cleaned_data["is_average"] - - # Обрабатываем координаты - self.process_coordinates(geo_instance) - - # Обрабатываем дату/время - self.process_timestamp(geo_instance) - - geo_instance.save() - - def get_or_create_geo_instance(self): - """Получает или создает экземпляр Geo.""" - if hasattr(self.object, "geo_obj") and self.object.geo_obj: - return self.object.geo_obj - return Geo(objitem=self.object) - - -class ObjItemUpdateView(ObjItemFormView): - """Представление для редактирования ObjItem.""" - - success_message = "Объект успешно сохранён!" - - def set_user_fields(self): - self.object.updated_by = self.request.user.customuser - - -class ObjItemCreateView(ObjItemFormView, CreateView): - """Представление для создания ObjItem.""" - - success_message = "Объект успешно создан!" - - def set_user_fields(self): - self.object.created_by = self.request.user.customuser - self.object.updated_by = self.request.user.customuser - - -class ObjItemDeleteView(RoleRequiredMixin, FormMessageMixin, DeleteView): - model = ObjItem - template_name = "mainapp/objitem_confirm_delete.html" - success_url = reverse_lazy("mainapp:objitem_list") - success_message = "Объект успешно удалён!" - required_roles = ["admin", "moderator"] - - def get_success_url(self): - """Возвращает URL с сохраненными параметрами фильтров.""" - # Получаем все параметры из GET запроса и сохраняем их в URL - if self.request.GET: - from urllib.parse import urlencode - query_string = urlencode(self.request.GET) - return reverse_lazy("mainapp:objitem_list") + '?' + query_string - return reverse_lazy("mainapp:objitem_list") - - -class ObjItemDetailView(LoginRequiredMixin, View): - """ - Представление для просмотра деталей ObjItem в режиме чтения. - - Доступно для всех авторизованных пользователей, показывает данные в режиме чтения. - """ - def get(self, request, pk): - obj = ObjItem.objects.filter(pk=pk).select_related( - 'geo_obj', - 'updated_by__user', - 'created_by__user', - 'parameter_obj', - 'parameter_obj__id_satellite', - 'parameter_obj__polarization', - 'parameter_obj__modulation', - 'parameter_obj__standard', - ).first() - - if not obj: - from django.http import Http404 - raise Http404("Объект не найден") - - # Сохраняем параметры возврата для кнопки "Назад" - return_params = request.GET.get('return_params', '') - - context = { - 'object': obj, - 'return_params': return_params - } - - return render(request, "mainapp/objitem_detail.html", context) - - -class FillLyngsatDataView(LoginRequiredMixin, FormMessageMixin, FormView): - """ - Представление для заполнения данных из Lyngsat. - - Позволяет выбрать спутники и регионы для парсинга данных с сайта Lyngsat. - Запускает асинхронную задачу Celery для обработки. - """ - template_name = "mainapp/fill_lyngsat_data.html" - form_class = FillLyngsatDataForm - success_url = reverse_lazy("mainapp:lyngsat_task_status") - error_message = "Форма заполнена некорректно" - - def form_valid(self, form): - satellites = form.cleaned_data["satellites"] - regions = form.cleaned_data["regions"] - use_cache = form.cleaned_data.get("use_cache", True) - force_refresh = form.cleaned_data.get("force_refresh", False) - - # Получаем названия спутников - target_sats = [sat.name for sat in satellites] - - try: - from lyngsatapp.tasks import fill_lyngsat_data_task - - # Запускаем асинхронную задачу с параметрами кеширования - task = fill_lyngsat_data_task.delay( - target_sats, - regions, - force_refresh=force_refresh, - use_cache=use_cache - ) - - cache_status = "без кеша" if not use_cache else ("с обновлением кеша" if force_refresh else "с кешированием") - - messages.success( - self.request, - f"Задача запущена ({cache_status})! ID задачи: {task.id}. " - "Вы будете перенаправлены на страницу отслеживания прогресса." - ) - - # Перенаправляем на страницу статуса задачи - return redirect('mainapp:lyngsat_task_status', task_id=task.id) - - except Exception as e: - messages.error(self.request, f"Ошибка при запуске задачи: {str(e)}") - return redirect("mainapp:fill_lyngsat_data") - - -class LyngsatTaskStatusView(LoginRequiredMixin, View): - """ - Представление для отслеживания статуса задачи заполнения данных Lyngsat. - """ - template_name = "mainapp/lyngsat_task_status.html" - - def get(self, request, task_id=None): - context = { - 'task_id': task_id - } - return render(request, self.template_name, context) - - -class LyngsatTaskStatusAPIView(LoginRequiredMixin, View): - """ - API для получения статуса задачи Celery. - """ - def get(self, request, task_id): - from celery.result import AsyncResult - from django.core.cache import cache - - task = AsyncResult(task_id) - - response_data = { - 'task_id': task_id, - 'state': task.state, - 'result': None, - 'error': None - } - - if task.state == 'PENDING': - response_data['status'] = 'Задача в очереди...' - elif task.state == 'PROGRESS': - response_data['status'] = task.info.get('status', '') - response_data['current'] = task.info.get('current', 0) - response_data['total'] = task.info.get('total', 1) - response_data['percent'] = int((task.info.get('current', 0) / task.info.get('total', 1)) * 100) - elif task.state == 'SUCCESS': - # Получаем результат из кеша - result = cache.get(f'lyngsat_task_{task_id}') - if result: - response_data['result'] = result - response_data['status'] = 'Задача завершена успешно' - else: - response_data['result'] = task.result - response_data['status'] = 'Задача завершена' - elif task.state == 'FAILURE': - response_data['status'] = 'Ошибка при выполнении задачи' - response_data['error'] = str(task.info) - else: - response_data['status'] = task.state - - return JsonResponse(response_data) - - -class ClearLyngsatCacheView(LoginRequiredMixin, View): - """ - Представление для очистки кеша LyngSat. - """ - def post(self, request): - from lyngsatapp.tasks import clear_cache_task - - cache_type = request.POST.get('cache_type', 'all') - - try: - # Запускаем задачу очистки кеша - task = clear_cache_task.delay(cache_type) - - messages.success( - request, - f"Задача очистки кеша ({cache_type}) запущена! ID задачи: {task.id}" - ) - except Exception as e: - messages.error(request, f"Ошибка при запуске задачи очистки кеша: {str(e)}") - - return redirect(request.META.get('HTTP_REFERER', 'mainapp:home')) - - def get(self, request): - """Страница управления кешем""" - return render(request, 'mainapp/clear_lyngsat_cache.html') +# Standard library imports +from collections import defaultdict +from io import BytesIO + +# Django imports +from django.utils import timezone +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth import logout +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.paginator import Paginator +from django.db import models +from django.db.models import F +from django.http import HttpResponse, JsonResponse +from django.shortcuts import redirect, render +from django.urls import reverse_lazy +from django.utils.decorators import method_decorator +from django.views import View +from django.views.generic import ( + CreateView, + DeleteView, + FormView, + UpdateView, +) + +# Third-party imports +import pandas as pd + +# Local imports +from .clusters import get_clusters +from .forms import ( + GeoForm, + LoadCsvData, + LoadExcelData, + NewEventForm, + ObjItemForm, + ParameterForm, + UploadFileForm, + UploadVchLoad, + VchLinkForm, + FillLyngsatDataForm, + LinkLyngsatForm, +) +from .mixins import CoordinateProcessingMixin, FormMessageMixin, RoleRequiredMixin +from .models import Geo, Modulation, ObjItem, Polarization, Satellite +from .utils import ( + add_satellite_list, + compare_and_link_vch_load, + fill_data_from_df, + get_points_from_csv, + get_vch_load_from_html, + kub_report, + parse_pagination_params, +) +from mapsapp.utils import parse_transponders_from_xml + + +class AddSatellitesView(LoginRequiredMixin, View): + def get(self, request): + add_satellite_list() + return redirect("mainapp:home") + + +# class AddTranspondersView(View): +# def get(self, request): +# try: +# parse_transponders_from_json(BASE_DIR / "transponders.json") +# except FileNotFoundError: +# print("Файл не найден") +# return redirect('home') + + +class AddTranspondersView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/transponders_upload.html" + form_class = UploadFileForm + success_message = "Файл успешно обработан" + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + uploaded_file = self.request.FILES["file"] + try: + content = uploaded_file.read() + parse_transponders_from_xml(BytesIO(content)) + except ValueError as e: + messages.error(self.request, f"Ошибка при чтении таблиц: {e}") + return redirect("mainapp:add_trans") + except Exception as e: + messages.error(self.request, f"Неизвестная ошибка: {e}") + return redirect("mainapp:add_trans") + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("mainapp:add_trans") + + +from django.views.generic import View + + +class ActionsPageView(View): + def get(self, request): + if request.user.is_authenticated: + return render(request, "mainapp/actions.html") + else: + return render(request, "mainapp/login_required.html") + + +class HomePageView(View): + def get(self, request): + if request.user.is_authenticated: + # Redirect to objitem list if authenticated + return redirect("mainapp:objitem_list") + else: + return render(request, "mainapp/login_required.html") + + +class LoadExcelDataView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/add_data_from_excel.html" + form_class = LoadExcelData + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + uploaded_file = self.request.FILES["file"] + selected_sat = form.cleaned_data["sat_choice"] + number = form.cleaned_data["number_input"] + + try: + import io + + df = pd.read_excel(io.BytesIO(uploaded_file.read())) + if number > 0: + df = df.head(number) + result = fill_data_from_df(df, selected_sat, self.request.user.customuser) + + messages.success( + self.request, f"Данные успешно загружены! Обработано строк: {result}" + ) + except Exception as e: + messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") + + return redirect("mainapp:load_excel_data") + + def get_success_url(self): + return reverse_lazy("mainapp:load_excel_data") + + +class GetLocationsView(LoginRequiredMixin, View): + def get(self, request, sat_id): + locations = ( + ObjItem.objects.filter(parameter_obj__id_satellite=sat_id) + .select_related( + "geo_obj", + "parameter_obj", + "parameter_obj__polarization", + ) + ) + + if not locations.exists(): + return JsonResponse({"error": "Объектов не найдено"}, status=404) + + features = [] + for loc in locations: + if not hasattr(loc, "geo_obj") or not loc.geo_obj or not loc.geo_obj.coords: + continue + + param = getattr(loc, 'parameter_obj', None) + if not param: + continue + + features.append( + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [loc.geo_obj.coords[0], loc.geo_obj.coords[1]], + }, + "properties": { + "pol": param.polarization.name if param.polarization else "-", + "freq": param.frequency * 1000000 if param.frequency else 0, + "name": loc.name or "-", + "id": loc.geo_obj.id, + }, + } + ) + + return JsonResponse({"type": "FeatureCollection", "features": features}) + + +class LoadCsvDataView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/add_data_from_csv.html" + form_class = LoadCsvData + success_message = "Данные успешно загружены!" + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + uploaded_file = self.request.FILES["file"] + try: + content = uploaded_file.read() + if isinstance(content, bytes): + content = content.decode("utf-8") + + get_points_from_csv(content, self.request.user.customuser) + except Exception as e: + messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") + return redirect("mainapp:load_csv_data") + + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("mainapp:load_csv_data") + + +@method_decorator(staff_member_required, name="dispatch") +class ShowMapView(RoleRequiredMixin, View): + required_roles = ["admin", "moderator"] + + def get(self, request): + ids = request.GET.get("ids", "") + points = [] + if ids: + id_list = [int(x) for x in ids.split(",") if x.isdigit()] + locations = ObjItem.objects.filter(id__in=id_list).select_related( + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + "geo_obj", + ) + for obj in locations: + if ( + not hasattr(obj, "geo_obj") + or not obj.geo_obj + or not obj.geo_obj.coords + ): + continue + param = getattr(obj, 'parameter_obj', None) + if not param: + continue + points.append( + { + "name": f"{obj.name}", + "freq": f"{param.frequency} [{param.freq_range}] МГц", + "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), + } + ) + else: + return redirect("admin") + grouped = defaultdict(list) + for p in points: + grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) + + groups = [ + {"name": name, "points": coords_list} + for name, coords_list in grouped.items() + ] + + context = { + "groups": groups, + } + return render(request, "admin/map_custom.html", context) + + +class ShowSelectedObjectsMapView(LoginRequiredMixin, View): + def get(self, request): + ids = request.GET.get("ids", "") + points = [] + if ids: + id_list = [int(x) for x in ids.split(",") if x.isdigit()] + locations = ObjItem.objects.filter(id__in=id_list).select_related( + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + "geo_obj", + ) + for obj in locations: + if ( + not hasattr(obj, "geo_obj") + or not obj.geo_obj + or not obj.geo_obj.coords + ): + continue + param = getattr(obj, 'parameter_obj', None) + if not param: + continue + points.append( + { + "name": f"{obj.name}", + "freq": f"{param.frequency} [{param.freq_range}] МГц", + "point": (obj.geo_obj.coords.x, obj.geo_obj.coords.y), + } + ) + else: + return redirect("mainapp:objitem_list") + + # Group points by object name + from collections import defaultdict + + grouped = defaultdict(list) + for p in points: + grouped[p["name"]].append({"point": p["point"], "frequency": p["freq"]}) + + groups = [ + {"name": name, "points": coords_list} + for name, coords_list in grouped.items() + ] + + context = { + "groups": groups, + } + return render(request, "mainapp/objitem_map.html", context) + + +class ClusterTestView(LoginRequiredMixin, View): + def get(self, request): + objs = ObjItem.objects.filter( + name__icontains="! Astra 4A 12654,040 [1,962] МГц H" + ) + coords = [] + for obj in objs: + if hasattr(obj, "geo_obj") and obj.geo_obj and obj.geo_obj.coords: + coords.append( + (obj.geo_obj.coords.coords[1], obj.geo_obj.coords.coords[0]) + ) + get_clusters(coords) + + return JsonResponse({"success": "ок"}) + + +def custom_logout(request): + logout(request) + return redirect("mainapp:home") + + +class UploadVchLoadView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/upload_html.html" + form_class = UploadVchLoad + success_message = "Файл успешно обработан" + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + selected_sat = form.cleaned_data["sat_choice"] + uploaded_file = self.request.FILES["file"] + try: + get_vch_load_from_html(uploaded_file, selected_sat) + except ValueError as e: + messages.error(self.request, f"Ошибка при чтении таблиц: {e}") + return redirect("mainapp:vch_load") + except Exception as e: + messages.error(self.request, f"Неизвестная ошибка: {e}") + return redirect("mainapp:vch_load") + + return super().form_valid(form) + + def get_success_url(self): + return reverse_lazy("mainapp:vch_load") + + +class LinkVchSigmaView(LoginRequiredMixin, FormView): + template_name = "mainapp/link_vch.html" + form_class = VchLinkForm + + def form_valid(self, form): + # value1 больше не используется - погрешность частоты определяется автоматически + freq_range = form.cleaned_data["value2"] + sat_id = form.cleaned_data["sat_choice"] + + # Передаём 0 для eps_freq и ku_range, так как они не используются + count_all, link_count = compare_and_link_vch_load(sat_id, 0, freq_range, 0) + + messages.success( + self.request, f"Привязано {link_count} из {count_all} объектов" + ) + return redirect("mainapp:link_vch_sigma") + + def form_invalid(self, form): + return self.render_to_response(self.get_context_data(form=form)) + + +class LinkLyngsatSourcesView(LoginRequiredMixin, FormMessageMixin, FormView): + """Представление для привязки источников LyngSat к объектам""" + template_name = "mainapp/link_lyngsat.html" + form_class = LinkLyngsatForm + success_message = "Привязка источников LyngSat завершена" + error_message = "Ошибка при привязке источников" + + def form_valid(self, form): + from lyngsatapp.models import LyngSat + + satellites = form.cleaned_data.get("satellites") + frequency_tolerance = form.cleaned_data.get("frequency_tolerance", 0.5) + + # Если спутники не выбраны, обрабатываем все + if satellites: + objitems = ObjItem.objects.filter( + parameter_obj__id_satellite__in=satellites + ).select_related('parameter_obj', 'parameter_obj__polarization') + else: + objitems = ObjItem.objects.filter( + parameter_obj__isnull=False + ).select_related('parameter_obj', 'parameter_obj__polarization') + + linked_count = 0 + total_count = objitems.count() + + for objitem in objitems: + if not hasattr(objitem, 'parameter_obj') or not objitem.parameter_obj: + continue + + param = objitem.parameter_obj + + # Округляем частоту объекта + if param.frequency: + rounded_freq = round(param.frequency, 0) # Округление до целого + + # Ищем подходящий источник LyngSat + # Сравниваем по округленной частоте и поляризации + lyngsat_sources = LyngSat.objects.filter( + id_satellite=param.id_satellite, + polarization=param.polarization, + frequency__gte=rounded_freq - frequency_tolerance, + frequency__lte=rounded_freq + frequency_tolerance + ).order_by('frequency') + + if lyngsat_sources.exists(): + # Берем первый подходящий источник + objitem.lyngsat_source = lyngsat_sources.first() + objitem.save(update_fields=['lyngsat_source']) + linked_count += 1 + + messages.success( + self.request, + f"Привязано {linked_count} из {total_count} объектов к источникам LyngSat" + ) + return redirect("mainapp:link_lyngsat") + + def form_invalid(self, form): + return self.render_to_response(self.get_context_data(form=form)) + + +class LyngsatDataAPIView(LoginRequiredMixin, View): + """API для получения данных LyngSat источника""" + + def get(self, request, lyngsat_id): + from lyngsatapp.models import LyngSat + + try: + lyngsat = LyngSat.objects.select_related( + 'id_satellite', + 'polarization', + 'modulation', + 'standard' + ).get(id=lyngsat_id) + + # Форматируем дату с учетом локального времени + last_update_str = '-' + if lyngsat.last_update: + local_time = timezone.localtime(lyngsat.last_update) + last_update_str = local_time.strftime("%d.%m.%Y") + + data = { + 'id': lyngsat.id, + 'satellite': lyngsat.id_satellite.name if lyngsat.id_satellite else '-', + 'frequency': f"{lyngsat.frequency:.3f}" if lyngsat.frequency else '-', + 'polarization': lyngsat.polarization.name if lyngsat.polarization else '-', + 'modulation': lyngsat.modulation.name if lyngsat.modulation else '-', + 'standard': lyngsat.standard.name if lyngsat.standard else '-', + 'sym_velocity': f"{lyngsat.sym_velocity:.0f}" if lyngsat.sym_velocity else '-', + 'fec': lyngsat.fec or '-', + 'channel_info': lyngsat.channel_info or '-', + 'last_update': last_update_str, + 'url': lyngsat.url or None, + } + + return JsonResponse(data) + except LyngSat.DoesNotExist: + return JsonResponse({'error': 'Источник LyngSat не найден'}, status=404) + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +class SigmaParameterDataAPIView(LoginRequiredMixin, View): + """API для получения данных SigmaParameter""" + + def get(self, request, parameter_id): + from .models import Parameter + + try: + parameter = Parameter.objects.select_related( + 'id_satellite', + 'polarization', + 'modulation', + 'standard' + ).prefetch_related( + 'sigma_parameter__mark', + 'sigma_parameter__id_satellite', + 'sigma_parameter__polarization', + 'sigma_parameter__modulation', + 'sigma_parameter__standard' + ).get(id=parameter_id) + + # Получаем все связанные SigmaParameter + sigma_params = parameter.sigma_parameter.all() + + sigma_data = [] + for sigma in sigma_params: + # Получаем отметки + marks = [] + for mark in sigma.mark.all().order_by('-timestamp'): + mark_str = '+' if mark.mark else '-' + date_str = '-' + if mark.timestamp: + local_time = timezone.localtime(mark.timestamp) + date_str = local_time.strftime("%d.%m.%Y %H:%M") + marks.append({ + 'mark': mark_str, + 'date': date_str + }) + + # Форматируем даты начала и окончания + datetime_begin_str = '-' + if sigma.datetime_begin: + local_time = timezone.localtime(sigma.datetime_begin) + datetime_begin_str = local_time.strftime("%d.%m.%Y %H:%M") + + datetime_end_str = '-' + if sigma.datetime_end: + local_time = timezone.localtime(sigma.datetime_end) + datetime_end_str = local_time.strftime("%d.%m.%Y %H:%M") + + sigma_data.append({ + 'id': sigma.id, + 'satellite': sigma.id_satellite.name if sigma.id_satellite else '-', + 'frequency': f"{sigma.frequency:.3f}" if sigma.frequency else '-', + 'transfer_frequency': f"{sigma.transfer_frequency:.3f}" if sigma.transfer_frequency else '-', + 'freq_range': f"{sigma.freq_range:.3f}" if sigma.freq_range else '-', + 'polarization': sigma.polarization.name if sigma.polarization else '-', + 'modulation': sigma.modulation.name if sigma.modulation else '-', + 'standard': sigma.standard.name if sigma.standard else '-', + 'bod_velocity': f"{sigma.bod_velocity:.0f}" if sigma.bod_velocity else '-', + 'snr': f"{sigma.snr:.1f}" if sigma.snr is not None else '-', + 'power': f"{sigma.power:.1f}" if sigma.power is not None else '-', + 'status': sigma.status or '-', + 'packets': 'Да' if sigma.packets else 'Нет' if sigma.packets is not None else '-', + 'datetime_begin': datetime_begin_str, + 'datetime_end': datetime_end_str, + 'marks': marks + }) + + return JsonResponse({ + 'parameter_id': parameter.id, + 'sigma_parameters': sigma_data + }) + except Parameter.DoesNotExist: + return JsonResponse({'error': 'Parameter не найден'}, status=404) + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +class ProcessKubsatView(LoginRequiredMixin, FormMessageMixin, FormView): + template_name = "mainapp/process_kubsat.html" + form_class = NewEventForm + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + uploaded_file = self.request.FILES["file"] + try: + content = uploaded_file.read() + df = kub_report(BytesIO(content)) + output = BytesIO() + with pd.ExcelWriter(output, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Результат") + output.seek(0) + + response = HttpResponse( + output.getvalue(), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response["Content-Disposition"] = ( + 'attachment; filename="kubsat_report.xlsx"' + ) + + messages.success(self.request, "Событие успешно обработано!") + return response + except Exception as e: + messages.error(self.request, f"Ошибка при обработке файла: {str(e)}") + return redirect("mainapp:kubsat_excel") + + +class DeleteSelectedObjectsView(RoleRequiredMixin, View): + required_roles = ["admin", "moderator"] + + def post(self, request): + ids = request.POST.get("ids", "") + if not ids: + return JsonResponse({"error": "Нет ID для удаления"}, status=400) + + try: + id_list = [int(x) for x in ids.split(",") if x.isdigit()] + deleted_count, _ = ObjItem.objects.filter(id__in=id_list).delete() + + return JsonResponse( + { + "success": True, + "message": "Объект успешно удалён", + "deleted_count": deleted_count, + } + ) + except Exception as e: + return JsonResponse({"error": f"Ошибка при удалении: {str(e)}"}, status=500) + + +class ObjItemListView(LoginRequiredMixin, View): + def get(self, request): + satellites = ( + Satellite.objects.filter(parameters__objitem__isnull=False) + .distinct() + .only("id", "name") + .order_by("name") + ) + + selected_sat_id = request.GET.get("satellite_id") + page_number, items_per_page = parse_pagination_params(request) + sort_param = request.GET.get("sort", "") + + freq_min = request.GET.get("freq_min") + freq_max = request.GET.get("freq_max") + range_min = request.GET.get("range_min") + range_max = request.GET.get("range_max") + snr_min = request.GET.get("snr_min") + snr_max = request.GET.get("snr_max") + bod_min = request.GET.get("bod_min") + bod_max = request.GET.get("bod_max") + search_query = request.GET.get("search") + selected_modulations = request.GET.getlist("modulation") + selected_polarizations = request.GET.getlist("polarization") + selected_satellites = request.GET.getlist("satellite_id") + has_kupsat = request.GET.get("has_kupsat") + has_valid = request.GET.get("has_valid") + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + + objects = ObjItem.objects.none() + + if selected_satellites or selected_sat_id: + if selected_sat_id and not selected_satellites: + try: + selected_sat_id_single = int(selected_sat_id) + selected_satellites = [selected_sat_id_single] + except ValueError: + selected_satellites = [] + + if selected_satellites: + objects = ( + ObjItem.objects.select_related( + "geo_obj", + "updated_by__user", + "created_by__user", + "lyngsat_source", + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + ) + .prefetch_related( + "parameter_obj__sigma_parameter", + "parameter_obj__sigma_parameter__polarization", + ) + .filter(parameter_obj__id_satellite_id__in=selected_satellites) + ) + else: + objects = ObjItem.objects.select_related( + "geo_obj", + "updated_by__user", + "created_by__user", + "lyngsat_source", + "parameter_obj", + "parameter_obj__id_satellite", + "parameter_obj__polarization", + "parameter_obj__modulation", + "parameter_obj__standard", + ).prefetch_related( + "parameter_obj__sigma_parameter", + "parameter_obj__sigma_parameter__polarization", + ) + + if freq_min is not None and freq_min.strip() != "": + try: + freq_min_val = float(freq_min) + objects = objects.filter( + parameter_obj__frequency__gte=freq_min_val + ) + except ValueError: + pass + if freq_max is not None and freq_max.strip() != "": + try: + freq_max_val = float(freq_max) + objects = objects.filter( + parameter_obj__frequency__lte=freq_max_val + ) + except ValueError: + pass + + if range_min is not None and range_min.strip() != "": + try: + range_min_val = float(range_min) + objects = objects.filter( + parameter_obj__freq_range__gte=range_min_val + ) + except ValueError: + pass + if range_max is not None and range_max.strip() != "": + try: + range_max_val = float(range_max) + objects = objects.filter( + parameter_obj__freq_range__lte=range_max_val + ) + except ValueError: + pass + + if snr_min is not None and snr_min.strip() != "": + try: + snr_min_val = float(snr_min) + objects = objects.filter(parameter_obj__snr__gte=snr_min_val) + except ValueError: + pass + if snr_max is not None and snr_max.strip() != "": + try: + snr_max_val = float(snr_max) + objects = objects.filter(parameter_obj__snr__lte=snr_max_val) + except ValueError: + pass + + if bod_min is not None and bod_min.strip() != "": + try: + bod_min_val = float(bod_min) + objects = objects.filter( + parameter_obj__bod_velocity__gte=bod_min_val + ) + except ValueError: + pass + if bod_max is not None and bod_max.strip() != "": + try: + bod_max_val = float(bod_max) + objects = objects.filter( + parameter_obj__bod_velocity__lte=bod_max_val + ) + except ValueError: + pass + + if selected_modulations: + objects = objects.filter( + parameter_obj__modulation__id__in=selected_modulations + ) + + if selected_polarizations: + objects = objects.filter( + parameter_obj__polarization__id__in=selected_polarizations + ) + + if has_kupsat == "1": + objects = objects.filter(geo_obj__coords_kupsat__isnull=False) + elif has_kupsat == "0": + objects = objects.filter(geo_obj__coords_kupsat__isnull=True) + + if has_valid == "1": + objects = objects.filter(geo_obj__coords_valid__isnull=False) + elif has_valid == "0": + objects = objects.filter(geo_obj__coords_valid__isnull=True) + + # Date filter for geo_obj timestamp + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + + if date_from and date_from.strip(): + try: + from datetime import datetime + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") + objects = objects.filter(geo_obj__timestamp__gte=date_from_obj) + except (ValueError, TypeError): + pass + + if date_to and date_to.strip(): + try: + from datetime import datetime, timedelta + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") + # Add one day to include the entire end date + date_to_obj = date_to_obj + timedelta(days=1) + objects = objects.filter(geo_obj__timestamp__lt=date_to_obj) + except (ValueError, TypeError): + pass + + # Filter by source type (lyngsat_source) + has_source_type = request.GET.get("has_source_type") + if has_source_type == "1": + objects = objects.filter(lyngsat_source__isnull=False) + elif has_source_type == "0": + objects = objects.filter(lyngsat_source__isnull=True) + + # Filter by sigma (sigma parameters) + has_sigma = request.GET.get("has_sigma") + if has_sigma == "1": + objects = objects.filter(parameter_obj__sigma_parameter__isnull=False) + elif has_sigma == "0": + objects = objects.filter(parameter_obj__sigma_parameter__isnull=True) + + if search_query: + search_query = search_query.strip() + if search_query: + objects = objects.filter( + models.Q(name__icontains=search_query) + | models.Q(geo_obj__location__icontains=search_query) + ) + else: + selected_sat_id = None + + objects = objects.annotate( + first_param_freq=F("parameter_obj__frequency"), + first_param_range=F("parameter_obj__freq_range"), + first_param_snr=F("parameter_obj__snr"), + first_param_bod=F("parameter_obj__bod_velocity"), + first_param_sat_name=F("parameter_obj__id_satellite__name"), + first_param_pol_name=F("parameter_obj__polarization__name"), + first_param_mod_name=F("parameter_obj__modulation__name"), + ) + + valid_sort_fields = { + "name": "name", + "-name": "-name", + "updated_at": "updated_at", + "-updated_at": "-updated_at", + "created_at": "created_at", + "-created_at": "-created_at", + "updated_by": "updated_by__user__username", + "-updated_by": "-updated_by__user__username", + "created_by": "created_by__user__username", + "-created_by": "-created_by__user__username", + "geo_timestamp": "geo_obj__timestamp", + "-geo_timestamp": "-geo_obj__timestamp", + "frequency": "first_param_freq", + "-frequency": "-first_param_freq", + "freq_range": "first_param_range", + "-freq_range": "-first_param_range", + "snr": "first_param_snr", + "-snr": "-first_param_snr", + "bod_velocity": "first_param_bod", + "-bod_velocity": "-first_param_bod", + "satellite": "first_param_sat_name", + "-satellite": "-first_param_sat_name", + "polarization": "first_param_pol_name", + "-polarization": "-first_param_pol_name", + "modulation": "first_param_mod_name", + "-modulation": "-first_param_mod_name", + } + + if sort_param in valid_sort_fields: + objects = objects.order_by(valid_sort_fields[sort_param]) + + paginator = Paginator(objects, items_per_page) + page_obj = paginator.get_page(page_number) + + processed_objects = [] + for obj in page_obj: + param = getattr(obj, 'parameter_obj', None) + + geo_coords = "-" + geo_timestamp = "-" + geo_location = "-" + kupsat_coords = "-" + valid_coords = "-" + distance_geo_kup = "-" + distance_geo_valid = "-" + distance_kup_valid = "-" + + if hasattr(obj, "geo_obj") and obj.geo_obj: + geo_timestamp = obj.geo_obj.timestamp + geo_location = obj.geo_obj.location + + if obj.geo_obj.coords: + longitude = obj.geo_obj.coords.coords[0] + latitude = obj.geo_obj.coords.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + geo_coords = f"{lat} {lon}" + + if obj.geo_obj.coords_kupsat: + longitude = obj.geo_obj.coords_kupsat.coords[0] + latitude = obj.geo_obj.coords_kupsat.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + kupsat_coords = f"{lat} {lon}" + elif obj.geo_obj.coords_kupsat is not None: + kupsat_coords = "-" + + if obj.geo_obj.coords_valid: + longitude = obj.geo_obj.coords_valid.coords[0] + latitude = obj.geo_obj.coords_valid.coords[1] + lon = f"{longitude}E" if longitude > 0 else f"{abs(longitude)}W" + lat = f"{latitude}N" if latitude > 0 else f"{abs(latitude)}S" + valid_coords = f"{lat} {lon}" + elif obj.geo_obj.coords_valid is not None: + valid_coords = "-" + + if obj.geo_obj.distance_coords_kup is not None: + distance_geo_kup = f"{obj.geo_obj.distance_coords_kup:.3f}" + + if obj.geo_obj.distance_coords_valid is not None: + distance_geo_valid = f"{obj.geo_obj.distance_coords_valid:.3f}" + + if obj.geo_obj.distance_kup_valid is not None: + distance_kup_valid = f"{obj.geo_obj.distance_kup_valid:.3f}" + + satellite_name = "-" + frequency = "-" + freq_range = "-" + polarization_name = "-" + bod_velocity = "-" + modulation_name = "-" + snr = "-" + standard_name = "-" + comment = "-" + is_average = "-" + + if param: + if hasattr(param, "id_satellite") and param.id_satellite: + satellite_name = ( + param.id_satellite.name + if hasattr(param.id_satellite, "name") + else "-" + ) + + frequency = ( + f"{param.frequency:.3f}" if param.frequency is not None else "-" + ) + freq_range = ( + f"{param.freq_range:.3f}" if param.freq_range is not None else "-" + ) + bod_velocity = ( + f"{param.bod_velocity:.0f}" + if param.bod_velocity is not None + else "-" + ) + snr = f"{param.snr:.0f}" if param.snr is not None else "-" + + if hasattr(param, "polarization") and param.polarization: + polarization_name = ( + param.polarization.name + if hasattr(param.polarization, "name") + else "-" + ) + + if hasattr(param, "modulation") and param.modulation: + modulation_name = ( + param.modulation.name + if hasattr(param.modulation, "name") + else "-" + ) + + if hasattr(param, "standard") and param.standard: + standard_name = ( + param.standard.name + if hasattr(param.standard, "name") + else "-" + ) + + if hasattr(obj, "geo_obj") and obj.geo_obj: + comment = obj.geo_obj.comment or "-" + is_average = "Да" if obj.geo_obj.is_average else "Нет" if obj.geo_obj.is_average is not None else "-" + + source_type = "ТВ" if obj.lyngsat_source else "-" + + has_sigma = False + sigma_info = "-" + if param: + sigma_count = param.sigma_parameter.count() + if sigma_count > 0: + has_sigma = True + first_sigma = param.sigma_parameter.first() + if first_sigma: + sigma_freq = f"{first_sigma.transfer_frequency:.3f}" if first_sigma.transfer_frequency else "-" + sigma_range = f"{first_sigma.freq_range:.3f}" if first_sigma.freq_range else "-" + sigma_pol = first_sigma.polarization.name if first_sigma.polarization else "-" + sigma_pol_short = sigma_pol[0] if sigma_pol and sigma_pol != "-" else "-" + sigma_info = f"{sigma_freq}/{sigma_range}/{sigma_pol_short}" + + processed_objects.append( + { + "id": obj.id, + "name": obj.name or "-", + "satellite_name": satellite_name, + "frequency": frequency, + "freq_range": freq_range, + "polarization": polarization_name, + "bod_velocity": bod_velocity, + "modulation": modulation_name, + "snr": snr, + "geo_timestamp": geo_timestamp, + "geo_location": geo_location, + "geo_coords": geo_coords, + "kupsat_coords": kupsat_coords, + "valid_coords": valid_coords, + "distance_geo_kup": distance_geo_kup, + "distance_geo_valid": distance_geo_valid, + "distance_kup_valid": distance_kup_valid, + "updated_by": obj.updated_by if obj.updated_by else "-", + "comment": comment, + "is_average": is_average, + "source_type": source_type, + "standard": standard_name, + "has_sigma": has_sigma, + "sigma_info": sigma_info, + "obj": obj, + } + ) + + modulations = Modulation.objects.all() + polarizations = Polarization.objects.all() + + # Get the new filter values + has_source_type = request.GET.get("has_source_type") + has_sigma = request.GET.get("has_sigma") + + context = { + "satellites": satellites, + "selected_satellite_id": selected_sat_id, + "page_obj": page_obj, + "processed_objects": processed_objects, + "items_per_page": items_per_page, + "available_items_per_page": [50, 100, 500, 1000], + "freq_min": freq_min, + "freq_max": freq_max, + "range_min": range_min, + "range_max": range_max, + "snr_min": snr_min, + "snr_max": snr_max, + "bod_min": bod_min, + "bod_max": bod_max, + "search_query": search_query, + "selected_modulations": [ + int(x) for x in selected_modulations if x.isdigit() + ], + "selected_polarizations": [ + int(x) for x in selected_polarizations if x.isdigit() + ], + "selected_satellites": [int(x) for x in selected_satellites if x.isdigit()], + "has_kupsat": has_kupsat, + "has_valid": has_valid, + "date_from": date_from, + "date_to": date_to, + "has_source_type": has_source_type, + "has_sigma": has_sigma, + "modulations": modulations, + "polarizations": polarizations, + "full_width_page": True, + "sort": sort_param, + } + + return render(request, "mainapp/objitem_list.html", context) + + +class ObjItemFormView( + RoleRequiredMixin, CoordinateProcessingMixin, FormMessageMixin, UpdateView +): + """ + Базовый класс для создания и редактирования ObjItem. + + Содержит общую логику обработки форм, координат и параметров. + """ + + model = ObjItem + form_class = ObjItemForm + template_name = "mainapp/objitem_form.html" + success_url = reverse_lazy("mainapp:home") + required_roles = ["admin", "moderator"] + + def get_success_url(self): + """Возвращает URL с сохраненными параметрами фильтров.""" + # Получаем все параметры из GET запроса и сохраняем их в URL + if self.request.GET: + from urllib.parse import urlencode + query_string = urlencode(self.request.GET) + return reverse_lazy("mainapp:objitem_list") + '?' + query_string + return reverse_lazy("mainapp:objitem_list") + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["LEAFLET_CONFIG"] = { + "DEFAULT_CENTER": (55.75, 37.62), + "DEFAULT_ZOOM": 5, + } + + # Сохраняем параметры возврата для кнопки "Назад" + context["return_params"] = self.request.GET.get('return_params', '') + + # Работаем с одной формой параметра вместо formset + if self.object and hasattr(self.object, "parameter_obj") and self.object.parameter_obj: + context["parameter_form"] = ParameterForm( + instance=self.object.parameter_obj, prefix="parameter" + ) + else: + context["parameter_form"] = ParameterForm(prefix="parameter") + + if self.object and hasattr(self.object, "geo_obj") and self.object.geo_obj: + context["geo_form"] = GeoForm( + instance=self.object.geo_obj, prefix="geo" + ) + else: + context["geo_form"] = GeoForm(prefix="geo") + + return context + + def form_valid(self, form): + # Получаем форму параметра + if self.object and hasattr(self.object, "parameter_obj") and self.object.parameter_obj: + parameter_form = ParameterForm( + self.request.POST, + instance=self.object.parameter_obj, + prefix="parameter" + ) + else: + parameter_form = ParameterForm(self.request.POST, prefix="parameter") + + if self.object and hasattr(self.object, "geo_obj") and self.object.geo_obj: + geo_form = GeoForm(self.request.POST, instance=self.object.geo_obj, prefix="geo") + else: + geo_form = GeoForm(self.request.POST, prefix="geo") + + # Сохраняем основной объект + self.object = form.save(commit=False) + self.set_user_fields() + self.object.save() + + # Сохраняем связанный параметр + if parameter_form.is_valid(): + self.save_parameter(parameter_form) + else: + context = self.get_context_data() + context.update({ + 'form': form, + 'parameter_form': parameter_form, + 'geo_form': geo_form, + }) + return self.render_to_response(context) + + # Сохраняем геоданные + if geo_form.is_valid(): + self.save_geo_data(geo_form) + else: + context = self.get_context_data() + context.update({ + 'form': form, + 'parameter_form': parameter_form, + 'geo_form': geo_form, + }) + return self.render_to_response(context) + + return super().form_valid(form) + + def set_user_fields(self): + """Устанавливает поля пользователя для объекта.""" + raise NotImplementedError("Subclasses must implement set_user_fields()") + + def save_parameter(self, parameter_form): + """Сохраняет параметр объекта через OneToOne связь.""" + if parameter_form.is_valid(): + instance = parameter_form.save(commit=False) + instance.objitem = self.object + instance.save() + + def save_geo_data(self, geo_form): + """Сохраняет геоданные объекта.""" + geo_instance = self.get_or_create_geo_instance() + + # Обновляем поля из geo_form + if geo_form.is_valid(): + geo_instance.location = geo_form.cleaned_data["location"] + geo_instance.comment = geo_form.cleaned_data["comment"] + geo_instance.is_average = geo_form.cleaned_data["is_average"] + + # Обрабатываем координаты + self.process_coordinates(geo_instance) + + # Обрабатываем дату/время + self.process_timestamp(geo_instance) + + geo_instance.save() + + def get_or_create_geo_instance(self): + """Получает или создает экземпляр Geo.""" + if hasattr(self.object, "geo_obj") and self.object.geo_obj: + return self.object.geo_obj + return Geo(objitem=self.object) + + +class ObjItemUpdateView(ObjItemFormView): + """Представление для редактирования ObjItem.""" + + success_message = "Объект успешно сохранён!" + + def set_user_fields(self): + self.object.updated_by = self.request.user.customuser + + +class ObjItemCreateView(ObjItemFormView, CreateView): + """Представление для создания ObjItem.""" + + success_message = "Объект успешно создан!" + + def set_user_fields(self): + self.object.created_by = self.request.user.customuser + self.object.updated_by = self.request.user.customuser + + +class ObjItemDeleteView(RoleRequiredMixin, FormMessageMixin, DeleteView): + model = ObjItem + template_name = "mainapp/objitem_confirm_delete.html" + success_url = reverse_lazy("mainapp:objitem_list") + success_message = "Объект успешно удалён!" + required_roles = ["admin", "moderator"] + + def get_success_url(self): + """Возвращает URL с сохраненными параметрами фильтров.""" + # Получаем все параметры из GET запроса и сохраняем их в URL + if self.request.GET: + from urllib.parse import urlencode + query_string = urlencode(self.request.GET) + return reverse_lazy("mainapp:objitem_list") + '?' + query_string + return reverse_lazy("mainapp:objitem_list") + + +class ObjItemDetailView(LoginRequiredMixin, View): + """ + Представление для просмотра деталей ObjItem в режиме чтения. + + Доступно для всех авторизованных пользователей, показывает данные в режиме чтения. + """ + def get(self, request, pk): + obj = ObjItem.objects.filter(pk=pk).select_related( + 'geo_obj', + 'updated_by__user', + 'created_by__user', + 'parameter_obj', + 'parameter_obj__id_satellite', + 'parameter_obj__polarization', + 'parameter_obj__modulation', + 'parameter_obj__standard', + ).first() + + if not obj: + from django.http import Http404 + raise Http404("Объект не найден") + + # Сохраняем параметры возврата для кнопки "Назад" + return_params = request.GET.get('return_params', '') + + context = { + 'object': obj, + 'return_params': return_params + } + + return render(request, "mainapp/objitem_detail.html", context) + + +class FillLyngsatDataView(LoginRequiredMixin, FormMessageMixin, FormView): + """ + Представление для заполнения данных из Lyngsat. + + Позволяет выбрать спутники и регионы для парсинга данных с сайта Lyngsat. + Запускает асинхронную задачу Celery для обработки. + """ + template_name = "mainapp/fill_lyngsat_data.html" + form_class = FillLyngsatDataForm + success_url = reverse_lazy("mainapp:lyngsat_task_status") + error_message = "Форма заполнена некорректно" + + def form_valid(self, form): + satellites = form.cleaned_data["satellites"] + regions = form.cleaned_data["regions"] + use_cache = form.cleaned_data.get("use_cache", True) + force_refresh = form.cleaned_data.get("force_refresh", False) + + # Получаем названия спутников + target_sats = [sat.name for sat in satellites] + + try: + from lyngsatapp.tasks import fill_lyngsat_data_task + + # Запускаем асинхронную задачу с параметрами кеширования + task = fill_lyngsat_data_task.delay( + target_sats, + regions, + force_refresh=force_refresh, + use_cache=use_cache + ) + + cache_status = "без кеша" if not use_cache else ("с обновлением кеша" if force_refresh else "с кешированием") + + messages.success( + self.request, + f"Задача запущена ({cache_status})! ID задачи: {task.id}. " + "Вы будете перенаправлены на страницу отслеживания прогресса." + ) + + # Перенаправляем на страницу статуса задачи + return redirect('mainapp:lyngsat_task_status', task_id=task.id) + + except Exception as e: + messages.error(self.request, f"Ошибка при запуске задачи: {str(e)}") + return redirect("mainapp:fill_lyngsat_data") + + +class LyngsatTaskStatusView(LoginRequiredMixin, View): + """ + Представление для отслеживания статуса задачи заполнения данных Lyngsat. + """ + template_name = "mainapp/lyngsat_task_status.html" + + def get(self, request, task_id=None): + context = { + 'task_id': task_id + } + return render(request, self.template_name, context) + + +class LyngsatTaskStatusAPIView(LoginRequiredMixin, View): + """ + API для получения статуса задачи Celery. + """ + def get(self, request, task_id): + from celery.result import AsyncResult + from django.core.cache import cache + + task = AsyncResult(task_id) + + response_data = { + 'task_id': task_id, + 'state': task.state, + 'result': None, + 'error': None + } + + if task.state == 'PENDING': + response_data['status'] = 'Задача в очереди...' + elif task.state == 'PROGRESS': + response_data['status'] = task.info.get('status', '') + response_data['current'] = task.info.get('current', 0) + response_data['total'] = task.info.get('total', 1) + response_data['percent'] = int((task.info.get('current', 0) / task.info.get('total', 1)) * 100) + elif task.state == 'SUCCESS': + # Получаем результат из кеша + result = cache.get(f'lyngsat_task_{task_id}') + if result: + response_data['result'] = result + response_data['status'] = 'Задача завершена успешно' + else: + response_data['result'] = task.result + response_data['status'] = 'Задача завершена' + elif task.state == 'FAILURE': + response_data['status'] = 'Ошибка при выполнении задачи' + response_data['error'] = str(task.info) + else: + response_data['status'] = task.state + + return JsonResponse(response_data) + + +class ClearLyngsatCacheView(LoginRequiredMixin, View): + """ + Представление для очистки кеша LyngSat. + """ + def post(self, request): + from lyngsatapp.tasks import clear_cache_task + + cache_type = request.POST.get('cache_type', 'all') + + try: + # Запускаем задачу очистки кеша + task = clear_cache_task.delay(cache_type) + + messages.success( + request, + f"Задача очистки кеша ({cache_type}) запущена! ID задачи: {task.id}" + ) + except Exception as e: + messages.error(request, f"Ошибка при запуске задачи очистки кеша: {str(e)}") + + return redirect(request.META.get('HTTP_REFERER', 'mainapp:home')) + + def get(self, request): + """Страница управления кешем""" + return render(request, 'mainapp/clear_lyngsat_cache.html') diff --git a/dbapp/pyproject.toml b/dbapp/pyproject.toml index 0d77a93..7df2518 100644 --- a/dbapp/pyproject.toml +++ b/dbapp/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "scikit-learn>=1.7.2", "selenium>=4.38.0", "setuptools>=80.9.0", + "uvicorn>=0.38.0", ] diff --git a/dbapp/uv.lock b/dbapp/uv.lock index 85cf6b9..24f0d4c 100644 --- a/dbapp/uv.lock +++ b/dbapp/uv.lock @@ -386,6 +386,7 @@ dependencies = [ { name = "scikit-learn" }, { name = "selenium" }, { name = "setuptools" }, + { name = "uvicorn" }, ] [package.metadata] @@ -426,6 +427,7 @@ requires-dist = [ { name = "scikit-learn", specifier = ">=1.7.2" }, { name = "selenium", specifier = ">=4.38.0" }, { name = "setuptools", specifier = ">=80.9.0" }, + { name = "uvicorn", specifier = ">=0.38.0" }, ] [package.metadata.requires-dev] @@ -1578,6 +1580,19 @@ socks = [ { name = "pysocks" }, ] +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +] + [[package]] name = "vine" version = "5.1.0" diff --git a/docker-compose.yaml b/docker-compose.yaml index be1da97..1ea5ae2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -37,41 +37,18 @@ services: - CAPTCHA_SOLVER=none networks: - app-network - - # web: - # build: - # context: ./dbapp - # dockerfile: Dockerfile - # container_name: django-app-dev + + # nginx: + # image: nginx:alpine + # container_name: nginx # restart: unless-stopped - # environment: - # - DEBUG=True - # - ENVIRONMENT=development - # - DJANGO_SETTINGS_MODULE=dbapp.settings.development - # - SECRET_KEY=django-insecure-dev-key-change-in-production - # - DB_ENGINE=django.contrib.gis.db.backends.postgis - # - DB_NAME=geodb - # - DB_USER=geralt - # - DB_PASSWORD=123456 - # - DB_HOST=db - # - DB_PORT=5432 - # - ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 # ports: - # - "8000:8000" + # - "80:80" + # # - "443:443" # volumes: - # # Монтируем только код приложения, не весь проект - # - ./dbapp/dbapp:/app/dbapp - # - ./dbapp/mainapp:/app/mainapp - # - ./dbapp/mapsapp:/app/mapsapp - # - ./dbapp/lyngsatapp:/app/lyngsatapp - # - ./dbapp/static:/app/static - # - ./dbapp/manage.py:/app/manage.py - # - static_volume_dev:/app/staticfiles - # - media_volume_dev:/app/media - # - logs_volume_dev:/app/logs - # depends_on: - # db: - # condition: service_healthy + # - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + # - ./nginx/conf.d:/etc/nginx/conf.d:ro + # - ./dbapp/staticfiles:/app/staticfiles:ro # networks: # - app-network @@ -92,9 +69,6 @@ services: volumes: postgres_data: redis_data: - # static_volume_dev: - # media_volume_dev: - # logs_volume_dev: # tileserver_config_dev: networks: diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf new file mode 100644 index 0000000..dbdfdef --- /dev/null +++ b/nginx/conf.d/default.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name localhost; + + location /static/ { + alias /home/vesemir/DataStorage/dbapp/staticfiles/; + expires 30d; + } + + location / { + proxy_pass http://host.docker.internal:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..119da59 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,39 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Log format + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer-when-downgrade" always; + add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always; + + # Proxy settings + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $server_name; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + # gzip_proxied expired no-cache no-store private must-revalidate auth; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + + # Include server blocks + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file