Compare commits
84 Commits
4f21c9d7c8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ca7709ebff | |||
| 9bf701f05a | |||
| f5875e5b87 | |||
| f79efd88e5 | |||
| cf3c7ee01a | |||
| 41e8dc30fd | |||
| 4949a03e68 | |||
| d889dc7b2a | |||
| 8393734dc3 | |||
| 25fe93231f | |||
| 8fb8b08c93 | |||
| 2b856ff6dc | |||
| cff2c73b6a | |||
| 9c095a7229 | |||
| 09bbedda18 | |||
| 727c24fb1f | |||
| 00b85b5bf2 | |||
| f954f77a6d | |||
| 027f971f5a | |||
| 30b56de709 | |||
| 24314b84ac | |||
| 4164ea2109 | |||
| 51eb5f3732 | |||
| d7d85ac834 | |||
| 118c86a73c | |||
| 3388f787c7 | |||
| 889899080a | |||
| a18071b7ec | |||
| b9e17df32c | |||
| 96f961b0f8 | |||
| ad479a2069 | |||
| 300927c7ea | |||
| 8d75e47abc | |||
| c72bf12d41 | |||
| 01871c3e13 | |||
| d521b6baad | |||
| 908e11879d | |||
| eba19126ef | |||
| 0be829b97b | |||
| 810d3a8f7f | |||
| efb99ea8d5 | |||
| bd39717e86 | |||
| d832171325 | |||
| cfaaae9360 | |||
| 27694a3a7d | |||
| 609fd5a1da | |||
| 388753ba31 | |||
| 68486d2283 | |||
| e24cf8a105 | |||
| 7879c3d9b5 | |||
| 1c18ae96f7 | |||
| a591b79656 | |||
| ed9a79f94a | |||
| 9a9900cfa6 | |||
| 0d239ef1de | |||
| 58838614a5 | |||
| c2c8c8799f | |||
| 1d1c42a8e7 | |||
| 66e1929978 | |||
| 4d7cc9f667 | |||
| c8bcd1adf0 | |||
| 55759ec705 | |||
| 06a39278d2 | |||
| c0f2f16303 | |||
| b889fb29a3 | |||
| f438e74946 | |||
| c55a41f5fe | |||
| 8994a0e500 | |||
| d9cb243388 | |||
| 9a816e62c2 | |||
| bc226bfc1a | |||
| d61236dee2 | |||
| 6a26991dc0 | |||
| 5ab6770809 | |||
| 8e0d32c307 | |||
| 122fe74e14 | |||
| d0a53e251e | |||
| 50498166e5 | |||
| a7e8f81ef3 | |||
| 7126974aed | |||
| 73ce06deec | |||
| 902eb23bd8 | |||
| 5e94086bf0 | |||
| a3c381b9c7 |
3
.env.dev
3
.env.dev
@@ -2,7 +2,8 @@
|
||||
|
||||
# Django Settings
|
||||
DEBUG=True
|
||||
ENVIRONMENT=development
|
||||
# ENVIRONMENT=development
|
||||
DJANGO_ENVIRONMENT=development
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.development
|
||||
SECRET_KEY=django-insecure-dev-key-only-for-development
|
||||
|
||||
|
||||
26
.env.prod
26
.env.prod
@@ -1,28 +1,28 @@
|
||||
# Production Environment Variables
|
||||
# ВАЖНО: Измените все значения перед деплоем!
|
||||
|
||||
# Django Settings
|
||||
DEBUG=False
|
||||
ENVIRONMENT=production
|
||||
# ENVIRONMENT=production
|
||||
DJANGO_ENVIRONMENT=production
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.production
|
||||
SECRET_KEY=change-this-to-a-very-long-random-secret-key-in-production
|
||||
SECRET_KEY=django-insecure-dev-key-only-for-production
|
||||
|
||||
# Database Configuration
|
||||
DB_ENGINE=django.contrib.gis.db.backends.postgis
|
||||
DB_NAME=geodb
|
||||
DB_USER=geralt
|
||||
DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
||||
DB_PASSWORD=123456
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Allowed Hosts (comma-separated)
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||
|
||||
# CSRF Trusted Origins (include port if using non-standard port)
|
||||
CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080
|
||||
|
||||
# PostgreSQL Configuration
|
||||
POSTGRES_DB=geodb
|
||||
POSTGRES_USER=geralt
|
||||
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
||||
POSTGRES_PASSWORD=123456
|
||||
|
||||
# Gunicorn Configuration
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_TIMEOUT=120
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://redis:6379/1
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Ensure shell scripts always use LF line endings
|
||||
*.sh text eol=lf
|
||||
entrypoint.sh text eol=lf
|
||||
|
||||
# Python files
|
||||
*.py text eol=lf
|
||||
|
||||
# Docker files
|
||||
Dockerfile text eol=lf
|
||||
docker-compose*.yaml text eol=lf
|
||||
.dockerignore text eol=lf
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -33,3 +33,5 @@ tiles
|
||||
# Docker
|
||||
# docker-*
|
||||
maplibre-gl-js-5.10.0.zip
|
||||
cert.pem
|
||||
templ.json
|
||||
@@ -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/<task_id>/` - статус конкретной задачи
|
||||
- Добавлено: `/api/lyngsat-task-status/<task_id>/` - 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/<task_id>/
|
||||
|
||||
**Ответ при выполнении:**
|
||||
```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/
|
||||
@@ -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/<task_id>/`
|
||||
|
||||
**Ответ**:
|
||||
```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/<task_id>/`
|
||||
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/)
|
||||
@@ -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. Протестировать форму заполнения данных через веб-интерфейс
|
||||
|
||||
## Примечания
|
||||
|
||||
- Процесс заполнения может занять продолжительное время (несколько минут на спутник)
|
||||
- Рекомендуется начинать с небольшого количества спутников
|
||||
- Все ошибки логируются и отображаются пользователю
|
||||
- Существующие записи обновляются, новые создаются
|
||||
@@ -1,249 +0,0 @@
|
||||
# Чеклист для деплоя в Production
|
||||
|
||||
## Перед деплоем
|
||||
|
||||
### 1. Безопасность
|
||||
|
||||
- [ ] Сгенерирован новый `SECRET_KEY`
|
||||
```bash
|
||||
python generate_secret_key.py
|
||||
```
|
||||
|
||||
- [ ] Изменены все пароли в `.env`:
|
||||
- [ ] `DB_PASSWORD` - сильный пароль для PostgreSQL
|
||||
- [ ] `POSTGRES_PASSWORD` - должен совпадать с `DB_PASSWORD`
|
||||
|
||||
- [ ] Настроен `ALLOWED_HOSTS` в `.env`:
|
||||
```
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
```
|
||||
|
||||
- [ ] `DEBUG=False` в `.env`
|
||||
|
||||
### 2. База данных
|
||||
|
||||
- [ ] Проверены все миграции:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py showmigrations
|
||||
```
|
||||
|
||||
- [ ] Настроен backup БД (cron job):
|
||||
```bash
|
||||
0 2 * * * cd /path/to/project && make backup
|
||||
```
|
||||
|
||||
### 3. Статические файлы
|
||||
|
||||
- [ ] Проверена директория для статики:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput
|
||||
```
|
||||
|
||||
### 4. SSL/HTTPS (опционально, но рекомендуется)
|
||||
|
||||
- [ ] Получены SSL сертификаты (Let's Encrypt, Certbot)
|
||||
- [ ] Сертификаты размещены в `nginx/ssl/`
|
||||
- [ ] Переименован `nginx/conf.d/ssl.conf.example` в `ssl.conf`
|
||||
- [ ] Обновлен `server_name` в `ssl.conf`
|
||||
|
||||
### 5. Nginx
|
||||
|
||||
- [ ] Проверена конфигурация Nginx:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec nginx nginx -t
|
||||
```
|
||||
|
||||
- [ ] Настроены правильные домены в `nginx/conf.d/default.conf`
|
||||
|
||||
### 6. Docker
|
||||
|
||||
- [ ] Проверен `.dockerignore` - исключены ненужные файлы
|
||||
- [ ] Проверен `.gitignore` - не коммитятся секреты
|
||||
|
||||
### 7. Переменные окружения
|
||||
|
||||
Проверьте `.env` файл:
|
||||
|
||||
```bash
|
||||
# Django
|
||||
DEBUG=False
|
||||
ENVIRONMENT=production
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.production
|
||||
SECRET_KEY=<ваш-длинный-секретный-ключ>
|
||||
|
||||
# Database
|
||||
DB_NAME=geodb
|
||||
DB_USER=geralt
|
||||
DB_PASSWORD=<сильный-пароль>
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRES_DB=geodb
|
||||
POSTGRES_USER=geralt
|
||||
POSTGRES_PASSWORD=<тот-же-сильный-пароль>
|
||||
|
||||
# Gunicorn
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_TIMEOUT=120
|
||||
```
|
||||
|
||||
## Деплой
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd <project-directory>
|
||||
```
|
||||
|
||||
### 2. Настройка окружения
|
||||
|
||||
```bash
|
||||
cp .env.prod .env
|
||||
nano .env # Отредактируйте все необходимые переменные
|
||||
```
|
||||
|
||||
### 3. Запуск контейнеров
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
```
|
||||
|
||||
### 4. Проверка статуса
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml ps
|
||||
docker-compose -f docker-compose.prod.yaml logs -f
|
||||
```
|
||||
|
||||
### 5. Создание суперпользователя
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 6. Проверка работоспособности
|
||||
|
||||
- [ ] Открыть http://yourdomain.com
|
||||
- [ ] Открыть http://yourdomain.com/admin
|
||||
- [ ] Проверить статические файлы
|
||||
- [ ] Проверить медиа файлы
|
||||
- [ ] Проверить TileServer GL: http://yourdomain.com:8080
|
||||
|
||||
## После деплоя
|
||||
|
||||
### 1. Мониторинг
|
||||
|
||||
- [ ] Настроить мониторинг логов:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml logs -f web
|
||||
```
|
||||
|
||||
- [ ] Проверить использование ресурсов:
|
||||
```bash
|
||||
docker stats
|
||||
```
|
||||
|
||||
### 2. Backup
|
||||
|
||||
- [ ] Настроить автоматический backup БД
|
||||
- [ ] Проверить восстановление из backup
|
||||
- [ ] Настроить backup медиа файлов
|
||||
|
||||
### 3. Обновления
|
||||
|
||||
- [ ] Документировать процесс обновления
|
||||
- [ ] Тестировать обновления на dev окружении
|
||||
|
||||
### 4. Безопасность
|
||||
|
||||
- [ ] Настроить firewall (UFW, iptables)
|
||||
- [ ] Ограничить доступ к портам:
|
||||
- Открыть: 80, 443
|
||||
- Закрыть: 5432, 8000 (доступ только внутри Docker сети)
|
||||
|
||||
- [ ] Настроить fail2ban (опционально)
|
||||
|
||||
### 5. Производительность
|
||||
|
||||
- [ ] Настроить кэширование (Redis, Memcached)
|
||||
- [ ] Оптимизировать количество Gunicorn workers
|
||||
- [ ] Настроить CDN для статики (опционально)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Проблема: Контейнеры не запускаются
|
||||
|
||||
```bash
|
||||
# Проверить логи
|
||||
docker-compose -f docker-compose.prod.yaml logs
|
||||
|
||||
# Проверить конфигурацию
|
||||
docker-compose -f docker-compose.prod.yaml config
|
||||
```
|
||||
|
||||
### Проблема: База данных недоступна
|
||||
|
||||
```bash
|
||||
# Проверить статус БД
|
||||
docker-compose -f docker-compose.prod.yaml exec db pg_isready -U geralt
|
||||
|
||||
# Проверить логи БД
|
||||
docker-compose -f docker-compose.prod.yaml logs db
|
||||
```
|
||||
|
||||
### Проблема: Статические файлы не загружаются
|
||||
|
||||
```bash
|
||||
# Пересобрать статику
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput
|
||||
|
||||
# Проверить права доступа
|
||||
docker-compose -f docker-compose.prod.yaml exec web ls -la /app/staticfiles
|
||||
```
|
||||
|
||||
### Проблема: 502 Bad Gateway
|
||||
|
||||
```bash
|
||||
# Проверить, что Django запущен
|
||||
docker-compose -f docker-compose.prod.yaml ps web
|
||||
|
||||
# Проверить логи Gunicorn
|
||||
docker-compose -f docker-compose.prod.yaml logs web
|
||||
|
||||
# Проверить конфигурацию Nginx
|
||||
docker-compose -f docker-compose.prod.yaml exec nginx nginx -t
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Перезапуск сервисов
|
||||
docker-compose -f docker-compose.prod.yaml restart web
|
||||
docker-compose -f docker-compose.prod.yaml restart nginx
|
||||
|
||||
# Обновление кода
|
||||
git pull
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
|
||||
# Backup БД
|
||||
docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql
|
||||
|
||||
# Восстановление БД
|
||||
docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql
|
||||
|
||||
# Просмотр логов
|
||||
docker-compose -f docker-compose.prod.yaml logs -f --tail=100 web
|
||||
|
||||
# Очистка старых образов
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
## Контакты для поддержки
|
||||
|
||||
- Документация: [DOCKER_README.md](DOCKER_README.md)
|
||||
- Быстрый старт: [QUICKSTART.md](QUICKSTART.md)
|
||||
@@ -1,102 +0,0 @@
|
||||
# Инструкция по развертыванию изменений
|
||||
|
||||
## Шаг 1: Применение миграций
|
||||
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
Это создаст таблицу `lyngsatapp_lyngsat` в базе данных.
|
||||
|
||||
## Шаг 2: Запуск FlareSolver (если еще не запущен)
|
||||
|
||||
FlareSolver необходим для обхода защиты Cloudflare на сайте Lyngsat.
|
||||
|
||||
### Вариант 1: Docker
|
||||
```bash
|
||||
docker run -d -p 8191:8191 --name flaresolverr ghcr.io/flaresolverr/flaresolverr:latest
|
||||
```
|
||||
|
||||
### Вариант 2: Docker Compose
|
||||
Добавьте в `docker-compose.yaml`:
|
||||
```yaml
|
||||
services:
|
||||
flaresolverr:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
container_name: flaresolverr
|
||||
ports:
|
||||
- "8191:8191"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Затем запустите:
|
||||
```bash
|
||||
docker-compose up -d flaresolverr
|
||||
```
|
||||
|
||||
## Шаг 3: Проверка работоспособности
|
||||
|
||||
1. Запустите сервер разработки:
|
||||
```bash
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
2. Откройте браузер и перейдите на:
|
||||
```
|
||||
http://localhost:8000/actions/
|
||||
```
|
||||
|
||||
3. Найдите карточку "Заполнение данных Lyngsat" и нажмите на кнопку
|
||||
|
||||
4. Выберите один-два спутника для тестирования
|
||||
|
||||
5. Выберите регионы (например, только Europe)
|
||||
|
||||
6. Нажмите "Заполнить данные" и дождитесь завершения
|
||||
|
||||
## Шаг 4: Проверка результатов
|
||||
|
||||
1. Перейдите в админ-панель Django:
|
||||
```
|
||||
http://localhost:8000/admin/
|
||||
```
|
||||
|
||||
2. Откройте раздел "Lyngsatapp" → "Источники LyngSat"
|
||||
|
||||
3. Проверьте, что данные загружены корректно
|
||||
|
||||
## Возможные проблемы и решения
|
||||
|
||||
### Проблема: FlareSolver не отвечает
|
||||
**Решение**: Проверьте, что FlareSolver запущен:
|
||||
```bash
|
||||
curl http://localhost:8191/v1
|
||||
```
|
||||
|
||||
### Проблема: Спутники не найдены в базе
|
||||
**Решение**: Убедитесь, что спутники добавлены в базу данных. Используйте функцию "Добавление списка спутников" на странице действий.
|
||||
|
||||
### Проблема: Долгое выполнение
|
||||
**Решение**: Это нормально. Процесс может занять несколько минут на спутник. Начните с 1-2 спутников для тестирования.
|
||||
|
||||
### Проблема: Ошибки при парсинге
|
||||
**Решение**: Проверьте логи. Некоторые ошибки (например, некорректные частоты) не критичны и не прерывают процесс.
|
||||
|
||||
## Откат изменений (если необходимо)
|
||||
|
||||
Если нужно откатить изменения:
|
||||
|
||||
```bash
|
||||
# Откатить миграцию
|
||||
python manage.py migrate lyngsatapp zero
|
||||
|
||||
# Откатить изменения в коде
|
||||
git checkout HEAD -- dbapp/
|
||||
```
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
- Подробное руководство пользователя: `LYNGSAT_FILL_GUIDE.md`
|
||||
- Сводка изменений: `CHANGES_SUMMARY.md`
|
||||
- Документация по проекту: `README.md`
|
||||
262
DOCKER_README.md
262
DOCKER_README.md
@@ -1,262 +0,0 @@
|
||||
# Docker Setup для Django + PostGIS + TileServer GL
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── dbapp/ # Django приложение
|
||||
│ ├── Dockerfile # Универсальный Dockerfile
|
||||
│ ├── entrypoint.sh # Скрипт запуска
|
||||
│ └── ...
|
||||
├── nginx/ # Конфигурация Nginx (только для prod)
|
||||
│ └── conf.d/
|
||||
│ └── default.conf
|
||||
├── tiles/ # Тайлы для TileServer GL
|
||||
├── docker-compose.yaml # Development окружение
|
||||
├── docker-compose.prod.yaml # Production окружение
|
||||
├── .env.dev # Переменные для development
|
||||
└── .env.prod # Переменные для production
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Development
|
||||
|
||||
1. Скопируйте файл окружения:
|
||||
```bash
|
||||
cp .env.dev .env
|
||||
```
|
||||
|
||||
2. Запустите контейнеры:
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
3. Создайте суперпользователя:
|
||||
```bash
|
||||
docker-compose exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
4. Приложение доступно:
|
||||
- Django: http://localhost:8000
|
||||
- TileServer GL: http://localhost:8080
|
||||
- PostgreSQL: localhost:5432
|
||||
|
||||
### Production
|
||||
|
||||
1. Скопируйте и настройте файл окружения:
|
||||
```bash
|
||||
cp .env.prod .env
|
||||
# Отредактируйте .env и измените SECRET_KEY, пароли и ALLOWED_HOSTS
|
||||
```
|
||||
|
||||
2. Запустите контейнеры:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
```
|
||||
|
||||
3. Создайте суперпользователя:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
4. Приложение доступно:
|
||||
- Nginx: http://localhost (порт 80)
|
||||
- Django (напрямую): http://localhost:8000
|
||||
- TileServer GL: http://localhost:8080
|
||||
- PostgreSQL: localhost:5432
|
||||
|
||||
## Основные команды
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Запуск
|
||||
docker-compose up -d
|
||||
|
||||
# Остановка
|
||||
docker-compose down
|
||||
|
||||
# Просмотр логов
|
||||
docker-compose logs -f web
|
||||
|
||||
# Выполнение команд Django
|
||||
docker-compose exec web python manage.py migrate
|
||||
docker-compose exec web python manage.py createsuperuser
|
||||
docker-compose exec web python manage.py shell
|
||||
|
||||
# Пересборка после изменений в Dockerfile
|
||||
docker-compose up -d --build
|
||||
|
||||
# Полная очистка (включая volumes)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Запуск
|
||||
docker-compose -f docker-compose.prod.yaml up -d
|
||||
|
||||
# Остановка
|
||||
docker-compose -f docker-compose.prod.yaml down
|
||||
|
||||
# Просмотр логов
|
||||
docker-compose -f docker-compose.prod.yaml logs -f web
|
||||
|
||||
# Выполнение команд Django
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
||||
|
||||
# Пересборка
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
```
|
||||
|
||||
## Различия между Dev и Prod
|
||||
|
||||
### Development
|
||||
- Django development server (runserver)
|
||||
- DEBUG=True
|
||||
- Код монтируется как volume (изменения применяются сразу)
|
||||
- Без Nginx
|
||||
- Простые пароли (для локальной разработки)
|
||||
|
||||
### Production
|
||||
- Gunicorn WSGI server
|
||||
- DEBUG=False
|
||||
- Код копируется в образ (не монтируется)
|
||||
- Nginx как reverse proxy
|
||||
- Сильные пароли и SECRET_KEY
|
||||
- Сбор статики (collectstatic)
|
||||
- Оптимизированные настройки безопасности
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
### Основные переменные (.env)
|
||||
|
||||
```bash
|
||||
# Django
|
||||
DEBUG=True/False
|
||||
ENVIRONMENT=development/production
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.development/production
|
||||
SECRET_KEY=your-secret-key
|
||||
|
||||
# Database
|
||||
DB_NAME=geodb
|
||||
DB_USER=geralt
|
||||
DB_PASSWORD=your-password
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
|
||||
|
||||
# Gunicorn (только для production)
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_TIMEOUT=120
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
||||
### Development
|
||||
- `postgres_data_dev` - данные PostgreSQL
|
||||
- `static_volume_dev` - статические файлы
|
||||
- `media_volume_dev` - медиа файлы
|
||||
- `logs_volume_dev` - логи
|
||||
- `./dbapp:/app` - код приложения (live reload)
|
||||
|
||||
### Production
|
||||
- `postgres_data_prod` - данные PostgreSQL
|
||||
- `static_volume_prod` - статические файлы
|
||||
- `media_volume_prod` - медиа файлы
|
||||
- `logs_volume_prod` - логи
|
||||
|
||||
## TileServer GL
|
||||
|
||||
Для работы TileServer GL поместите ваши тайлы в директорию `./tiles/`.
|
||||
|
||||
Пример структуры:
|
||||
```
|
||||
tiles/
|
||||
├── config.json
|
||||
└── your-tiles.mbtiles
|
||||
```
|
||||
|
||||
## Backup и восстановление БД
|
||||
|
||||
### Backup
|
||||
```bash
|
||||
# Development
|
||||
docker-compose exec db pg_dump -U geralt geodb > backup.sql
|
||||
|
||||
# Production
|
||||
docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql
|
||||
```
|
||||
|
||||
### Восстановление
|
||||
```bash
|
||||
# Development
|
||||
docker-compose exec -T db psql -U geralt geodb < backup.sql
|
||||
|
||||
# Production
|
||||
docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Проблемы с миграциями
|
||||
```bash
|
||||
docker-compose exec web python manage.py migrate --fake-initial
|
||||
```
|
||||
|
||||
### Проблемы с правами доступа
|
||||
```bash
|
||||
docker-compose exec -u root web chown -R app:app /app
|
||||
```
|
||||
|
||||
### Очистка всех данных
|
||||
```bash
|
||||
docker-compose down -v
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
### Проверка логов
|
||||
```bash
|
||||
# Все сервисы
|
||||
docker-compose logs -f
|
||||
|
||||
# Конкретный сервис
|
||||
docker-compose logs -f web
|
||||
docker-compose logs -f db
|
||||
```
|
||||
|
||||
## Безопасность для Production
|
||||
|
||||
1. **Измените SECRET_KEY** - используйте длинный случайный ключ
|
||||
2. **Измените пароли БД** - используйте сильные пароли
|
||||
3. **Настройте ALLOWED_HOSTS** - укажите ваш домен
|
||||
4. **Настройте SSL** - добавьте сертификаты в `nginx/ssl/`
|
||||
5. **Ограничьте доступ к портам** - не открывайте порты БД наружу
|
||||
|
||||
## Генерация SECRET_KEY
|
||||
|
||||
```python
|
||||
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||
```
|
||||
|
||||
## Мониторинг
|
||||
|
||||
### Проверка статуса контейнеров
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### Использование ресурсов
|
||||
```bash
|
||||
docker stats
|
||||
```
|
||||
|
||||
### Healthcheck
|
||||
```bash
|
||||
curl http://localhost:8000/admin/
|
||||
```
|
||||
307
DOCKER_SETUP.md
307
DOCKER_SETUP.md
@@ -1,307 +0,0 @@
|
||||
# Docker Setup - Полное руководство
|
||||
|
||||
## 📋 Обзор
|
||||
|
||||
Этот проект использует Docker для развертывания Django приложения с PostGIS и TileServer GL.
|
||||
|
||||
**Основные компоненты:**
|
||||
- Django 5.2 с PostGIS
|
||||
- PostgreSQL 17 с расширением PostGIS 3.4
|
||||
- TileServer GL для работы с картографическими тайлами
|
||||
- Nginx (только для production)
|
||||
- Gunicorn WSGI сервер (production)
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Development
|
||||
```bash
|
||||
cp .env.dev .env
|
||||
make dev-up
|
||||
make createsuperuser
|
||||
```
|
||||
Откройте http://localhost:8000
|
||||
|
||||
### Production
|
||||
```bash
|
||||
cp .env.prod .env
|
||||
# Отредактируйте .env (SECRET_KEY, пароли, домены)
|
||||
make prod-up
|
||||
make prod-createsuperuser
|
||||
```
|
||||
Откройте http://yourdomain.com
|
||||
|
||||
## 📁 Структура файлов
|
||||
|
||||
```
|
||||
.
|
||||
├── dbapp/ # Django приложение
|
||||
│ ├── Dockerfile # Универсальный Dockerfile
|
||||
│ ├── entrypoint.sh # Скрипт инициализации
|
||||
│ ├── .dockerignore # Исключения для Docker
|
||||
│ └── ...
|
||||
│
|
||||
├── nginx/ # Nginx конфигурация (prod)
|
||||
│ ├── conf.d/
|
||||
│ │ ├── default.conf # HTTP конфигурация
|
||||
│ │ └── ssl.conf.example # HTTPS конфигурация (пример)
|
||||
│ └── ssl/ # SSL сертификаты
|
||||
│
|
||||
├── tiles/ # Тайлы для TileServer GL
|
||||
│ ├── README.md # Инструкция по настройке
|
||||
│ ├── config.json.example # Пример конфигурации
|
||||
│ └── .gitignore
|
||||
│
|
||||
├── docker-compose.yaml # Development окружение
|
||||
├── docker-compose.prod.yaml # Production окружение
|
||||
│
|
||||
├── .env.dev # Переменные для dev
|
||||
├── .env.prod # Переменные для prod (шаблон)
|
||||
│
|
||||
├── Makefile # Удобные команды
|
||||
├── generate_secret_key.py # Генератор SECRET_KEY
|
||||
│
|
||||
└── Документация:
|
||||
├── QUICKSTART.md # Быстрый старт
|
||||
├── DOCKER_README.md # Подробная документация
|
||||
├── DEPLOYMENT_CHECKLIST.md # Чеклист для деплоя
|
||||
└── DOCKER_SETUP.md # Этот файл
|
||||
```
|
||||
|
||||
## 🔧 Конфигурация
|
||||
|
||||
### Dockerfile
|
||||
|
||||
**Один универсальный Dockerfile** для dev и prod:
|
||||
- Multi-stage build для оптимизации размера
|
||||
- Установка GDAL, PostGIS зависимостей
|
||||
- Использование uv для управления зависимостями
|
||||
- Non-root пользователь для безопасности
|
||||
- Healthcheck для мониторинга
|
||||
|
||||
### entrypoint.sh
|
||||
|
||||
Скрипт автоматически:
|
||||
- Ждет готовности PostgreSQL
|
||||
- Выполняет миграции
|
||||
- Собирает статику (только prod)
|
||||
- Запускает runserver (dev) или Gunicorn (prod)
|
||||
|
||||
Поведение определяется переменной `ENVIRONMENT`:
|
||||
- `development` → Django development server
|
||||
- `production` → Gunicorn WSGI server
|
||||
|
||||
### docker-compose.yaml (Development)
|
||||
|
||||
**Сервисы:**
|
||||
- `db` - PostgreSQL с PostGIS
|
||||
- `web` - Django приложение
|
||||
- `tileserver` - TileServer GL
|
||||
|
||||
**Особенности:**
|
||||
- Код монтируется как volume (live reload)
|
||||
- DEBUG=True
|
||||
- Django development server
|
||||
- Простые пароли для локальной разработки
|
||||
|
||||
### docker-compose.prod.yaml (Production)
|
||||
|
||||
**Сервисы:**
|
||||
- `db` - PostgreSQL с PostGIS
|
||||
- `web` - Django с Gunicorn
|
||||
- `tileserver` - TileServer GL
|
||||
- `nginx` - Reverse proxy
|
||||
|
||||
**Особенности:**
|
||||
- Код копируется в образ (не монтируется)
|
||||
- DEBUG=False
|
||||
- Gunicorn WSGI server
|
||||
- Nginx для статики и проксирования
|
||||
- Сильные пароли из .env
|
||||
- Сбор статики (collectstatic)
|
||||
|
||||
## 🔐 Безопасность
|
||||
|
||||
### Для Production обязательно:
|
||||
|
||||
1. **Сгенерируйте SECRET_KEY:**
|
||||
```bash
|
||||
python generate_secret_key.py
|
||||
```
|
||||
|
||||
2. **Измените пароли БД** в `.env`
|
||||
|
||||
3. **Настройте ALLOWED_HOSTS:**
|
||||
```
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
```
|
||||
|
||||
4. **Настройте SSL/HTTPS** (рекомендуется):
|
||||
- Получите сертификаты (Let's Encrypt)
|
||||
- Поместите в `nginx/ssl/`
|
||||
- Используйте `nginx/conf.d/ssl.conf.example`
|
||||
|
||||
5. **Ограничьте доступ к портам:**
|
||||
- Открыть: 80, 443
|
||||
- Закрыть: 5432, 8000
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Логи
|
||||
```bash
|
||||
# Development
|
||||
make dev-logs
|
||||
|
||||
# Production
|
||||
make prod-logs
|
||||
|
||||
# Конкретный сервис
|
||||
docker-compose logs -f web
|
||||
docker-compose logs -f db
|
||||
```
|
||||
|
||||
### Статус
|
||||
```bash
|
||||
make status # Development
|
||||
make prod-status # Production
|
||||
docker stats # Использование ресурсов
|
||||
```
|
||||
|
||||
### Healthcheck
|
||||
```bash
|
||||
curl http://localhost:8000/admin/
|
||||
```
|
||||
|
||||
## 💾 Backup и восстановление
|
||||
|
||||
### Backup
|
||||
```bash
|
||||
make backup
|
||||
# или
|
||||
docker-compose exec db pg_dump -U geralt geodb > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Восстановление
|
||||
```bash
|
||||
docker-compose exec -T db psql -U geralt geodb < backup.sql
|
||||
```
|
||||
|
||||
### Автоматический backup (cron)
|
||||
```bash
|
||||
# Добавьте в crontab
|
||||
0 2 * * * cd /path/to/project && make backup
|
||||
```
|
||||
|
||||
## 🔄 Обновление
|
||||
|
||||
### Development
|
||||
```bash
|
||||
git pull
|
||||
make dev-build
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
git pull
|
||||
make prod-build
|
||||
make prod-migrate
|
||||
```
|
||||
|
||||
## 🗺️ TileServer GL
|
||||
|
||||
Поместите `.mbtiles` файлы в директорию `tiles/`:
|
||||
|
||||
```bash
|
||||
tiles/
|
||||
├── world.mbtiles
|
||||
└── satellite.mbtiles
|
||||
```
|
||||
|
||||
Доступ: http://localhost:8080
|
||||
|
||||
Подробнее: [tiles/README.md](tiles/README.md)
|
||||
|
||||
## 🛠️ Makefile команды
|
||||
|
||||
### Development
|
||||
```bash
|
||||
make dev-up # Запустить
|
||||
make dev-down # Остановить
|
||||
make dev-build # Пересобрать
|
||||
make dev-logs # Логи
|
||||
make dev-restart # Перезапустить web
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
make prod-up # Запустить
|
||||
make prod-down # Остановить
|
||||
make prod-build # Пересобрать
|
||||
make prod-logs # Логи
|
||||
make prod-restart # Перезапустить web
|
||||
```
|
||||
|
||||
### Django
|
||||
```bash
|
||||
make shell # Django shell
|
||||
make migrate # Миграции
|
||||
make makemigrations # Создать миграции
|
||||
make createsuperuser # Создать суперпользователя
|
||||
make collectstatic # Собрать статику
|
||||
```
|
||||
|
||||
### Утилиты
|
||||
```bash
|
||||
make backup # Backup БД
|
||||
make status # Статус контейнеров
|
||||
make clean # Очистка (с volumes)
|
||||
make clean-all # Полная очистка
|
||||
```
|
||||
|
||||
## 📚 Дополнительная документация
|
||||
|
||||
- **[QUICKSTART.md](QUICKSTART.md)** - Быстрый старт для нетерпеливых
|
||||
- **[DOCKER_README.md](DOCKER_README.md)** - Подробная документация по Docker
|
||||
- **[DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md)** - Чеклист для деплоя
|
||||
- **[tiles/README.md](tiles/README.md)** - Настройка TileServer GL
|
||||
|
||||
## ❓ Troubleshooting
|
||||
|
||||
### Контейнеры не запускаются
|
||||
```bash
|
||||
docker-compose logs
|
||||
docker-compose config
|
||||
```
|
||||
|
||||
### База данных недоступна
|
||||
```bash
|
||||
docker-compose exec db pg_isready -U geralt
|
||||
docker-compose logs db
|
||||
```
|
||||
|
||||
### Статические файлы не загружаются
|
||||
```bash
|
||||
docker-compose exec web python manage.py collectstatic --noinput
|
||||
docker-compose exec web ls -la /app/staticfiles
|
||||
```
|
||||
|
||||
### 502 Bad Gateway
|
||||
```bash
|
||||
docker-compose ps web
|
||||
docker-compose logs web
|
||||
docker-compose exec nginx nginx -t
|
||||
```
|
||||
|
||||
## 🎯 Следующие шаги
|
||||
|
||||
1. ✅ Прочитайте [QUICKSTART.md](QUICKSTART.md)
|
||||
2. ✅ Запустите development окружение
|
||||
3. ✅ Изучите [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) перед деплоем
|
||||
4. ✅ Настройте TileServer GL ([tiles/README.md](tiles/README.md))
|
||||
5. ✅ Настройте SSL для production
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
При возникновении проблем:
|
||||
1. Проверьте логи: `make dev-logs` или `make prod-logs`
|
||||
2. Изучите документацию в этой директории
|
||||
3. Проверьте [DOCKER_README.md](DOCKER_README.md) для подробностей
|
||||
@@ -1,240 +0,0 @@
|
||||
# Обзор созданных файлов Docker Setup
|
||||
|
||||
## 🐳 Docker файлы
|
||||
|
||||
### `dbapp/Dockerfile`
|
||||
**Универсальный Dockerfile** для dev и prod окружений.
|
||||
- Multi-stage build для оптимизации
|
||||
- Установка GDAL, PostGIS, PostgreSQL клиента
|
||||
- Использование uv для управления зависимостями
|
||||
- Non-root пользователь для безопасности
|
||||
- Healthcheck для мониторинга
|
||||
|
||||
### `dbapp/entrypoint.sh`
|
||||
**Скрипт инициализации контейнера.**
|
||||
- Ожидание готовности PostgreSQL
|
||||
- Автоматические миграции
|
||||
- Сбор статики (только prod)
|
||||
- Запуск runserver (dev) или Gunicorn (prod)
|
||||
|
||||
### `dbapp/.dockerignore`
|
||||
**Исключения для Docker build.**
|
||||
- Исключает ненужные файлы из образа
|
||||
- Уменьшает размер образа
|
||||
- Ускоряет сборку
|
||||
|
||||
## 🔧 Docker Compose файлы
|
||||
|
||||
### `docker-compose.yaml`
|
||||
**Development окружение.**
|
||||
- PostgreSQL с PostGIS
|
||||
- Django с development server
|
||||
- TileServer GL
|
||||
- Код монтируется как volume (live reload)
|
||||
- DEBUG=True
|
||||
|
||||
### `docker-compose.prod.yaml`
|
||||
**Production окружение.**
|
||||
- PostgreSQL с PostGIS
|
||||
- Django с Gunicorn
|
||||
- TileServer GL
|
||||
- Nginx reverse proxy
|
||||
- Код копируется в образ
|
||||
- DEBUG=False
|
||||
- Оптимизированные настройки
|
||||
|
||||
## 🌐 Nginx конфигурация
|
||||
|
||||
### `nginx/conf.d/default.conf`
|
||||
**HTTP конфигурация для production.**
|
||||
- Проксирование к Django
|
||||
- Раздача статики и медиа
|
||||
- Оптимизированные таймауты
|
||||
- Кэширование статики
|
||||
|
||||
### `nginx/conf.d/ssl.conf.example`
|
||||
**HTTPS конфигурация (пример).**
|
||||
- SSL/TLS настройки
|
||||
- Редирект с HTTP на HTTPS
|
||||
- Security headers
|
||||
- Оптимизированные SSL параметры
|
||||
|
||||
### `nginx/ssl/.gitkeep`
|
||||
**Директория для SSL сертификатов.**
|
||||
- Поместите сюда fullchain.pem и privkey.pem
|
||||
|
||||
## 🗺️ TileServer GL
|
||||
|
||||
### `tiles/README.md`
|
||||
**Инструкция по настройке TileServer GL.**
|
||||
- Как добавить тайлы
|
||||
- Примеры конфигурации
|
||||
- Использование в Django/Leaflet
|
||||
- Где взять тайлы
|
||||
|
||||
### `tiles/config.json.example`
|
||||
**Пример конфигурации TileServer GL.**
|
||||
- Настройки путей
|
||||
- Форматы и качество
|
||||
- Домены
|
||||
|
||||
### `tiles/.gitignore`
|
||||
**Исключения для git.**
|
||||
- Игнорирует большие .mbtiles файлы
|
||||
- Сохраняет примеры конфигурации
|
||||
|
||||
## 🔐 Переменные окружения
|
||||
|
||||
### `.env.dev`
|
||||
**Переменные для development.**
|
||||
- DEBUG=True
|
||||
- Простые пароли для локальной разработки
|
||||
- Настройки БД для dev
|
||||
|
||||
### `.env.prod`
|
||||
**Шаблон переменных для production.**
|
||||
- DEBUG=False
|
||||
- Требует изменения SECRET_KEY и паролей
|
||||
- Настройки для production
|
||||
|
||||
## 🛠️ Утилиты
|
||||
|
||||
### `Makefile`
|
||||
**Удобные команды для работы с Docker.**
|
||||
- `make dev-up` - запуск dev
|
||||
- `make prod-up` - запуск prod
|
||||
- `make migrate` - миграции
|
||||
- `make backup` - backup БД
|
||||
- И многое другое
|
||||
|
||||
### `generate_secret_key.py`
|
||||
**Генератор Django SECRET_KEY.**
|
||||
```bash
|
||||
python generate_secret_key.py
|
||||
```
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
### `QUICKSTART.md`
|
||||
**Быстрый старт.**
|
||||
- Минимальные команды для запуска
|
||||
- Development и Production
|
||||
- Основные команды
|
||||
|
||||
### `DOCKER_README.md`
|
||||
**Подробная документация.**
|
||||
- Полное описание структуры
|
||||
- Все команды с примерами
|
||||
- Troubleshooting
|
||||
- Backup и восстановление
|
||||
|
||||
### `DOCKER_SETUP.md`
|
||||
**Полное руководство.**
|
||||
- Обзор всей системы
|
||||
- Конфигурация
|
||||
- Безопасность
|
||||
- Мониторинг
|
||||
|
||||
### `DEPLOYMENT_CHECKLIST.md`
|
||||
**Чеклист для деплоя.**
|
||||
- Пошаговая инструкция
|
||||
- Проверка безопасности
|
||||
- Настройка production
|
||||
- Troubleshooting
|
||||
|
||||
### `FILES_OVERVIEW.md`
|
||||
**Этот файл.**
|
||||
- Описание всех созданных файлов
|
||||
- Назначение каждого файла
|
||||
|
||||
## 📝 Обновленные файлы
|
||||
|
||||
### `.gitignore`
|
||||
**Обновлен для Docker.**
|
||||
- Исключает .env файлы
|
||||
- Исключает логи и backup
|
||||
- Исключает временные файлы
|
||||
|
||||
## 🎯 Как использовать
|
||||
|
||||
### Для начала работы:
|
||||
1. Прочитайте **QUICKSTART.md**
|
||||
2. Выберите окружение (dev или prod)
|
||||
3. Скопируйте соответствующий .env файл
|
||||
4. Запустите с помощью Makefile
|
||||
|
||||
### Для деплоя:
|
||||
1. Прочитайте **DEPLOYMENT_CHECKLIST.md**
|
||||
2. Следуйте чеклисту пошагово
|
||||
3. Используйте **DOCKER_README.md** для справки
|
||||
|
||||
### Для настройки TileServer:
|
||||
1. Прочитайте **tiles/README.md**
|
||||
2. Добавьте .mbtiles файлы
|
||||
3. Настройте config.json (опционально)
|
||||
|
||||
## 📊 Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── Docker конфигурация
|
||||
│ ├── dbapp/Dockerfile
|
||||
│ ├── dbapp/entrypoint.sh
|
||||
│ ├── dbapp/.dockerignore
|
||||
│ ├── docker-compose.yaml
|
||||
│ └── docker-compose.prod.yaml
|
||||
│
|
||||
├── Nginx
|
||||
│ ├── nginx/conf.d/default.conf
|
||||
│ ├── nginx/conf.d/ssl.conf.example
|
||||
│ └── nginx/ssl/.gitkeep
|
||||
│
|
||||
├── TileServer GL
|
||||
│ ├── tiles/README.md
|
||||
│ ├── tiles/config.json.example
|
||||
│ └── tiles/.gitignore
|
||||
│
|
||||
├── Переменные окружения
|
||||
│ ├── .env.dev
|
||||
│ └── .env.prod
|
||||
│
|
||||
├── Утилиты
|
||||
│ ├── Makefile
|
||||
│ └── generate_secret_key.py
|
||||
│
|
||||
└── Документация
|
||||
├── QUICKSTART.md
|
||||
├── DOCKER_README.md
|
||||
├── DOCKER_SETUP.md
|
||||
├── DEPLOYMENT_CHECKLIST.md
|
||||
└── FILES_OVERVIEW.md
|
||||
```
|
||||
|
||||
## ✅ Что было сделано
|
||||
|
||||
1. ✅ Создан универсальный Dockerfile (один для dev и prod)
|
||||
2. ✅ Настроен entrypoint.sh с автоматической инициализацией
|
||||
3. ✅ Созданы docker-compose.yaml для dev и prod
|
||||
4. ✅ Настроен Nginx для production
|
||||
5. ✅ Добавлена поддержка TileServer GL
|
||||
6. ✅ Созданы .env файлы для разных окружений
|
||||
7. ✅ Добавлен Makefile с удобными командами
|
||||
8. ✅ Написана подробная документация
|
||||
9. ✅ Создан чеклист для деплоя
|
||||
10. ✅ Добавлены утилиты (генератор SECRET_KEY)
|
||||
|
||||
## 🚀 Следующие шаги
|
||||
|
||||
1. Запустите development окружение
|
||||
2. Протестируйте все функции
|
||||
3. Подготовьте production окружение
|
||||
4. Следуйте DEPLOYMENT_CHECKLIST.md
|
||||
5. Настройте мониторинг и backup
|
||||
|
||||
## 💡 Полезные ссылки
|
||||
|
||||
- Django Documentation: https://docs.djangoproject.com/
|
||||
- Docker Documentation: https://docs.docker.com/
|
||||
- PostGIS Documentation: https://postgis.net/documentation/
|
||||
- TileServer GL: https://github.com/maptiler/tileserver-gl
|
||||
- Nginx Documentation: https://nginx.org/en/docs/
|
||||
@@ -1,347 +0,0 @@
|
||||
# Руководство по установке асинхронной системы Lyngsat
|
||||
|
||||
## Вариант 1: Полная установка с Celery (рекомендуется)
|
||||
|
||||
### Шаг 1: Установка зависимостей
|
||||
|
||||
```bash
|
||||
pip install -r dbapp/requirements.txt
|
||||
```
|
||||
|
||||
Это установит:
|
||||
- `celery>=5.4.0`
|
||||
- `django-celery-results>=2.5.1`
|
||||
- И все остальные зависимости
|
||||
|
||||
### Шаг 2: Применение миграций
|
||||
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
Это создаст:
|
||||
- Таблицу `lyngsatapp_lyngsat` для данных Lyngsat
|
||||
- Таблицы `django_celery_results_*` для результатов Celery
|
||||
|
||||
### Шаг 3: Запуск сервисов
|
||||
|
||||
```bash
|
||||
# Запуск Redis и FlareSolver
|
||||
docker-compose up -d redis flaresolverr
|
||||
|
||||
# Проверка
|
||||
redis-cli ping # Должно вернуть PONG
|
||||
curl http://localhost:8191/v1 # Должно вернуть JSON
|
||||
```
|
||||
|
||||
### Шаг 4: Запуск приложения
|
||||
|
||||
**Терминал 1 - Django:**
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
**Терминал 2 - Celery Worker:**
|
||||
```bash
|
||||
cd dbapp
|
||||
celery -A dbapp worker --loglevel=info
|
||||
```
|
||||
|
||||
### Шаг 5: Тестирование
|
||||
|
||||
1. Откройте `http://localhost:8000/actions/`
|
||||
2. Нажмите "Заполнить данные Lyngsat"
|
||||
3. Выберите спутники и регионы
|
||||
4. Наблюдайте за прогрессом!
|
||||
|
||||
---
|
||||
|
||||
## Вариант 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
|
||||
python dbapp/manage.py check
|
||||
# Должно вывести: System check identified no issues (0 silenced).
|
||||
```
|
||||
|
||||
### Проверка Celery (если установлен)
|
||||
```bash
|
||||
celery -A dbapp inspect ping
|
||||
# Должно вывести: pong
|
||||
```
|
||||
|
||||
### Проверка Redis (если установлен)
|
||||
```bash
|
||||
redis-cli ping
|
||||
# Должно вывести: PONG
|
||||
```
|
||||
|
||||
### Проверка FlareSolver
|
||||
```bash
|
||||
curl http://localhost:8191/v1
|
||||
# Должно вернуть JSON с информацией о сервисе
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Решение проблем при установке
|
||||
|
||||
### Проблема: 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 не отвечает
|
||||
|
||||
**Решение**: Запустите FlareSolver
|
||||
```bash
|
||||
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
|
||||
- Настройте логирование
|
||||
- Настройте алерты
|
||||
|
||||
---
|
||||
|
||||
## Дополнительные инструменты
|
||||
|
||||
### Flower - мониторинг Celery
|
||||
|
||||
```bash
|
||||
pip install flower
|
||||
celery -A dbapp flower
|
||||
# Откройте http://localhost:5555
|
||||
```
|
||||
|
||||
### Redis Commander - GUI для Redis
|
||||
|
||||
```bash
|
||||
docker run -d -p 8081:8081 --name redis-commander \
|
||||
--env REDIS_HOSTS=local:localhost:6379 \
|
||||
rediscommander/redis-commander
|
||||
# Откройте http://localhost:8081
|
||||
```
|
||||
|
||||
### pgAdmin - GUI для PostgreSQL
|
||||
|
||||
```bash
|
||||
docker run -d -p 5050:80 --name pgadmin \
|
||||
-e PGADMIN_DEFAULT_EMAIL=admin@admin.com \
|
||||
-e PGADMIN_DEFAULT_PASSWORD=admin \
|
||||
dpage/pgadmin4
|
||||
# Откройте http://localhost:5050
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Обновление системы
|
||||
|
||||
### Обновление зависимостей
|
||||
|
||||
```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 <PID>
|
||||
# Запустите снова
|
||||
celery -A dbapp worker --loglevel=info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Удаление системы
|
||||
|
||||
### Остановка сервисов
|
||||
|
||||
```bash
|
||||
# Остановка Docker контейнеров
|
||||
docker-compose down
|
||||
|
||||
# Остановка Celery Worker
|
||||
pkill -f "celery worker"
|
||||
```
|
||||
|
||||
### Удаление данных
|
||||
|
||||
```bash
|
||||
# Удаление Docker volumes
|
||||
docker-compose down -v
|
||||
|
||||
# Удаление виртуального окружения
|
||||
rm -rf dbapp/.venv
|
||||
|
||||
# Удаление миграций (опционально)
|
||||
find dbapp -path "*/migrations/*.py" -not -name "__init__.py" -delete
|
||||
find dbapp -path "*/migrations/*.pyc" -delete
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Поддержка
|
||||
|
||||
Если у вас возникли проблемы:
|
||||
|
||||
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 в репозитории с описанием проблемы и логами
|
||||
@@ -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` (заменена карточка с картами)
|
||||
|
||||
## Примечания
|
||||
|
||||
- Процесс может занять продолжительное время в зависимости от количества выбранных спутников
|
||||
- Рекомендуется выбирать небольшое количество спутников для первого запуска
|
||||
- Существующие записи будут обновлены, новые - созданы
|
||||
- Все ошибки логируются и отображаются пользователю
|
||||
38
Makefile
38
Makefile
@@ -2,14 +2,26 @@
|
||||
|
||||
help:
|
||||
@echo "Доступные команды:"
|
||||
@echo ""
|
||||
@echo "Development:"
|
||||
@echo " make dev-up - Запустить development окружение"
|
||||
@echo " make dev-down - Остановить development окружение"
|
||||
@echo " make dev-build - Пересобрать development контейнеры"
|
||||
@echo " make dev-logs - Показать логи development"
|
||||
@echo ""
|
||||
@echo "Production:"
|
||||
@echo " make prod-up - Запустить production окружение"
|
||||
@echo " make prod-down - Остановить production окружение"
|
||||
@echo " make prod-build - Пересобрать production контейнеры"
|
||||
@echo " make prod-logs - Показать логи production"
|
||||
@echo ""
|
||||
@echo "Celery (Production):"
|
||||
@echo " make prod-worker-logs - Логи Celery worker"
|
||||
@echo " make prod-beat-logs - Логи Celery beat"
|
||||
@echo " make prod-celery-status - Статус Celery"
|
||||
@echo " make prod-celery-test - Тест Celery подключения"
|
||||
@echo ""
|
||||
@echo "Django:"
|
||||
@echo " make shell - Открыть Django shell"
|
||||
@echo " make migrate - Выполнить миграции"
|
||||
@echo " make createsuperuser - Создать суперпользователя"
|
||||
@@ -97,3 +109,29 @@ status:
|
||||
|
||||
prod-status:
|
||||
docker-compose -f docker-compose.prod.yaml ps
|
||||
|
||||
# Celery команды для production
|
||||
prod-worker-logs:
|
||||
docker-compose -f docker-compose.prod.yaml logs -f worker
|
||||
|
||||
prod-beat-logs:
|
||||
docker-compose -f docker-compose.prod.yaml logs -f beat
|
||||
|
||||
prod-celery-status:
|
||||
docker-compose -f docker-compose.prod.yaml exec web uv run celery -A dbapp inspect active
|
||||
|
||||
prod-celery-test:
|
||||
docker-compose -f docker-compose.prod.yaml exec web uv run python test_celery.py
|
||||
|
||||
prod-redis-test:
|
||||
docker-compose -f docker-compose.prod.yaml exec web uv run python check_redis.py
|
||||
|
||||
# Celery команды для development
|
||||
celery-status:
|
||||
cd dbapp && uv run celery -A dbapp inspect active
|
||||
|
||||
celery-test:
|
||||
cd dbapp && uv run python test_celery.py
|
||||
|
||||
redis-test:
|
||||
cd dbapp && uv run python check_redis.py
|
||||
|
||||
106
QUICKSTART.md
106
QUICKSTART.md
@@ -1,106 +0,0 @@
|
||||
# Быстрый старт с Docker
|
||||
|
||||
## Development (разработка)
|
||||
|
||||
```bash
|
||||
# 1. Скопировать переменные окружения
|
||||
cp .env.dev .env
|
||||
|
||||
# 2. Запустить контейнеры
|
||||
make dev-up
|
||||
# или
|
||||
docker-compose up -d --build
|
||||
|
||||
# 3. Создать суперпользователя
|
||||
make createsuperuser
|
||||
# или
|
||||
docker-compose exec web python manage.py createsuperuser
|
||||
|
||||
# 4. Открыть в браузере
|
||||
# Django: http://localhost:8000
|
||||
# Admin: http://localhost:8000/admin
|
||||
# TileServer: http://localhost:8080
|
||||
```
|
||||
|
||||
## Production (продакшн)
|
||||
|
||||
```bash
|
||||
# 1. Скопировать и настроить переменные
|
||||
cp .env.prod .env
|
||||
nano .env # Измените SECRET_KEY, пароли, ALLOWED_HOSTS
|
||||
|
||||
# 2. Запустить контейнеры
|
||||
make prod-up
|
||||
# или
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
|
||||
# 3. Создать суперпользователя
|
||||
make prod-createsuperuser
|
||||
# или
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
||||
|
||||
# 4. Открыть в браузере
|
||||
# Nginx: http://localhost
|
||||
# Django: http://localhost:8000
|
||||
# TileServer: http://localhost:8080
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Просмотр логов
|
||||
make dev-logs # development
|
||||
make prod-logs # production
|
||||
|
||||
# Остановка
|
||||
make dev-down # development
|
||||
make prod-down # production
|
||||
|
||||
# Перезапуск после изменений
|
||||
make dev-build # development
|
||||
make prod-build # production
|
||||
|
||||
# Django shell
|
||||
make shell # development
|
||||
make prod-shell # production
|
||||
|
||||
# Миграции
|
||||
make migrate # development
|
||||
make prod-migrate # production
|
||||
|
||||
# Backup БД
|
||||
make backup
|
||||
|
||||
# Статус контейнеров
|
||||
make status # development
|
||||
make prod-status # production
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── dbapp/ # Django приложение
|
||||
│ ├── Dockerfile # Универсальный Dockerfile
|
||||
│ ├── entrypoint.sh # Скрипт запуска
|
||||
│ ├── manage.py
|
||||
│ └── ...
|
||||
├── nginx/ # Nginx (только prod)
|
||||
│ └── conf.d/
|
||||
│ └── default.conf
|
||||
├── tiles/ # Тайлы для TileServer GL
|
||||
│ ├── README.md
|
||||
│ └── config.json.example
|
||||
├── docker-compose.yaml # Development
|
||||
├── docker-compose.prod.yaml # Production
|
||||
├── .env.dev # Переменные dev
|
||||
├── .env.prod # Переменные prod
|
||||
├── Makefile # Команды для удобства
|
||||
└── DOCKER_README.md # Подробная документация
|
||||
```
|
||||
|
||||
## Что дальше?
|
||||
|
||||
1. Прочитайте [DOCKER_README.md](DOCKER_README.md) для подробной информации
|
||||
2. Настройте TileServer GL - см. [tiles/README.md](tiles/README.md)
|
||||
3. Для production настройте SSL сертификаты в `nginx/ssl/`
|
||||
@@ -1,117 +0,0 @@
|
||||
# Быстрый старт: Асинхронное заполнение данных Lyngsat
|
||||
|
||||
## Минимальная настройка (5 минут)
|
||||
|
||||
### 1. Установите зависимости
|
||||
```bash
|
||||
pip install -r dbapp/requirements.txt
|
||||
```
|
||||
|
||||
### 2. Примените миграции
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### 3. Запустите необходимые сервисы
|
||||
|
||||
**Терминал 1 - Redis и FlareSolver:**
|
||||
```bash
|
||||
docker-compose up -d redis flaresolverr
|
||||
```
|
||||
|
||||
**Терминал 2 - Django:**
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
**Терминал 3 - Celery Worker:**
|
||||
```bash
|
||||
cd dbapp
|
||||
celery -A dbapp worker --loglevel=info
|
||||
```
|
||||
|
||||
### 4. Используйте систему
|
||||
|
||||
1. Откройте браузер: `http://localhost:8000/actions/`
|
||||
2. Нажмите "Заполнить данные Lyngsat"
|
||||
3. Выберите 1-2 спутника для теста
|
||||
4. Выберите регион (например, Europe)
|
||||
5. Нажмите "Заполнить данные"
|
||||
6. Наблюдайте за прогрессом в реальном времени!
|
||||
|
||||
## Проверка работоспособности
|
||||
|
||||
### Redis
|
||||
```bash
|
||||
redis-cli ping
|
||||
# Должно вернуть: PONG
|
||||
```
|
||||
|
||||
### FlareSolver
|
||||
```bash
|
||||
curl http://localhost:8191/v1
|
||||
# Должно вернуть JSON с информацией о сервисе
|
||||
```
|
||||
|
||||
### Celery Worker
|
||||
Проверьте вывод в терминале 3 - должны быть сообщения:
|
||||
```
|
||||
[2024-01-15 10:30:00,000: INFO/MainProcess] Connected to redis://localhost:6379/0
|
||||
[2024-01-15 10:30:00,000: INFO/MainProcess] celery@hostname ready.
|
||||
```
|
||||
|
||||
## Остановка сервисов
|
||||
|
||||
```bash
|
||||
# Остановить Docker контейнеры
|
||||
docker-compose down
|
||||
|
||||
# Остановить Django (Ctrl+C в терминале 2)
|
||||
|
||||
# Остановить Celery Worker (Ctrl+C в терминале 3)
|
||||
```
|
||||
|
||||
## Просмотр логов
|
||||
|
||||
```bash
|
||||
# Логи Celery Worker (если запущен с --logfile)
|
||||
tail -f dbapp/logs/celery_worker.log
|
||||
|
||||
# Логи Docker контейнеров
|
||||
docker-compose logs -f redis
|
||||
docker-compose logs -f flaresolverr
|
||||
```
|
||||
|
||||
## Что дальше?
|
||||
|
||||
- Прочитайте полную документацию: `ASYNC_LYNGSAT_GUIDE.md`
|
||||
- Настройте production окружение
|
||||
- Добавьте периодические задачи
|
||||
- Настройте email уведомления
|
||||
|
||||
## Решение проблем
|
||||
|
||||
**Worker не запускается:**
|
||||
```bash
|
||||
# Проверьте Redis
|
||||
redis-cli ping
|
||||
|
||||
# Проверьте переменные окружения
|
||||
echo $CELERY_BROKER_URL
|
||||
```
|
||||
|
||||
**Задача не выполняется:**
|
||||
```bash
|
||||
# Проверьте FlareSolver
|
||||
curl http://localhost:8191/v1
|
||||
|
||||
# Проверьте логи worker
|
||||
tail -f dbapp/logs/celery_worker.log
|
||||
```
|
||||
|
||||
**Прогресс не обновляется:**
|
||||
- Откройте консоль браузера (F12)
|
||||
- Проверьте Network tab на наличие ошибок
|
||||
- Обновите страницу
|
||||
@@ -1,217 +0,0 @@
|
||||
# Celery Setup and Testing Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Make sure you have Redis running (it's already configured in your docker-compose.yaml):
|
||||
|
||||
```bash
|
||||
# Start Redis and other services
|
||||
cd /home/vesemir/DataStorage
|
||||
docker-compose up -d redis
|
||||
```
|
||||
|
||||
## Installing Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Database Setup
|
||||
|
||||
Since we're using django-celery-results and celery-beat, you need to run migrations:
|
||||
|
||||
```bash
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
This will create the necessary tables for storing Celery results and managing periodic tasks.
|
||||
|
||||
## Running Celery
|
||||
|
||||
### 1. Start Celery Worker
|
||||
|
||||
```bash
|
||||
# From the dbapp directory
|
||||
cd /home/vesemir/DataStorage/dbapp
|
||||
|
||||
# Run with development settings
|
||||
python -m celery -A dbapp worker --loglevel=info
|
||||
|
||||
# Or with environment variable
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.development celery -A dbapp worker --loglevel=info
|
||||
```
|
||||
|
||||
### 2. Start Celery Beat (for periodic tasks)
|
||||
|
||||
```bash
|
||||
# From the dbapp directory
|
||||
cd /home/vesemir/DataStorage/dbapp
|
||||
|
||||
# Run with development settings
|
||||
python -m celery -A dbapp beat --loglevel=info
|
||||
|
||||
# Or with environment variable
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.development celery -A dbapp beat --loglevel=info
|
||||
```
|
||||
|
||||
### 3. Start Flower (Optional - for monitoring)
|
||||
|
||||
```bash
|
||||
# Install flower if not already installed
|
||||
pip install flower
|
||||
|
||||
# Run flower to monitor tasks
|
||||
celery -A dbapp flower
|
||||
```
|
||||
|
||||
## Testing Celery
|
||||
|
||||
### Method 1: Using Django Shell
|
||||
|
||||
```bash
|
||||
cd /home/vesemir/DataStorage/dbapp
|
||||
python manage.py shell
|
||||
```
|
||||
|
||||
```python
|
||||
# In the Django shell
|
||||
from mainapp.tasks import test_celery_connection, add_numbers
|
||||
from lyngsatapp.tasks import fill_lyngsat_data_task
|
||||
|
||||
# Test simple connection
|
||||
result = test_celery_connection.delay("Test message!")
|
||||
print(result.id) # Task ID
|
||||
print(result.get(timeout=10)) # Wait for result and print
|
||||
|
||||
# Test addition
|
||||
result = add_numbers.delay(5, 7)
|
||||
print(result.get(timeout=10))
|
||||
|
||||
# Check task state
|
||||
print(result.state) # Should be 'SUCCESS'
|
||||
print(result.ready()) # Should be True
|
||||
print(result.successful()) # Should be True
|
||||
```
|
||||
|
||||
### Method 2: Using Django Management Command
|
||||
|
||||
Create a management command to test:
|
||||
|
||||
```bash
|
||||
mkdir -p dbapp/management/commands
|
||||
```
|
||||
|
||||
Create `/home/vesemir/DataStorage/dbapp/dbapp/management/commands/test_celery.py`:
|
||||
|
||||
```python
|
||||
from django.core.management.base import BaseCommand
|
||||
from mainapp.tasks import test_celery_connection, add_numbers
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Test Celery functionality'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write('Testing Celery connection...')
|
||||
|
||||
# Test simple task
|
||||
result = test_celery_connection.delay("Hello from test command!")
|
||||
self.stdout.write(f'Task ID: {result.id}')
|
||||
|
||||
# Wait for result
|
||||
task_result = result.get(timeout=10)
|
||||
self.stdout.write(self.style.SUCCESS(f'Task result: {task_result}'))
|
||||
|
||||
# Test math task
|
||||
math_result = add_numbers.delay(10, 20)
|
||||
sum_result = math_result.get(timeout=10)
|
||||
self.stdout.write(self.style.SUCCESS(f'10 + 20 = {sum_result}'))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('All tests passed!'))
|
||||
```
|
||||
|
||||
Then run:
|
||||
```bash
|
||||
python manage.py test_celery
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Connection Error with Redis**: Make sure Redis is running
|
||||
```bash
|
||||
docker-compose up -d redis
|
||||
```
|
||||
|
||||
2. **Module Not Found Errors**: Ensure all dependencies are installed
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Settings Module Error**: Make sure DJANGO_SETTINGS_MODULE is set properly
|
||||
```bash
|
||||
export DJANGO_SETTINGS_MODULE=dbapp.settings.development
|
||||
```
|
||||
|
||||
4. **Database Tables Missing**: Run migrations
|
||||
```bash
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Check if Celery can connect to Redis:
|
||||
|
||||
```bash
|
||||
# Test Redis connection
|
||||
redis-cli ping
|
||||
```
|
||||
|
||||
Check Celery configuration:
|
||||
```python
|
||||
# In Django shell
|
||||
from django.conf import settings
|
||||
print(settings.CELERY_BROKER_URL)
|
||||
print(settings.CELERY_RESULT_BACKEND)
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Make sure your `.env` file contains:
|
||||
|
||||
```
|
||||
CELERY_BROKER_URL=redis://localhost:6379/0
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.development
|
||||
```
|
||||
|
||||
## Running in Production
|
||||
|
||||
For production, ensure you have:
|
||||
|
||||
1. A production Redis instance
|
||||
2. Proper security settings
|
||||
3. Daemonized Celery workers
|
||||
|
||||
Example systemd service file for Celery worker (save as `/etc/systemd/system/celery.service`):
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=Celery Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=forking
|
||||
User=www-data
|
||||
Group=www-data
|
||||
EnvironmentFile=/path/to/your/.env
|
||||
WorkingDirectory=/home/vesemir/DataStorage/dbapp
|
||||
ExecStart=/path/to/your/venv/bin/celery -A dbapp worker --loglevel=info --pidfile=/var/run/celery/worker.pid --logfile=/var/log/celery/worker.log
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
KillSignal=SIGTERM
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
@@ -1,57 +1,53 @@
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.13.7-slim AS builder
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gdal-bin \
|
||||
libgdal-dev \
|
||||
proj-bin \
|
||||
proj-data \
|
||||
libproj-dev \
|
||||
libproj25 \
|
||||
libgeos-dev \
|
||||
libgeos-c1v5 \
|
||||
# Устанавливаем системные библиотеки для GIS, Postgres, сборки пакетов
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
postgresql-client \
|
||||
gdal-bin libgdal-dev \
|
||||
libproj-dev proj-bin \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
netcat-openbsd \
|
||||
gcc \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade pip
|
||||
RUN pip install --upgrade pip
|
||||
# Устанавливаем uv пакетно-менеджер глобально
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
# Copy requirements file
|
||||
COPY requirements.txt ./
|
||||
# Копируем зависимости
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Синхронизируем зависимости (включая prod + dev), чтобы билдить
|
||||
RUN uv sync --locked
|
||||
|
||||
# Copy project files
|
||||
# Копируем весь код приложения
|
||||
COPY . .
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /app/staticfiles /app/logs /app/media
|
||||
# --- рантайм-стадия — минимальный образ для продакшена ---
|
||||
FROM python:3.13.7-slim
|
||||
|
||||
# Set permissions for entrypoint
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd --create-home --shell /bin/bash app && \
|
||||
chown -R app:app /app
|
||||
# Устанавливаем только runtime-системные библиотеки
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gdal-bin \
|
||||
libproj-dev proj-bin \
|
||||
libpq5 \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER app
|
||||
# Копируем всё из билдера
|
||||
COPY --from=builder /usr/local/lib/python3.13 /usr/local/lib/python3.13
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# Загружаем переменные окружения из .env (см. docker-compose)
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PATH="/usr/local/bin:$PATH"
|
||||
|
||||
# Делаем entrypoint скрипты исполняемыми
|
||||
RUN chmod +x /app/entrypoint.sh /app/entrypoint-celery.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run entrypoint script
|
||||
# Используем entrypoint для инициализации (миграции, статика)
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
96
dbapp/check_redis.py
Normal file
96
dbapp/check_redis.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Скрипт для проверки подключения к Redis.
|
||||
Запуск: python check_redis.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import redis
|
||||
except ImportError:
|
||||
print("❌ Redis библиотека не установлена")
|
||||
print("Установите: pip install redis")
|
||||
sys.exit(1)
|
||||
|
||||
def check_redis():
|
||||
"""Проверка подключения к Redis"""
|
||||
print("=" * 60)
|
||||
print("ПРОВЕРКА REDIS")
|
||||
print("=" * 60)
|
||||
|
||||
# Получаем URL из переменных окружения
|
||||
broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
cache_url = os.getenv("REDIS_URL", "redis://localhost:6379/1")
|
||||
|
||||
print(f"\n1. Broker URL: {broker_url}")
|
||||
print(f"2. Cache URL: {cache_url}")
|
||||
|
||||
# Проверка broker (database 0)
|
||||
print("\n3. Проверка Celery Broker (db 0)...")
|
||||
try:
|
||||
r_broker = redis.from_url(broker_url)
|
||||
r_broker.ping()
|
||||
print(" ✓ Подключение успешно")
|
||||
|
||||
# Проверка ключей
|
||||
keys = r_broker.keys("*")
|
||||
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||
|
||||
# Проверка очереди celery
|
||||
queue_length = r_broker.llen("celery")
|
||||
print(f" ✓ Задач в очереди 'celery': {queue_length}")
|
||||
|
||||
except redis.ConnectionError as e:
|
||||
print(f" ✗ Ошибка подключения: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ✗ Ошибка: {e}")
|
||||
return False
|
||||
|
||||
# Проверка cache (database 1)
|
||||
print("\n4. Проверка Django Cache (db 1)...")
|
||||
try:
|
||||
r_cache = redis.from_url(cache_url)
|
||||
r_cache.ping()
|
||||
print(" ✓ Подключение успешно")
|
||||
|
||||
# Проверка ключей
|
||||
keys = r_cache.keys("*")
|
||||
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||
|
||||
except redis.ConnectionError as e:
|
||||
print(f" ✗ Ошибка подключения: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ✗ Ошибка: {e}")
|
||||
return False
|
||||
|
||||
# Тест записи/чтения
|
||||
print("\n5. Тест записи/чтения...")
|
||||
try:
|
||||
test_key = "test:celery:connection"
|
||||
test_value = "OK"
|
||||
|
||||
r_broker.set(test_key, test_value, ex=10) # TTL 10 секунд
|
||||
result = r_broker.get(test_key)
|
||||
|
||||
if result and result.decode() == test_value:
|
||||
print(f" ✓ Запись/чтение работает")
|
||||
r_broker.delete(test_key)
|
||||
else:
|
||||
print(f" ✗ Ошибка: ожидалось '{test_value}', получено '{result}'")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Ошибка: {e}")
|
||||
return False
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = check_redis()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -4,18 +4,12 @@ Celery configuration for dbapp project.
|
||||
import os
|
||||
from celery import Celery
|
||||
|
||||
# Use the environment variable to determine the settings module
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', os.getenv('DJANGO_SETTINGS_MODULE', 'dbapp.settings.development'))
|
||||
|
||||
app = Celery('dbapp')
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -175,8 +175,8 @@ USE_TZ = True
|
||||
# ============================================================================
|
||||
|
||||
LOGIN_URL = "login"
|
||||
LOGIN_REDIRECT_URL = "mainapp:home"
|
||||
LOGOUT_REDIRECT_URL = "mainapp:home"
|
||||
LOGIN_REDIRECT_URL = "mainapp:source_list"
|
||||
LOGOUT_REDIRECT_URL = "mainapp:source_list"
|
||||
|
||||
# ============================================================================
|
||||
# STATIC FILES CONFIGURATION
|
||||
@@ -197,6 +197,8 @@ STATICFILES_DIRS = [
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
|
||||
|
||||
# ============================================================================
|
||||
# THIRD-PARTY APP CONFIGURATION
|
||||
# ============================================================================
|
||||
@@ -225,13 +227,38 @@ LEAFLET_CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CACHE CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Redis Cache Configuration
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": os.getenv("REDIS_URL", "redis://localhost:6379/1"),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"CONNECTION_POOL_CLASS_KWARGS": {
|
||||
"max_connections": 50,
|
||||
"retry_on_timeout": True,
|
||||
},
|
||||
"SOCKET_CONNECT_TIMEOUT": 5,
|
||||
"SOCKET_TIMEOUT": 5,
|
||||
},
|
||||
"KEY_PREFIX": "dbapp",
|
||||
"TIMEOUT": 300, # Default timeout 5 minutes
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# CELERY CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Celery Configuration Options
|
||||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0") # Use Redis for results
|
||||
CELERY_RESULT_BACKEND = os.getenv(
|
||||
"CELERY_BROKER_URL", "redis://localhost:6379/0"
|
||||
) # Use Redis for results
|
||||
CELERY_CACHE_BACKEND = "default"
|
||||
|
||||
# Celery Task Configuration
|
||||
@@ -257,3 +284,7 @@ CELERY_ACCEPT_CONTENT = ["json"]
|
||||
CELERY_TASK_SERIALIZER = "json"
|
||||
CELERY_RESULT_SERIALIZER = "json"
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
|
||||
# Celery Exception Handling
|
||||
CELERY_TASK_IGNORE_RESULT = False
|
||||
CELERY_TASK_STORE_ERRORS_EVEN_IF_IGNORED = True
|
||||
|
||||
@@ -46,3 +46,10 @@ INTERNAL_IPS = [
|
||||
|
||||
# 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"
|
||||
@@ -19,23 +19,29 @@ DEBUG = False
|
||||
# In production, specify allowed hosts explicitly from environment variable
|
||||
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||
|
||||
# CSRF trusted origins (required for forms to work behind proxy)
|
||||
CSRF_TRUSTED_ORIGINS = os.getenv(
|
||||
"CSRF_TRUSTED_ORIGINS",
|
||||
"http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080"
|
||||
).split(",")
|
||||
|
||||
# ============================================================================
|
||||
# SECURITY SETTINGS
|
||||
# ============================================================================
|
||||
|
||||
# SSL/HTTPS settings
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
# SSL/HTTPS settings (disable for local testing without SSL)
|
||||
SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "False") == "True"
|
||||
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "False") == "True"
|
||||
CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "False") == "True"
|
||||
|
||||
# Security headers
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
|
||||
# HSTS settings
|
||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
# HSTS settings (disable for local testing)
|
||||
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv("SECURE_HSTS_INCLUDE_SUBDOMAINS", "False") == "True"
|
||||
SECURE_HSTS_PRELOAD = os.getenv("SECURE_HSTS_PRELOAD", "False") == "True"
|
||||
|
||||
# Additional security settings
|
||||
SECURE_REDIRECT_EXEMPT = []
|
||||
@@ -51,7 +57,7 @@ TEMPLATES = [
|
||||
"DIRS": [
|
||||
BASE_DIR / "templates",
|
||||
],
|
||||
"APP_DIRS": True,
|
||||
"APP_DIRS": False,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
@@ -82,6 +88,13 @@ STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesSto
|
||||
# ============================================================================
|
||||
# LOGGING CONFIGURATION
|
||||
# ============================================================================
|
||||
LOGS_DIR = BASE_DIR.parent / "logs"
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ============================================================================
|
||||
# CELERY LOGGING CONFIGURATION
|
||||
# ============================================================================
|
||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
@@ -110,7 +123,13 @@ LOGGING = {
|
||||
"file": {
|
||||
"level": "ERROR",
|
||||
"class": "logging.FileHandler",
|
||||
"filename": BASE_DIR.parent / "logs" / "django_errors.log",
|
||||
"filename": LOGS_DIR / "django_errors.log",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
"celery_file": {
|
||||
"level": "INFO",
|
||||
"class": "logging.FileHandler",
|
||||
"filename": LOGS_DIR / "celery.log",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
"mail_admins": {
|
||||
@@ -131,5 +150,24 @@ LOGGING = {
|
||||
"level": "ERROR",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery": {
|
||||
"handlers": ["console", "celery_file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.task": {
|
||||
"handlers": ["console", "celery_file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.worker": {
|
||||
"handlers": ["console", "celery_file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Force Celery to log to stdout for Docker
|
||||
CELERY_WORKER_REDIRECT_STDOUTS = True
|
||||
CELERY_WORKER_REDIRECT_STDOUTS_LEVEL = "INFO"
|
||||
|
||||
@@ -14,17 +14,23 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from mainapp import views
|
||||
from mainapp.views import custom_logout
|
||||
from django.contrib.auth import views as auth_views
|
||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls, name='admin'),
|
||||
path('', include('mainapp.urls', namespace='mainapp')),
|
||||
path('', include('mapsapp.urls', namespace='mapsapp')),
|
||||
path('lyngsat/', include('lyngsatapp.urls', namespace='lyngsatapp')),
|
||||
# Authentication URLs
|
||||
path('login/', auth_views.LoginView.as_view(), name='login'),
|
||||
path('logout/', views.custom_logout, name='logout'),
|
||||
] + debug_toolbar_urls()
|
||||
path('logout/', custom_logout, name='logout'),
|
||||
]
|
||||
|
||||
# Only include debug toolbar in development
|
||||
if settings.DEBUG:
|
||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
||||
urlpatterns += debug_toolbar_urls()
|
||||
|
||||
26
dbapp/entrypoint-celery.sh
Normal file
26
dbapp/entrypoint-celery.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting Celery Worker..."
|
||||
|
||||
# Ждем PostgreSQL
|
||||
echo "Waiting for PostgreSQL..."
|
||||
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL started"
|
||||
|
||||
# Ждем Redis (проверяем через Python, т.к. redis-cli не установлен)
|
||||
echo "Waiting for Redis..."
|
||||
until uv run python -c "import redis; r = redis.from_url('${CELERY_BROKER_URL}'); r.ping()" 2>/dev/null; do
|
||||
echo "Redis is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
echo "Redis started"
|
||||
|
||||
# Создаем директорию для логов
|
||||
mkdir -p /app/logs
|
||||
|
||||
# Запускаем команду (celery worker или beat)
|
||||
exec "$@"
|
||||
25
dbapp/entrypoint.sh
Executable file → Normal file
25
dbapp/entrypoint.sh
Executable file → Normal file
@@ -6,32 +6,35 @@ ENVIRONMENT=${ENVIRONMENT:-production}
|
||||
|
||||
echo "Starting in $ENVIRONMENT mode..."
|
||||
|
||||
# Ждем PostgreSQL
|
||||
if [ -d "logs" ]; then
|
||||
echo "Directory logs already exists."
|
||||
else
|
||||
echo "Creating logs directory..."
|
||||
mkdir -p logs
|
||||
fi
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
while ! nc -z $DB_HOST $DB_PORT; do
|
||||
sleep 0.1
|
||||
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL started"
|
||||
|
||||
# Выполняем миграции
|
||||
echo "Running migrations..."
|
||||
python manage.py migrate --noinput
|
||||
uv run python manage.py migrate --noinput
|
||||
|
||||
# Собираем статику (только для production)
|
||||
if [ "$ENVIRONMENT" = "production" ]; then
|
||||
echo "Collecting static files..."
|
||||
python manage.py collectstatic --noinput
|
||||
uv run python manage.py collectstatic --noinput
|
||||
fi
|
||||
|
||||
# Запускаем сервер в зависимости от окружения
|
||||
if [ "$ENVIRONMENT" = "development" ]; then
|
||||
echo "Starting Django development server..."
|
||||
exec python manage.py runserver 0.0.0.0:8000
|
||||
exec uv run python manage.py runserver 0.0.0.0:8000
|
||||
else
|
||||
echo "Starting Gunicorn..."
|
||||
exec gunicorn --bind 0.0.0.0:8000 \
|
||||
exec uv run gunicorn --bind 0.0.0.0:8000 \
|
||||
--workers ${GUNICORN_WORKERS:-3} \
|
||||
--timeout ${GUNICORN_TIMEOUT:-120} \
|
||||
--reload \
|
||||
dbapp.wsgi:application
|
||||
fi
|
||||
|
||||
128
dbapp/fix_objitems_without_source.py
Normal file
128
dbapp/fix_objitems_without_source.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Скрипт для исправления ObjItems без связи с Source.
|
||||
|
||||
Для каждого ObjItem без source:
|
||||
1. Получить координаты из geo_obj
|
||||
2. Найти ближайший Source (по coords_average)
|
||||
3. Если расстояние <= 0.5 градуса, связать ObjItem с этим Source
|
||||
4. Иначе создать новый Source с coords_average = координаты geo_obj
|
||||
"""
|
||||
|
||||
# import os
|
||||
# import django
|
||||
|
||||
# os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dbapp.settings")
|
||||
# django.setup()
|
||||
|
||||
# from mainapp.models import ObjItem, Source, CustomUser
|
||||
# from django.contrib.gis.geos import Point
|
||||
# from django.contrib.gis.measure import D
|
||||
# from django.contrib.gis.db.models.functions import Distance
|
||||
|
||||
|
||||
# def calculate_distance_degrees(coord1, coord2):
|
||||
# """Вычисляет расстояние между двумя координатами в градусах."""
|
||||
# import math
|
||||
|
||||
# lon1, lat1 = coord1
|
||||
# lon2, lat2 = coord2
|
||||
|
||||
# return math.sqrt((lon2 - lon1) ** 2 + (lat2 - lat1) ** 2)
|
||||
|
||||
|
||||
# def fix_objitems_without_source():
|
||||
# """Исправляет ObjItems без связи с Source."""
|
||||
|
||||
# # Получаем пользователя по умолчанию
|
||||
# default_user = CustomUser.objects.get(id=1)
|
||||
|
||||
# # Получаем все ObjItems без source
|
||||
# objitems_without_source = ObjItem.objects.filter(source__isnull=True)
|
||||
# total_count = objitems_without_source.count()
|
||||
|
||||
# print(f"Найдено {total_count} ObjItems без source")
|
||||
|
||||
# if total_count == 0:
|
||||
# print("Нечего исправлять!")
|
||||
# return
|
||||
|
||||
# fixed_count = 0
|
||||
# new_sources_count = 0
|
||||
|
||||
# for objitem in objitems_without_source:
|
||||
# # Проверяем, есть ли geo_obj
|
||||
# if not hasattr(objitem, 'geo_obj') or not objitem.geo_obj or not objitem.geo_obj.coords:
|
||||
# print(f"ObjItem {objitem.id} не имеет geo_obj или координат, пропускаем")
|
||||
# continue
|
||||
|
||||
# geo_coords = objitem.geo_obj.coords
|
||||
# coord_tuple = (geo_coords.x, geo_coords.y)
|
||||
|
||||
# # Ищем ближайший Source
|
||||
# sources_with_coords = Source.objects.filter(coords_average__isnull=False)
|
||||
|
||||
# closest_source = None
|
||||
# min_distance = float('inf')
|
||||
|
||||
# for source in sources_with_coords:
|
||||
# source_coord = (source.coords_average.x, source.coords_average.y)
|
||||
# distance = calculate_distance_degrees(coord_tuple, source_coord)
|
||||
|
||||
# if distance < min_distance:
|
||||
# min_distance = distance
|
||||
# closest_source = source
|
||||
|
||||
# # Если нашли близкий Source (расстояние <= 0.5 градуса)
|
||||
# if closest_source and min_distance <= 0.5:
|
||||
# objitem.source = closest_source
|
||||
# objitem.save()
|
||||
# print(f"ObjItem {objitem.id} связан с Source {closest_source.id} (расстояние: {min_distance:.4f}°)")
|
||||
# fixed_count += 1
|
||||
# else:
|
||||
# # Создаем новый Source
|
||||
# new_source = Source.objects.create(
|
||||
# coords_average=Point(coord_tuple, srid=4326),
|
||||
# created_by=default_user
|
||||
# )
|
||||
# objitem.source = new_source
|
||||
# objitem.save()
|
||||
# print(f"ObjItem {objitem.id} связан с новым Source {new_source.id}")
|
||||
# fixed_count += 1
|
||||
# new_sources_count += 1
|
||||
|
||||
# print(f"\nГотово!")
|
||||
# print(f"Исправлено ObjItems: {fixed_count}")
|
||||
# print(f"Создано новых Source: {new_sources_count}")
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
# fix_objitems_without_source()
|
||||
|
||||
|
||||
from geographiclib.geodesic import Geodesic
|
||||
|
||||
def calculate_mean_coords(coord1: tuple, coord2: tuple) -> tuple[tuple, float]:
|
||||
"""
|
||||
Вычисляет среднюю точку между двумя координатами с использованием геодезических вычислений (с учётом эллипсоида).
|
||||
|
||||
:param lat1: Широта первой точки в градусах.
|
||||
:param lon1: Долгота первой точки в градусах.
|
||||
:param lat2: Широта второй точки в градусах.
|
||||
:param lon2: Долгота второй точки в градусах.
|
||||
:return: Словарь с ключами 'lat' и 'lon' для средней точки, и расстояние(dist) в КМ.
|
||||
"""
|
||||
lon1, lat1 = coord1
|
||||
lon2, lat2 = coord2
|
||||
geod_inv = Geodesic.WGS84.Inverse(lat1, lon1, lat2, lon2)
|
||||
azimuth1 = geod_inv['azi1']
|
||||
distance = geod_inv['s12']
|
||||
geod_direct = Geodesic.WGS84.Direct(lat1, lon1, azimuth1, distance / 2)
|
||||
return (geod_direct['lon2'], geod_direct['lat2']), distance/1000
|
||||
|
||||
# Пример использования
|
||||
lat1, lon1 = 56.15465080269812, 38.140518028837285
|
||||
lat2, lon2 = 56.0852, 38.0852
|
||||
midpoint = calculate_mean_coords((lat1, lon1), (lat2, lon2)) #56.15465080269812, 38.140518028837285
|
||||
|
||||
print(f"Средняя точка: {midpoint[0]}")
|
||||
print(f"Расстояние: {midpoint[1]} км")
|
||||
564
dbapp/lyngsatapp/async_parser.py
Normal file
564
dbapp/lyngsatapp/async_parser.py
Normal file
@@ -0,0 +1,564 @@
|
||||
"""
|
||||
Асинхронный парсер данных LyngSat с поддержкой кеширования в Redis.
|
||||
"""
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from datetime import datetime
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional
|
||||
from django.core.cache import cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_satellite_names(satellite_string: str) -> list[str]:
|
||||
"""Извлекает все возможные имена спутников из строки."""
|
||||
slash_parts = [part.strip() for part in satellite_string.split('/')]
|
||||
all_names = []
|
||||
for part in slash_parts:
|
||||
main_match = re.match(r'^([^(]+)', part)
|
||||
if main_match:
|
||||
main_name = main_match.group(1).strip()
|
||||
if main_name:
|
||||
all_names.append(main_name)
|
||||
bracket_match = re.search(r'\(([^)]+)\)', part)
|
||||
if bracket_match:
|
||||
bracket_name = bracket_match.group(1).strip()
|
||||
if bracket_name:
|
||||
all_names.append(bracket_name)
|
||||
seen = set()
|
||||
result = []
|
||||
for name in all_names:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
result.append(name.strip().lower())
|
||||
return result
|
||||
|
||||
|
||||
class AsyncLyngSatParser:
|
||||
"""
|
||||
Асинхронный парсер данных для LyngSat с поддержкой кеширования.
|
||||
|
||||
Кеширование:
|
||||
- Страницы регионов кешируются на 7 дней
|
||||
- Данные спутников кешируются на 1 день
|
||||
"""
|
||||
|
||||
# Время жизни кеша
|
||||
REGION_CACHE_TTL = 60 * 60 * 24 * 7 # 7 дней
|
||||
SATELLITE_CACHE_TTL = 60 * 60 * 24 # 1 день
|
||||
|
||||
# Префиксы для ключей кеша
|
||||
REGION_CACHE_PREFIX = "lyngsat_region"
|
||||
SATELLITE_CACHE_PREFIX = "lyngsat_satellite"
|
||||
SATELLITE_LIST_CACHE_PREFIX = "lyngsat_sat_list"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
flaresolver_url: str = "http://localhost:8191/v1",
|
||||
regions: list[str] | None = None,
|
||||
target_sats: list[str] | None = None,
|
||||
use_cache: bool = True,
|
||||
):
|
||||
"""
|
||||
Инициализация парсера.
|
||||
|
||||
Args:
|
||||
flaresolver_url: URL FlareSolverr для обхода защиты
|
||||
regions: Список регионов для парсинга
|
||||
target_sats: Список целевых спутников (в нижнем регистре)
|
||||
use_cache: Использовать ли кеширование
|
||||
"""
|
||||
self.flaresolver_url = flaresolver_url
|
||||
self.use_cache = use_cache
|
||||
self.target_sats = (
|
||||
list(map(lambda sat: sat.strip().lower(), target_sats)) if target_sats else None
|
||||
)
|
||||
self.regions = regions if regions else ["europe", "asia", "america", "atlantic"]
|
||||
self.BASE_URL = "https://www.lyngsat.com"
|
||||
|
||||
def _get_cache_key(self, prefix: str, identifier: str) -> str:
|
||||
"""Генерирует ключ для кеша."""
|
||||
return f"{prefix}:{identifier}"
|
||||
|
||||
def _get_from_cache(self, key: str) -> Optional[any]:
|
||||
"""Получает данные из кеша."""
|
||||
if not self.use_cache:
|
||||
return None
|
||||
try:
|
||||
data = cache.get(key)
|
||||
if data:
|
||||
logger.debug(f"Данные получены из кеша: {key}")
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка при получении из кеша {key}: {e}")
|
||||
return None
|
||||
|
||||
def _set_to_cache(self, key: str, value: any, ttl: int) -> None:
|
||||
"""Сохраняет данные в кеш."""
|
||||
if not self.use_cache:
|
||||
return
|
||||
try:
|
||||
cache.set(key, value, timeout=ttl)
|
||||
logger.debug(f"Данные сохранены в кеш: {key} (TTL: {ttl}s)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Ошибка при сохранении в кеш {key}: {e}")
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls, cache_type: str = "all") -> dict:
|
||||
"""
|
||||
Очищает кеш парсера.
|
||||
|
||||
Args:
|
||||
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
|
||||
|
||||
Returns:
|
||||
dict: Статистика очистки
|
||||
"""
|
||||
stats = {"cleared": 0, "errors": []}
|
||||
|
||||
try:
|
||||
from django.core.cache import cache as django_cache
|
||||
|
||||
if cache_type in ("regions", "all"):
|
||||
# Очищаем кеш регионов
|
||||
regions = ["europe", "asia", "america", "atlantic"]
|
||||
for region in regions:
|
||||
key = f"{cls.REGION_CACHE_PREFIX}:{region}"
|
||||
try:
|
||||
result = django_cache.delete(key)
|
||||
if result:
|
||||
stats["cleared"] += 1
|
||||
logger.info(f"Очищен кеш региона: {region}")
|
||||
else:
|
||||
logger.debug(f"Кеш региона {region} не найден или уже удален")
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при очистке кеша региона {region}: {e}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
|
||||
if cache_type in ("satellites", "all"):
|
||||
# Для очистки кеша спутников используем keys()
|
||||
if hasattr(django_cache, 'keys'):
|
||||
try:
|
||||
# Очищаем списки спутников
|
||||
list_keys = django_cache.keys(f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*")
|
||||
if list_keys:
|
||||
if hasattr(django_cache, 'delete_many'):
|
||||
django_cache.delete_many(list_keys)
|
||||
else:
|
||||
for key in list_keys:
|
||||
django_cache.delete(key)
|
||||
stats["cleared"] += len(list_keys)
|
||||
logger.info(f"Очищено {len(list_keys)} списков спутников")
|
||||
|
||||
# Очищаем данные спутников
|
||||
sat_keys = django_cache.keys(f"{cls.SATELLITE_CACHE_PREFIX}:*")
|
||||
if sat_keys:
|
||||
if hasattr(django_cache, 'delete_many'):
|
||||
django_cache.delete_many(sat_keys)
|
||||
else:
|
||||
for key in sat_keys:
|
||||
django_cache.delete(key)
|
||||
stats["cleared"] += len(sat_keys)
|
||||
logger.info(f"Очищено {len(sat_keys)} данных спутников")
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при очистке кеша спутников: {e}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
else:
|
||||
logger.warning("Бэкенд кеша не поддерживает keys()")
|
||||
logger.info("Для полной очистки используйте: redis-cli flushdb")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Критическая ошибка при очистке кеша: {e}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
|
||||
return stats
|
||||
|
||||
@classmethod
|
||||
def clear_all_cache(cls) -> dict:
|
||||
"""Полностью очищает весь кеш LyngSat."""
|
||||
stats = {"cleared": 0, "errors": []}
|
||||
try:
|
||||
from django.core.cache import cache as django_cache
|
||||
|
||||
# Для django-redis используем keys() + delete_many()
|
||||
if hasattr(django_cache, 'keys'):
|
||||
patterns = [
|
||||
f"{cls.REGION_CACHE_PREFIX}:*",
|
||||
f"{cls.SATELLITE_CACHE_PREFIX}:*",
|
||||
f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*",
|
||||
]
|
||||
|
||||
all_keys = []
|
||||
for pattern in patterns:
|
||||
try:
|
||||
keys = django_cache.keys(pattern)
|
||||
if keys:
|
||||
all_keys.extend(keys)
|
||||
logger.info(f"Найдено {len(keys)} ключей по паттерну: {pattern}")
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при поиске ключей {pattern}: {e}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
|
||||
# Удаляем все найденные ключи
|
||||
if all_keys:
|
||||
try:
|
||||
if hasattr(django_cache, 'delete_many'):
|
||||
django_cache.delete_many(all_keys)
|
||||
else:
|
||||
for key in all_keys:
|
||||
django_cache.delete(key)
|
||||
stats["cleared"] = len(all_keys)
|
||||
logger.info(f"Удалено {len(all_keys)} ключей")
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при удалении ключей: {e}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
else:
|
||||
logger.info("Ключи для удаления не найдены")
|
||||
|
||||
elif hasattr(django_cache, 'delete_pattern'):
|
||||
# Fallback на delete_pattern
|
||||
patterns = [
|
||||
f"{cls.REGION_CACHE_PREFIX}:*",
|
||||
f"{cls.SATELLITE_CACHE_PREFIX}:*",
|
||||
f"{cls.SATELLITE_LIST_CACHE_PREFIX}:*",
|
||||
]
|
||||
for pattern in patterns:
|
||||
try:
|
||||
deleted = django_cache.delete_pattern(pattern)
|
||||
if deleted and isinstance(deleted, int):
|
||||
stats["cleared"] += deleted
|
||||
logger.info(f"Очищено {deleted} ключей по паттерну: {pattern}")
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при очистке паттерна {pattern}: {e}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
else:
|
||||
# Fallback для других бэкендов кеша
|
||||
logger.warning("Бэкенд кеша не поддерживает keys() или delete_pattern()")
|
||||
return cls.clear_cache("all")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Критическая ошибка при полной очистке кеша: {e}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
|
||||
return stats
|
||||
|
||||
def parse_metadata(self, metadata: str) -> dict:
|
||||
"""Парсит метаданные транспондера."""
|
||||
if not metadata or not metadata.strip():
|
||||
return {
|
||||
"standard": None,
|
||||
"modulation": None,
|
||||
"symbol_rate": None,
|
||||
"fec": None,
|
||||
}
|
||||
normalized = re.sub(r"\s+", "", metadata.strip())
|
||||
fec_match = re.search(r"([1-9]/[1-9])$", normalized)
|
||||
fec = fec_match.group(1) if fec_match else None
|
||||
if fec_match:
|
||||
core = normalized[: fec_match.start()]
|
||||
else:
|
||||
core = normalized
|
||||
std_match = re.match(r"(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)", core)
|
||||
standard = std_match.group(1) if std_match else None
|
||||
rest = core[len(standard) :] if standard else core
|
||||
modulation = None
|
||||
mod_match = re.match(r"(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)", rest)
|
||||
if mod_match:
|
||||
modulation = mod_match.group(1)
|
||||
rest = rest[len(modulation) :]
|
||||
symbol_rate = None
|
||||
sr_match = re.search(r"(\d+)$", rest)
|
||||
if sr_match:
|
||||
try:
|
||||
symbol_rate = int(sr_match.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return {
|
||||
"standard": standard,
|
||||
"modulation": modulation,
|
||||
"symbol_rate": symbol_rate,
|
||||
"fec": fec,
|
||||
}
|
||||
|
||||
def extract_date(self, s: str) -> datetime | None:
|
||||
"""Извлекает дату из строки формата YYMMDD."""
|
||||
s = s.strip()
|
||||
match = re.search(r"(\d{6})$", s)
|
||||
if not match:
|
||||
return None
|
||||
yymmdd = match.group(1)
|
||||
try:
|
||||
return datetime.strptime(yymmdd, "%y%m%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def convert_polarization(self, polarization: str) -> str:
|
||||
"""Преобразует код поляризации в понятное название на русском."""
|
||||
polarization_map = {
|
||||
"V": "Вертикальная",
|
||||
"H": "Горизонтальная",
|
||||
"R": "Правая",
|
||||
"L": "Левая",
|
||||
}
|
||||
return polarization_map.get(polarization.upper(), polarization)
|
||||
|
||||
def fetch_region_page(self, region: str, force_refresh: bool = False) -> Optional[str]:
|
||||
"""
|
||||
Получает HTML страницу региона с кешированием.
|
||||
|
||||
Args:
|
||||
region: Название региона
|
||||
force_refresh: Принудительно обновить кеш
|
||||
|
||||
Returns:
|
||||
HTML содержимое страницы или None при ошибке
|
||||
"""
|
||||
cache_key = self._get_cache_key(self.REGION_CACHE_PREFIX, region)
|
||||
|
||||
# Проверяем кеш
|
||||
if not force_refresh:
|
||||
cached_html = self._get_from_cache(cache_key)
|
||||
if cached_html:
|
||||
logger.info(f"Страница региона {region} получена из кеша")
|
||||
return cached_html
|
||||
|
||||
# Запрашиваем страницу
|
||||
url = f"{self.BASE_URL}/{region}.html"
|
||||
logger.info(f"Запрос страницы региона: {url}")
|
||||
|
||||
try:
|
||||
payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000}
|
||||
response = requests.post(self.flaresolver_url, json=payload, timeout=70)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Ошибка при запросе {url}: статус {response.status_code}")
|
||||
return None
|
||||
|
||||
html_content = response.json().get("solution", {}).get("response", "")
|
||||
|
||||
if html_content:
|
||||
# Сохраняем в кеш
|
||||
self._set_to_cache(cache_key, html_content, self.REGION_CACHE_TTL)
|
||||
logger.info(f"Страница региона {region} получена и закеширована")
|
||||
|
||||
return html_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении страницы {url}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def get_satellite_list_from_region(self, region: str, force_refresh: bool = False) -> list[dict]:
|
||||
"""
|
||||
Получает список спутников из региона.
|
||||
|
||||
Args:
|
||||
region: Название региона
|
||||
force_refresh: Принудительно обновить кеш
|
||||
|
||||
Returns:
|
||||
Список словарей с информацией о спутниках
|
||||
"""
|
||||
# Создаем уникальный ключ кеша с учетом целевых спутников
|
||||
# Если target_sats не указаны, используем "all"
|
||||
sats_key = "all" if not self.target_sats else "_".join(sorted(self.target_sats))
|
||||
cache_key = self._get_cache_key(self.SATELLITE_LIST_CACHE_PREFIX, f"{region}_{sats_key}")
|
||||
|
||||
# Проверяем кеш
|
||||
if not force_refresh:
|
||||
cached_list = self._get_from_cache(cache_key)
|
||||
if cached_list:
|
||||
logger.info(f"Список спутников региона {region} (фильтр: {sats_key[:50]}) получен из кеша")
|
||||
return cached_list
|
||||
|
||||
# Получаем HTML страницы
|
||||
html_content = self.fetch_region_page(region, force_refresh)
|
||||
if not html_content:
|
||||
return []
|
||||
|
||||
# Парсим список спутников
|
||||
satellites = []
|
||||
try:
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
col_table = soup.find_all("div", class_="desktab")[0]
|
||||
tables = col_table.find_next_sibling("table").find_all("table")
|
||||
|
||||
trs = []
|
||||
for table in tables:
|
||||
trs.extend(table.find_all("tr"))
|
||||
|
||||
for tr in trs:
|
||||
sat_name = tr.find("span").text.replace("ü", "u").strip().lower()
|
||||
|
||||
# Фильтруем по целевым спутникам
|
||||
if self.target_sats is not None:
|
||||
names = parse_satellite_names(sat_name)
|
||||
if len(names) == 1:
|
||||
sat_name = names[0]
|
||||
else:
|
||||
for name in names:
|
||||
if name in self.target_sats:
|
||||
sat_name = name
|
||||
break
|
||||
|
||||
if sat_name not in self.target_sats:
|
||||
continue
|
||||
|
||||
try:
|
||||
sat_url = tr.find_all("a")[2]["href"]
|
||||
except IndexError:
|
||||
sat_url = tr.find_all("a")[0]["href"]
|
||||
|
||||
update_date_str = tr.find_all("td")[-1].text
|
||||
try:
|
||||
update_date = datetime.strptime(update_date_str, "%y%m%d").date()
|
||||
except (ValueError, TypeError):
|
||||
update_date = None
|
||||
|
||||
satellites.append({
|
||||
"name": sat_name,
|
||||
"url": sat_url,
|
||||
"update_date": update_date,
|
||||
"region": region
|
||||
})
|
||||
|
||||
# Сохраняем в кеш
|
||||
self._set_to_cache(cache_key, satellites, self.REGION_CACHE_TTL)
|
||||
sats_filter = "все" if not self.target_sats else f"{len(self.target_sats)} целевых"
|
||||
logger.info(f"Найдено {len(satellites)} спутников в регионе {region} (фильтр: {sats_filter})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при парсинге списка спутников региона {region}: {e}", exc_info=True)
|
||||
|
||||
return satellites
|
||||
|
||||
def fetch_satellite_data(self, sat_name: str, sat_url: str, force_refresh: bool = False) -> Optional[dict]:
|
||||
"""
|
||||
Получает данные одного спутника с кешированием.
|
||||
|
||||
Args:
|
||||
sat_name: Название спутника
|
||||
sat_url: URL страницы спутника
|
||||
force_refresh: Принудительно обновить кеш
|
||||
|
||||
Returns:
|
||||
Словарь с данными спутника или None при ошибке
|
||||
"""
|
||||
cache_key = self._get_cache_key(self.SATELLITE_CACHE_PREFIX, sat_name)
|
||||
|
||||
# Проверяем кеш
|
||||
if not force_refresh:
|
||||
cached_data = self._get_from_cache(cache_key)
|
||||
if cached_data:
|
||||
logger.info(f"Данные спутника {sat_name} получены из кеша")
|
||||
return cached_data
|
||||
|
||||
# Запрашиваем данные
|
||||
full_url = f"{self.BASE_URL}/{sat_url}"
|
||||
logger.info(f"Запрос данных спутника {sat_name}: {full_url}")
|
||||
|
||||
try:
|
||||
payload = {"cmd": "request.get", "url": full_url, "maxTimeout": 60000}
|
||||
response = requests.post(self.flaresolver_url, json=payload, timeout=70)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.error(f"Ошибка при запросе {full_url}: статус {response.status_code}")
|
||||
return None
|
||||
|
||||
html_content = response.json().get("solution", {}).get("response", "")
|
||||
|
||||
if not html_content:
|
||||
logger.warning(f"Пустой ответ для спутника {sat_name}")
|
||||
return None
|
||||
|
||||
# Парсим данные
|
||||
sources = self.parse_satellite_content(html_content)
|
||||
|
||||
satellite_data = {
|
||||
"name": sat_name,
|
||||
"url": full_url,
|
||||
"sources": sources,
|
||||
"fetched_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Сохраняем в кеш
|
||||
self._set_to_cache(cache_key, satellite_data, self.SATELLITE_CACHE_TTL)
|
||||
logger.info(f"Данные спутника {sat_name} получены и закешированы ({len(sources)} источников)")
|
||||
|
||||
return satellite_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении данных спутника {sat_name}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def parse_satellite_content(self, html_content: str) -> list[dict]:
|
||||
"""Парсит содержимое страницы спутника."""
|
||||
data = []
|
||||
try:
|
||||
sat_soup = BeautifulSoup(html_content, "html.parser")
|
||||
big_table = sat_soup.find("table", class_="bigtable")
|
||||
|
||||
if not big_table:
|
||||
logger.warning("Таблица bigtable не найдена")
|
||||
return data
|
||||
|
||||
all_tables = big_table.find_all("div", class_="desktab")[:-1]
|
||||
|
||||
for table in all_tables:
|
||||
trs = table.find_next_sibling("table").find_all("tr")
|
||||
for idx, tr in enumerate(trs):
|
||||
tds = tr.find_all("td")
|
||||
if len(tds) < 9 or idx < 2:
|
||||
continue
|
||||
|
||||
try:
|
||||
freq, polarization = tds[0].find("b").text.strip().split("\xa0")
|
||||
polarization = self.convert_polarization(polarization)
|
||||
meta = self.parse_metadata(tds[1].text)
|
||||
provider_name = tds[3].text
|
||||
last_update = self.extract_date(tds[-1].text)
|
||||
|
||||
data.append({
|
||||
"freq": freq,
|
||||
"pol": polarization,
|
||||
"metadata": meta,
|
||||
"provider_name": provider_name,
|
||||
"last_update": last_update,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка при парсинге строки транспондера: {e}")
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при парсинге содержимого спутника: {e}", exc_info=True)
|
||||
|
||||
return data
|
||||
|
||||
def get_all_satellites_list(self, force_refresh: bool = False) -> list[dict]:
|
||||
"""
|
||||
Получает список всех спутников из всех регионов.
|
||||
|
||||
Args:
|
||||
force_refresh: Принудительно обновить кеш
|
||||
|
||||
Returns:
|
||||
Список словарей с информацией о спутниках
|
||||
"""
|
||||
all_satellites = []
|
||||
|
||||
for region in self.regions:
|
||||
logger.info(f"Получение списка спутников из региона: {region}")
|
||||
satellites = self.get_satellite_list_from_region(region, force_refresh)
|
||||
all_satellites.extend(satellites)
|
||||
|
||||
logger.info(f"Всего найдено спутников: {len(all_satellites)}")
|
||||
return all_satellites
|
||||
292
dbapp/lyngsatapp/async_utils.py
Normal file
292
dbapp/lyngsatapp/async_utils.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Утилиты для асинхронной обработки данных LyngSat с кешированием.
|
||||
"""
|
||||
import logging
|
||||
from typing import Callable, Optional
|
||||
from .async_parser import AsyncLyngSatParser
|
||||
from .models import LyngSat
|
||||
from mainapp.models import Polarization, Standard, Modulation, Satellite
|
||||
from dbapp.settings.base import FLARESOLVERR_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def process_single_satellite(
|
||||
parser: AsyncLyngSatParser,
|
||||
satellite_info: dict,
|
||||
force_refresh: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Обрабатывает один спутник и сохраняет данные в БД.
|
||||
|
||||
Args:
|
||||
parser: Экземпляр парсера
|
||||
satellite_info: Информация о спутнике (name, url, update_date)
|
||||
force_refresh: Принудительно обновить кеш
|
||||
|
||||
Returns:
|
||||
dict: Статистика обработки спутника
|
||||
"""
|
||||
sat_name = satellite_info["name"]
|
||||
sat_url = satellite_info["url"]
|
||||
|
||||
stats = {
|
||||
"satellite_name": sat_name,
|
||||
"sources_found": 0,
|
||||
"created": 0,
|
||||
"updated": 0,
|
||||
"errors": []
|
||||
}
|
||||
|
||||
logger.info(f"Обработка спутника: {sat_name}")
|
||||
|
||||
# Получаем данные спутника (из кеша или с сайта)
|
||||
satellite_data = parser.fetch_satellite_data(sat_name, sat_url, force_refresh)
|
||||
|
||||
if not satellite_data:
|
||||
error_msg = f"Не удалось получить данные для спутника {sat_name}"
|
||||
logger.error(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
return stats
|
||||
|
||||
sources = satellite_data.get("sources", [])
|
||||
stats["sources_found"] = len(sources)
|
||||
|
||||
logger.info(f"Найдено {len(sources)} источников для {sat_name}")
|
||||
|
||||
# Находим спутник в базе по имени или альтернативному имени (lowercase)
|
||||
from django.db.models import Q
|
||||
sat_name_lower = sat_name.lower()
|
||||
try:
|
||||
sat_obj = Satellite.objects.get(
|
||||
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
|
||||
)
|
||||
logger.debug(f"Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
||||
except Satellite.DoesNotExist:
|
||||
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
||||
logger.warning(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
return stats
|
||||
except Satellite.MultipleObjectsReturned:
|
||||
error_msg = f"Найдено несколько спутников с именем '{sat_name}'"
|
||||
logger.warning(error_msg)
|
||||
stats["errors"].append(error_msg)
|
||||
return stats
|
||||
|
||||
# Обрабатываем каждый источник
|
||||
for source_idx, source in enumerate(sources, 1):
|
||||
try:
|
||||
# Парсим частоту
|
||||
try:
|
||||
freq = float(source['freq'])
|
||||
except (ValueError, TypeError):
|
||||
freq = -1.0
|
||||
logger.debug(f"Некорректная частота для {sat_name}: {source.get('freq')}")
|
||||
|
||||
last_update = source['last_update']
|
||||
fec = source['metadata'].get('fec')
|
||||
modulation_name = source['metadata'].get('modulation')
|
||||
standard_name = source['metadata'].get('standard')
|
||||
symbol_velocity = source['metadata'].get('symbol_rate')
|
||||
polarization_name = source['pol']
|
||||
channel_info = source['provider_name']
|
||||
|
||||
# Создаем или получаем связанные объекты
|
||||
pol_obj, _ = Polarization.objects.get_or_create(
|
||||
name=polarization_name if polarization_name else "-"
|
||||
)
|
||||
|
||||
mod_obj, _ = Modulation.objects.get_or_create(
|
||||
name=modulation_name if modulation_name else "-"
|
||||
)
|
||||
|
||||
standard_obj, _ = Standard.objects.get_or_create(
|
||||
name=standard_name if standard_name else "-"
|
||||
)
|
||||
|
||||
# Создаем или обновляем запись Lyngsat
|
||||
lyng_obj, created = LyngSat.objects.update_or_create(
|
||||
id_satellite=sat_obj,
|
||||
frequency=freq,
|
||||
polarization=pol_obj,
|
||||
defaults={
|
||||
"modulation": mod_obj,
|
||||
"standard": standard_obj,
|
||||
"sym_velocity": symbol_velocity if symbol_velocity else 0,
|
||||
"channel_info": channel_info[:20] if channel_info else "",
|
||||
"last_update": last_update,
|
||||
"fec": fec[:30] if fec else "",
|
||||
"url": satellite_data["url"]
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
stats['created'] += 1
|
||||
logger.debug(f"Создана запись для {sat_name} {freq} МГц")
|
||||
else:
|
||||
stats['updated'] += 1
|
||||
logger.debug(f"Обновлена запись для {sat_name} {freq} МГц")
|
||||
|
||||
# Логируем прогресс каждые 10 источников
|
||||
if source_idx % 10 == 0:
|
||||
logger.info(f"Обработано {source_idx}/{len(sources)} источников для {sat_name}")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
stats['errors'].append(error_msg)
|
||||
continue
|
||||
|
||||
logger.info(f"Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}")
|
||||
return stats
|
||||
|
||||
|
||||
def fill_lyngsat_data_async(
|
||||
target_sats: list[str],
|
||||
regions: list[str] = None,
|
||||
task_id: str = None,
|
||||
update_progress: Optional[Callable] = None,
|
||||
force_refresh: bool = False,
|
||||
use_cache: bool = True
|
||||
) -> dict:
|
||||
"""
|
||||
Асинхронно заполняет данные Lyngsat для указанных спутников.
|
||||
Обрабатывает спутники по одному с кешированием.
|
||||
|
||||
Args:
|
||||
target_sats: Список названий спутников для обработки
|
||||
regions: Список регионов для парсинга (по умолчанию все)
|
||||
task_id: ID задачи Celery для логирования
|
||||
update_progress: Функция для обновления прогресса (current, total, status, details)
|
||||
force_refresh: Принудительно обновить кеш
|
||||
use_cache: Использовать ли кеширование
|
||||
|
||||
Returns:
|
||||
dict: Статистика обработки
|
||||
"""
|
||||
log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat Async]"
|
||||
|
||||
overall_stats = {
|
||||
'total_satellites': 0,
|
||||
'processed_satellites': 0,
|
||||
'total_sources': 0,
|
||||
'created': 0,
|
||||
'updated': 0,
|
||||
'errors': [],
|
||||
'satellites_details': []
|
||||
}
|
||||
|
||||
if regions is None:
|
||||
regions = ["europe", "asia", "america", "atlantic"]
|
||||
|
||||
logger.info(f"{log_prefix} Начало асинхронной обработки данных")
|
||||
logger.info(f"{log_prefix} Спутники: {', '.join(target_sats)}")
|
||||
logger.info(f"{log_prefix} Регионы: {', '.join(regions)}")
|
||||
logger.info(f"{log_prefix} Использование кеша: {use_cache}, Принудительное обновление: {force_refresh}")
|
||||
|
||||
if update_progress:
|
||||
update_progress(0, len(target_sats), "Инициализация парсера...", {})
|
||||
|
||||
try:
|
||||
# Создаем парсер
|
||||
parser = AsyncLyngSatParser(
|
||||
flaresolver_url=FLARESOLVERR_URL,
|
||||
target_sats=target_sats,
|
||||
regions=regions,
|
||||
use_cache=use_cache
|
||||
)
|
||||
|
||||
logger.info(f"{log_prefix} Получение списка спутников...")
|
||||
if update_progress:
|
||||
update_progress(0, len(target_sats), "Получение списка спутников...", {})
|
||||
|
||||
# Получаем список всех спутников
|
||||
all_satellites = parser.get_all_satellites_list(force_refresh)
|
||||
overall_stats['total_satellites'] = len(all_satellites)
|
||||
|
||||
logger.info(f"{log_prefix} Найдено {len(all_satellites)} спутников для обработки")
|
||||
|
||||
# Обрабатываем каждый спутник по отдельности
|
||||
for idx, satellite_info in enumerate(all_satellites, 1):
|
||||
sat_name = satellite_info["name"]
|
||||
|
||||
logger.info(f"{log_prefix} Обработка спутника {idx}/{len(all_satellites)}: {sat_name}")
|
||||
|
||||
if update_progress:
|
||||
update_progress(
|
||||
idx - 1,
|
||||
len(all_satellites),
|
||||
f"Обработка {sat_name}...",
|
||||
{
|
||||
"current_satellite": sat_name,
|
||||
"created": overall_stats['created'],
|
||||
"updated": overall_stats['updated']
|
||||
}
|
||||
)
|
||||
|
||||
# Обрабатываем спутник
|
||||
sat_stats = process_single_satellite(parser, satellite_info, force_refresh)
|
||||
|
||||
# Обновляем общую статистику
|
||||
overall_stats['processed_satellites'] += 1
|
||||
overall_stats['total_sources'] += sat_stats['sources_found']
|
||||
overall_stats['created'] += sat_stats['created']
|
||||
overall_stats['updated'] += sat_stats['updated']
|
||||
overall_stats['errors'].extend(sat_stats['errors'])
|
||||
overall_stats['satellites_details'].append(sat_stats)
|
||||
|
||||
logger.info(
|
||||
f"{log_prefix} Спутник {sat_name} обработан: "
|
||||
f"источников {sat_stats['sources_found']}, "
|
||||
f"создано {sat_stats['created']}, "
|
||||
f"обновлено {sat_stats['updated']}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"{log_prefix} Обработка завершена. "
|
||||
f"Спутников: {overall_stats['processed_satellites']}/{overall_stats['total_satellites']}, "
|
||||
f"Источников: {overall_stats['total_sources']}, "
|
||||
f"Создано: {overall_stats['created']}, "
|
||||
f"Обновлено: {overall_stats['updated']}, "
|
||||
f"Ошибок: {len(overall_stats['errors'])}"
|
||||
)
|
||||
|
||||
if update_progress:
|
||||
update_progress(
|
||||
overall_stats['processed_satellites'],
|
||||
overall_stats['total_satellites'],
|
||||
"Завершено",
|
||||
{
|
||||
"created": overall_stats['created'],
|
||||
"updated": overall_stats['updated'],
|
||||
"errors_count": len(overall_stats['errors'])
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Критическая ошибка: {str(e)}"
|
||||
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
|
||||
overall_stats['errors'].append(error_msg)
|
||||
|
||||
return overall_stats
|
||||
|
||||
|
||||
def clear_lyngsat_cache(cache_type: str = "all") -> dict:
|
||||
"""
|
||||
Очищает кеш LyngSat.
|
||||
|
||||
Args:
|
||||
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
|
||||
|
||||
Returns:
|
||||
dict: Статистика очистки
|
||||
"""
|
||||
logger.info(f"Очистка кеша LyngSat: {cache_type}")
|
||||
|
||||
if cache_type == "all":
|
||||
stats = AsyncLyngSatParser.clear_all_cache()
|
||||
else:
|
||||
stats = AsyncLyngSatParser.clear_cache(cache_type)
|
||||
|
||||
logger.info(f"Кеш очищен: {stats}")
|
||||
return stats
|
||||
0
dbapp/lyngsatapp/management/__init__.py
Normal file
0
dbapp/lyngsatapp/management/__init__.py
Normal file
0
dbapp/lyngsatapp/management/commands/__init__.py
Normal file
0
dbapp/lyngsatapp/management/commands/__init__.py
Normal file
40
dbapp/lyngsatapp/management/commands/clear_lyngsat_cache.py
Normal file
40
dbapp/lyngsatapp/management/commands/clear_lyngsat_cache.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Management команда для очистки кеша LyngSat.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from lyngsatapp.async_utils import clear_lyngsat_cache
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Очищает кеш данных LyngSat'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--type',
|
||||
type=str,
|
||||
default='all',
|
||||
choices=['regions', 'satellites', 'all'],
|
||||
help='Тип кеша для очистки (regions, satellites, all)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
cache_type = options['type']
|
||||
|
||||
self.stdout.write(f'Очистка кеша LyngSat: {cache_type}...')
|
||||
|
||||
stats = clear_lyngsat_cache(cache_type)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Кеш очищен успешно! Удалено записей: {stats["cleared"]}'
|
||||
)
|
||||
)
|
||||
|
||||
if stats['errors']:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f'Ошибок при очистке: {len(stats["errors"])}'
|
||||
)
|
||||
)
|
||||
for error in stats['errors']:
|
||||
self.stdout.write(self.style.ERROR(f' - {error}'))
|
||||
@@ -1,7 +1,5 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-10 20:03
|
||||
# Generated by Django 5.2.7 on 2025-11-12 14:21
|
||||
|
||||
import django.db.models.deletion
|
||||
import mainapp.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -10,7 +8,6 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0007_remove_parameter_objitems_parameter_objitem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@@ -20,14 +17,10 @@ class Migration(migrations.Migration):
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('frequency', models.FloatField(blank=True, default=0, null=True, verbose_name='Частота, МГц')),
|
||||
('sym_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')),
|
||||
('last_update', models.DateTimeField(blank=True, null=True, verbose_name='Время')),
|
||||
('last_update', models.DateTimeField(blank=True, null=True, verbose_name='Дата посленего обновления')),
|
||||
('channel_info', models.CharField(blank=True, max_length=20, null=True, verbose_name='Описание источника')),
|
||||
('fec', models.CharField(blank=True, max_length=30, null=True, verbose_name='Коэффициент коррекции ошибок')),
|
||||
('url', models.URLField(blank=True, null=True, verbose_name='Ссылка на страницу')),
|
||||
('id_satellite', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='lyngsat', to='mainapp.satellite', verbose_name='Спутник')),
|
||||
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.modulation', verbose_name='Модуляция')),
|
||||
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.polarization', verbose_name='Поляризация')),
|
||||
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.standard', verbose_name='Стандарт')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Источник LyngSat',
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 13:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('lyngsatapp', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='lyngsat',
|
||||
name='last_update',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='Дата посленего обновления'),
|
||||
),
|
||||
]
|
||||
38
dbapp/lyngsatapp/migrations/0002_initial.py
Normal file
38
dbapp/lyngsatapp/migrations/0002_initial.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-12 14:21
|
||||
|
||||
import django.db.models.deletion
|
||||
import mainapp.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('lyngsatapp', '0001_initial'),
|
||||
('mainapp', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='lyngsat',
|
||||
name='id_satellite',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='lyngsat', to='mainapp.satellite', verbose_name='Спутник'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lyngsat',
|
||||
name='modulation',
|
||||
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.modulation', verbose_name='Модуляция'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lyngsat',
|
||||
name='polarization',
|
||||
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.polarization', verbose_name='Поляризация'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lyngsat',
|
||||
name='standard',
|
||||
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='lyngsat', to='mainapp.standard', verbose_name='Стандарт'),
|
||||
),
|
||||
]
|
||||
@@ -6,14 +6,101 @@ from celery import shared_task
|
||||
from django.core.cache import cache
|
||||
|
||||
from .utils import fill_lyngsat_data
|
||||
from .async_utils import fill_lyngsat_data_async, clear_lyngsat_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_async')
|
||||
def fill_lyngsat_data_task(self, target_sats, regions=None):
|
||||
def fill_lyngsat_data_task(self, target_sats, regions=None, force_refresh=False, use_cache=True):
|
||||
"""
|
||||
Асинхронная задача для заполнения данных Lyngsat.
|
||||
Асинхронная задача для заполнения данных Lyngsat с кешированием.
|
||||
Обрабатывает спутники по одному.
|
||||
|
||||
Args:
|
||||
target_sats: Список названий спутников для обработки
|
||||
regions: Список регионов для парсинга (по умолчанию все)
|
||||
force_refresh: Принудительно обновить кеш
|
||||
use_cache: Использовать ли кеширование
|
||||
|
||||
Returns:
|
||||
dict: Статистика обработки
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[Task {task_id}] Начало обработки данных Lyngsat")
|
||||
logger.info(f"[Task {task_id}] Спутники: {', '.join(target_sats)}")
|
||||
logger.info(f"[Task {task_id}] Регионы: {', '.join(regions) if regions else 'все'}")
|
||||
logger.info(f"[Task {task_id}] Кеширование: {use_cache}, Принудительное обновление: {force_refresh}")
|
||||
|
||||
# Обновляем статус задачи
|
||||
self.update_state(
|
||||
state='PROGRESS',
|
||||
meta={
|
||||
'current': 0,
|
||||
'total': len(target_sats),
|
||||
'status': 'Инициализация...',
|
||||
'details': {}
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
# Вызываем асинхронную функцию заполнения данных
|
||||
stats = fill_lyngsat_data_async(
|
||||
target_sats=target_sats,
|
||||
regions=regions,
|
||||
task_id=task_id,
|
||||
force_refresh=force_refresh,
|
||||
use_cache=use_cache,
|
||||
update_progress=lambda current, total, status, details: self.update_state(
|
||||
state='PROGRESS',
|
||||
meta={
|
||||
'current': current,
|
||||
'total': total,
|
||||
'status': status,
|
||||
'details': details
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"[Task {task_id}] Обработка завершена успешно")
|
||||
logger.info(f"[Task {task_id}] Статистика: {stats}")
|
||||
|
||||
# Сохраняем результат в кеш для отображения на странице
|
||||
cache.set(f'lyngsat_task_{task_id}', stats, timeout=3600)
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Task {task_id}] Ошибка при обработке: {str(e)}", exc_info=True)
|
||||
error_message = f"{type(e).__name__}: {str(e)}"
|
||||
self.update_state(
|
||||
state='FAILURE',
|
||||
meta={
|
||||
'error': error_message,
|
||||
'status': 'Ошибка при обработке',
|
||||
'details': {},
|
||||
'exc_type': type(e).__name__,
|
||||
'exc_message': str(e)
|
||||
}
|
||||
)
|
||||
# Возвращаем словарь с ошибкой вместо raise для корректной сериализации
|
||||
return {
|
||||
'error': error_message,
|
||||
'status': 'FAILURE',
|
||||
'total_satellites': 0,
|
||||
'processed_satellites': 0,
|
||||
'total_sources': 0,
|
||||
'created': 0,
|
||||
'updated': 0,
|
||||
'errors': [error_message]
|
||||
}
|
||||
|
||||
|
||||
@shared_task(bind=True, name='lyngsatapp.fill_lyngsat_data_sync')
|
||||
def fill_lyngsat_data_task_sync(self, target_sats, regions=None):
|
||||
"""
|
||||
Синхронная задача для заполнения данных Lyngsat (старая версия без кеширования).
|
||||
Используется для обратной совместимости.
|
||||
|
||||
Args:
|
||||
target_sats: Список названий спутников для обработки
|
||||
@@ -23,7 +110,7 @@ def fill_lyngsat_data_task(self, target_sats, regions=None):
|
||||
dict: Статистика обработки
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[Task {task_id}] Начало обработки данных Lyngsat")
|
||||
logger.info(f"[Task {task_id}] Начало синхронной обработки данных Lyngsat")
|
||||
logger.info(f"[Task {task_id}] Спутники: {', '.join(target_sats)}")
|
||||
logger.info(f"[Task {task_id}] Регионы: {', '.join(regions) if regions else 'все'}")
|
||||
|
||||
@@ -38,7 +125,7 @@ def fill_lyngsat_data_task(self, target_sats, regions=None):
|
||||
)
|
||||
|
||||
try:
|
||||
# Вызываем функцию заполнения данных
|
||||
# Вызываем старую функцию заполнения данных
|
||||
stats = fill_lyngsat_data(
|
||||
target_sats=target_sats,
|
||||
regions=regions,
|
||||
@@ -63,11 +150,52 @@ def fill_lyngsat_data_task(self, target_sats, regions=None):
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Task {task_id}] Ошибка при обработке: {str(e)}", exc_info=True)
|
||||
error_message = f"{type(e).__name__}: {str(e)}"
|
||||
self.update_state(
|
||||
state='FAILURE',
|
||||
meta={
|
||||
'error': str(e),
|
||||
'status': 'Ошибка при обработке'
|
||||
'error': error_message,
|
||||
'status': 'Ошибка при обработке',
|
||||
'exc_type': type(e).__name__,
|
||||
'exc_message': str(e)
|
||||
}
|
||||
)
|
||||
raise
|
||||
# Возвращаем словарь с ошибкой вместо raise для корректной сериализации
|
||||
return {
|
||||
'error': error_message,
|
||||
'status': 'FAILURE',
|
||||
'total_satellites': 0,
|
||||
'total_sources': 0,
|
||||
'created': 0,
|
||||
'updated': 0,
|
||||
'errors': [error_message]
|
||||
}
|
||||
|
||||
|
||||
@shared_task(bind=True, name='lyngsatapp.clear_cache')
|
||||
def clear_cache_task(self, cache_type='all'):
|
||||
"""
|
||||
Задача для очистки кеша LyngSat.
|
||||
|
||||
Args:
|
||||
cache_type: Тип кеша для очистки ("regions", "satellites", "all")
|
||||
|
||||
Returns:
|
||||
dict: Статистика очистки
|
||||
"""
|
||||
task_id = self.request.id
|
||||
logger.info(f"[Task {task_id}] Запуск задачи очистки кеша: {cache_type}")
|
||||
|
||||
try:
|
||||
stats = clear_lyngsat_cache(cache_type)
|
||||
logger.info(f"[Task {task_id}] Кеш очищен успешно: {stats}")
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"[Task {task_id}] Ошибка при очистке кеша: {str(e)}", exc_info=True)
|
||||
error_message = f"{type(e).__name__}: {str(e)}"
|
||||
return {
|
||||
'error': error_message,
|
||||
'status': 'FAILURE',
|
||||
'cleared': 0,
|
||||
'errors': [error_message]
|
||||
}
|
||||
|
||||
151
dbapp/lyngsatapp/templates/lyngsatapp/lyngsat_list.html
Normal file
151
dbapp/lyngsatapp/templates/lyngsatapp/lyngsat_list.html
Normal file
@@ -0,0 +1,151 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
|
||||
{% block title %}Источники LyngSat{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.table-responsive tr.selected {
|
||||
background-color: #d4edff;
|
||||
}
|
||||
.sticky-top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3">
|
||||
<!-- Page Header -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h2>Данные по ИРИ с ресурса LyngSat</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar Component -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=True search_placeholder="Поиск по ID..." action_buttons=action_buttons_html %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Panel Component -->
|
||||
{% include 'mainapp/components/_filter_panel.html' with filters=filter_html_list %}
|
||||
|
||||
<!-- Main Table -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
|
||||
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.85rem;">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th scope="col" class="text-center" style="min-width: 60px;">
|
||||
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
|
||||
</th>
|
||||
<th scope="col" style="min-width: 120px;">Спутник</th>
|
||||
<th scope="col" style="min-width: 100px;">
|
||||
{% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
|
||||
</th>
|
||||
<th scope="col" style="min-width: 100px;">Поляризация</th>
|
||||
<th scope="col" style="min-width: 120px;">
|
||||
{% include 'mainapp/components/_sort_header.html' with field='sym_velocity' label='Сим. скорость, БОД' current_sort=sort %}
|
||||
</th>
|
||||
<th scope="col" style="min-width: 100px;">Модуляция</th>
|
||||
<th scope="col" style="min-width: 100px;">Стандарт</th>
|
||||
<th scope="col" style="min-width: 80px;">FEC</th>
|
||||
<th scope="col" style="min-width: 150px;">Описание</th>
|
||||
<th scope="col" style="min-width: 120px;">
|
||||
{% include 'mainapp/components/_sort_header.html' with field='last_update' label='Обновлено' current_sort=sort %}
|
||||
</th>
|
||||
<th scope="col" style="min-width: 100px;">Ссылка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in lyngsat_items %}
|
||||
<tr>
|
||||
<td class="text-center">{{ item.id }}</td>
|
||||
<td>
|
||||
{% if item.id_satellite %}
|
||||
<a href="#" class="text-decoration-underline"
|
||||
onclick="showSatelliteModal({{ item.id_satellite.id }}); return false;">
|
||||
{{ item.id_satellite.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.frequency|floatformat:3|default:"-" }}</td>
|
||||
<td>{{ item.polarization.name|default:"-" }}</td>
|
||||
<td>{{ item.sym_velocity|floatformat:0|default:"-" }}</td>
|
||||
<td>{{ item.modulation.name|default:"-" }}</td>
|
||||
<td>{{ item.standard.name|default:"-" }}</td>
|
||||
<td>{{ item.fec|default:"-" }}</td>
|
||||
<td>{{ item.channel_info|default:"-" }}</td>
|
||||
<td>{{ item.last_update|date:"d.m.Y"|default:"-" }}</td>
|
||||
<td>
|
||||
{% if item.url %}
|
||||
<a href="{{ item.url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="Открыть ссылку">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="11" class="text-center text-muted">Нет данных для отображения</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% load static %}
|
||||
<!-- Include sorting functionality -->
|
||||
<script src="{% static 'js/sorting.js' %}"></script>
|
||||
|
||||
<script>
|
||||
// Function to select/deselect all options in a select element
|
||||
function selectAllOptions(selectName, selectAll) {
|
||||
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
||||
if (selectElement) {
|
||||
for (let i = 0; i < selectElement.options.length; i++) {
|
||||
selectElement.options[i].selected = selectAll;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced filter counter for multi-select fields
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (form) {
|
||||
// Add event listeners to multi-select fields
|
||||
const selectFields = form.querySelectorAll('select[multiple]');
|
||||
selectFields.forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
// Trigger the filter counter update from _filter_panel.html
|
||||
const event = new Event('change', { bubbles: true });
|
||||
form.dispatchEvent(event);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Include the satellite modal component -->
|
||||
{% include 'mainapp/components/_satellite_modal.html' %}
|
||||
|
||||
{% endblock %}
|
||||
8
dbapp/lyngsatapp/urls.py
Normal file
8
dbapp/lyngsatapp/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'lyngsatapp'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.LyngSatListView.as_view(), name='lyngsat_list'),
|
||||
]
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from .parser import LyngSatParser
|
||||
from .models import LyngSat
|
||||
from mainapp.models import Polarization, Standard, Modulation, Satellite
|
||||
from dbapp.settings.base import FLARESOLVERR_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,7 +51,7 @@ def fill_lyngsat_data(
|
||||
|
||||
try:
|
||||
parser = LyngSatParser(
|
||||
flaresolver_url="http://localhost:8191/v1",
|
||||
flaresolver_url=FLARESOLVERR_URL,
|
||||
target_sats=target_sats,
|
||||
regions=regions
|
||||
)
|
||||
@@ -76,9 +77,13 @@ def fill_lyngsat_data(
|
||||
|
||||
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
|
||||
|
||||
# Находим спутник в базе
|
||||
# Находим спутник в базе по имени или альтернативному имени (lowercase)
|
||||
from django.db.models import Q
|
||||
sat_name_lower = sat_name.lower()
|
||||
try:
|
||||
sat_obj = Satellite.objects.get(name__icontains=sat_name)
|
||||
sat_obj = Satellite.objects.get(
|
||||
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
|
||||
)
|
||||
logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
||||
except Satellite.DoesNotExist:
|
||||
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
||||
|
||||
@@ -1,3 +1,285 @@
|
||||
from django.shortcuts import render
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q
|
||||
from django.views.generic import ListView
|
||||
|
||||
# Create your views here.
|
||||
from .models import LyngSat
|
||||
from mainapp.models import Satellite, Polarization, Modulation, Standard
|
||||
from mainapp.utils import parse_pagination_params
|
||||
|
||||
|
||||
class LyngSatListView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
Представление для отображения списка источников LyngSat с фильтрацией и пагинацией.
|
||||
"""
|
||||
model = LyngSat
|
||||
template_name = 'lyngsatapp/lyngsat_list.html'
|
||||
context_object_name = 'lyngsat_items'
|
||||
paginate_by = 50
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Возвращает отфильтрованный и отсортированный queryset.
|
||||
"""
|
||||
queryset = LyngSat.objects.select_related(
|
||||
'id_satellite',
|
||||
'polarization',
|
||||
'modulation',
|
||||
'standard'
|
||||
).all()
|
||||
|
||||
# Поиск по ID
|
||||
search_query = self.request.GET.get('search', '').strip()
|
||||
if search_query:
|
||||
try:
|
||||
search_id = int(search_query)
|
||||
queryset = queryset.filter(id=search_id)
|
||||
except ValueError:
|
||||
queryset = queryset.none()
|
||||
|
||||
# Фильтр по спутнику
|
||||
satellite_ids = self.request.GET.getlist('satellite_id')
|
||||
if satellite_ids:
|
||||
queryset = queryset.filter(id_satellite_id__in=satellite_ids)
|
||||
|
||||
# Фильтр по поляризации
|
||||
polarization_ids = self.request.GET.getlist('polarization_id')
|
||||
if polarization_ids:
|
||||
queryset = queryset.filter(polarization_id__in=polarization_ids)
|
||||
|
||||
# Фильтр по модуляции
|
||||
modulation_ids = self.request.GET.getlist('modulation_id')
|
||||
if modulation_ids:
|
||||
queryset = queryset.filter(modulation_id__in=modulation_ids)
|
||||
|
||||
# Фильтр по стандарту
|
||||
standard_ids = self.request.GET.getlist('standard_id')
|
||||
if standard_ids:
|
||||
queryset = queryset.filter(standard_id__in=standard_ids)
|
||||
|
||||
# Фильтр по частоте
|
||||
freq_min = self.request.GET.get('freq_min', '').strip()
|
||||
freq_max = self.request.GET.get('freq_max', '').strip()
|
||||
if freq_min:
|
||||
try:
|
||||
queryset = queryset.filter(frequency__gte=float(freq_min))
|
||||
except ValueError:
|
||||
pass
|
||||
if freq_max:
|
||||
try:
|
||||
queryset = queryset.filter(frequency__lte=float(freq_max))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Фильтр по символьной скорости
|
||||
sym_min = self.request.GET.get('sym_min', '').strip()
|
||||
sym_max = self.request.GET.get('sym_max', '').strip()
|
||||
if sym_min:
|
||||
try:
|
||||
queryset = queryset.filter(sym_velocity__gte=float(sym_min))
|
||||
except ValueError:
|
||||
pass
|
||||
if sym_max:
|
||||
try:
|
||||
queryset = queryset.filter(sym_velocity__lte=float(sym_max))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Фильтр по дате обновления
|
||||
date_from = self.request.GET.get('date_from', '').strip()
|
||||
date_to = self.request.GET.get('date_to', '').strip()
|
||||
if date_from:
|
||||
queryset = queryset.filter(last_update__gte=date_from)
|
||||
if date_to:
|
||||
queryset = queryset.filter(last_update__lte=date_to)
|
||||
|
||||
# Сортировка
|
||||
sort = self.request.GET.get('sort', '-id')
|
||||
valid_sort_fields = ['id', '-id', 'frequency', '-frequency', 'sym_velocity', '-sym_velocity', 'last_update', '-last_update']
|
||||
if sort in valid_sort_fields:
|
||||
queryset = queryset.order_by(sort)
|
||||
else:
|
||||
queryset = queryset.order_by('-id')
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Добавляет дополнительный контекст для шаблона.
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Параметры пагинации
|
||||
page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
|
||||
context['items_per_page'] = items_per_page
|
||||
context['available_items_per_page'] = [25, 50, 100, 200, 500]
|
||||
|
||||
# Пагинация
|
||||
paginator = Paginator(self.get_queryset(), items_per_page)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
context['page_obj'] = page_obj
|
||||
context['lyngsat_items'] = page_obj.object_list
|
||||
|
||||
# Параметры поиска и фильтрации
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
context['sort'] = self.request.GET.get('sort', '-id')
|
||||
|
||||
# Данные для фильтров - только спутники с существующими записями LyngSat
|
||||
satellites = Satellite.objects.filter(
|
||||
lyngsat__isnull=False
|
||||
).distinct().order_by('name')
|
||||
polarizations = Polarization.objects.all().order_by('name')
|
||||
modulations = Modulation.objects.all().order_by('name')
|
||||
standards = Standard.objects.all().order_by('name')
|
||||
|
||||
# Выбранные фильтры
|
||||
selected_satellites = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
|
||||
selected_polarizations = [int(x) for x in self.request.GET.getlist('polarization_id') if x.isdigit()]
|
||||
selected_modulations = [int(x) for x in self.request.GET.getlist('modulation_id') if x.isdigit()]
|
||||
selected_standards = [int(x) for x in self.request.GET.getlist('standard_id') if x.isdigit()]
|
||||
|
||||
# Параметры фильтров
|
||||
freq_min = self.request.GET.get('freq_min', '')
|
||||
freq_max = self.request.GET.get('freq_max', '')
|
||||
sym_min = self.request.GET.get('sym_min', '')
|
||||
sym_max = self.request.GET.get('sym_max', '')
|
||||
date_from = self.request.GET.get('date_from', '')
|
||||
date_to = self.request.GET.get('date_to', '')
|
||||
|
||||
# Action buttons HTML for toolbar component
|
||||
from django.urls import reverse
|
||||
action_buttons_html = f'''
|
||||
<a href="{reverse('mainapp:fill_lyngsat_data')}" class="btn btn-secondary btn-sm" title="Заполнить данные Lyngsat">
|
||||
<i class="bi bi-cloud-download"></i> Добавить данные
|
||||
</a>
|
||||
<a href="{reverse('mainapp:link_lyngsat')}" class="btn btn-primary btn-sm" title="Привязать источники LyngSat">
|
||||
<i class="bi bi-link-45deg"></i> Привязать
|
||||
</a>
|
||||
<a href="{reverse('mainapp:unlink_all_lyngsat')}" class="btn btn-warning btn-sm" title="Отвязать все источники LyngSat">
|
||||
<i class="bi bi-x-circle"></i> Отвязать
|
||||
</a>
|
||||
'''
|
||||
context['action_buttons_html'] = action_buttons_html
|
||||
|
||||
# Build filter HTML list for filter_panel component
|
||||
filter_html_list = []
|
||||
|
||||
# Satellite filter
|
||||
satellite_options = ''.join([
|
||||
f'<option value="{sat.id}" {"selected" if sat.id in selected_satellites else ""}>{sat.name}</option>'
|
||||
for sat in satellites
|
||||
])
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Спутник:</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('satellite_id', false)">Снять</button>
|
||||
</div>
|
||||
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
|
||||
{satellite_options}
|
||||
</select>
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Polarization filter
|
||||
polarization_options = ''.join([
|
||||
f'<option value="{pol.id}" {"selected" if pol.id in selected_polarizations else ""}>{pol.name}</option>'
|
||||
for pol in polarizations
|
||||
])
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Поляризация:</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('polarization_id', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('polarization_id', false)">Снять</button>
|
||||
</div>
|
||||
<select name="polarization_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||
{polarization_options}
|
||||
</select>
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Modulation filter
|
||||
modulation_options = ''.join([
|
||||
f'<option value="{mod.id}" {"selected" if mod.id in selected_modulations else ""}>{mod.name}</option>'
|
||||
for mod in modulations
|
||||
])
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Модуляция:</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('modulation_id', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('modulation_id', false)">Снять</button>
|
||||
</div>
|
||||
<select name="modulation_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||
{modulation_options}
|
||||
</select>
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Standard filter
|
||||
standard_options = ''.join([
|
||||
f'<option value="{std.id}" {"selected" if std.id in selected_standards else ""}>{std.name}</option>'
|
||||
for std in standards
|
||||
])
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Стандарт:</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('standard_id', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('standard_id', false)">Снять</button>
|
||||
</div>
|
||||
<select name="standard_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||
{standard_options}
|
||||
</select>
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Frequency filter
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Частота, МГц:</label>
|
||||
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1"
|
||||
placeholder="От" value="{freq_min}">
|
||||
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm"
|
||||
placeholder="До" value="{freq_max}">
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Symbol rate filter
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Символьная скорость, БОД:</label>
|
||||
<input type="number" step="0.001" name="sym_min" class="form-control form-control-sm mb-1"
|
||||
placeholder="От" value="{sym_min}">
|
||||
<input type="number" step="0.001" name="sym_max" class="form-control form-control-sm"
|
||||
placeholder="До" value="{sym_max}">
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Date filter
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Дата обновления:</label>
|
||||
<input type="date" name="date_from" id="date_from" class="form-control form-control-sm mb-1"
|
||||
placeholder="От" value="{date_from}">
|
||||
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm"
|
||||
placeholder="До" value="{date_to}">
|
||||
</div>
|
||||
''')
|
||||
|
||||
context['filter_html_list'] = filter_html_list
|
||||
|
||||
# Enable full width layout
|
||||
context['full_width_page'] = True
|
||||
|
||||
return context
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
# Third-party imports
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from sklearn.cluster import DBSCAN, HDBSCAN, KMeans
|
||||
|
||||
# Local imports
|
||||
from .models import ObjItem
|
||||
|
||||
def get_clusters(coords: list[tuple[float, float]]):
|
||||
coords = np.radians(coords)
|
||||
lat, lon = coords[:, 0], coords[:, 1]
|
||||
db = DBSCAN(eps=0.06, min_samples=5, algorithm='ball_tree', metric='haversine')
|
||||
# db = HDBSCAN()
|
||||
cluster_labels = db.fit_predict(coords)
|
||||
plt.figure(figsize=(10, 8))
|
||||
unique_labels = set(cluster_labels)
|
||||
colors = plt.cm.tab10(np.linspace(0, 1, len(unique_labels)))
|
||||
|
||||
for label, color in zip(unique_labels, colors):
|
||||
if label == -1:
|
||||
color = 'k'
|
||||
label_name = 'Шум'
|
||||
else:
|
||||
label_name = f'Кластер {label}'
|
||||
|
||||
mask = cluster_labels == label
|
||||
plt.scatter(lon[mask], lat[mask], c=[color], label=label_name, s=30)
|
||||
|
||||
plt.xlabel('Долгота')
|
||||
plt.ylabel('Широта')
|
||||
plt.title('Кластеризация геоданных с DBSCAN (метрика Хаверсина)')
|
||||
plt.legend()
|
||||
plt.grid(True)
|
||||
plt.show()
|
||||
File diff suppressed because it is too large
Load Diff
1
dbapp/mainapp/management/__init__.py
Normal file
1
dbapp/mainapp/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Management commands package
|
||||
1
dbapp/mainapp/management/commands/__init__.py
Normal file
1
dbapp/mainapp/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Commands package
|
||||
169
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
169
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Management command для генерации тестовых отметок сигналов.
|
||||
|
||||
Использование:
|
||||
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025
|
||||
|
||||
Параметры:
|
||||
--satellite_id: ID спутника (обязательный)
|
||||
--user_id: ID пользователя CustomUser (обязательный)
|
||||
--date_range: Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (обязательный)
|
||||
--clear: Удалить существующие отметки перед генерацией
|
||||
|
||||
Особенности:
|
||||
- Генерирует отметки только в будние дни (пн-пт)
|
||||
- Время отметок: утро с 8:00 до 11:00
|
||||
- Одна отметка в день для всех сигналов спутника
|
||||
- Все отметки в один день имеют одинаковый timestamp (пакетное сохранение)
|
||||
- Все отметки имеют значение True (сигнал присутствует)
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
|
||||
from mainapp.models import TechAnalyze, ObjectMark, Satellite, CustomUser
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Генерирует тестовые отметки сигналов для теханализов выбранного спутника'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--satellite_id',
|
||||
type=int,
|
||||
required=True,
|
||||
help='ID спутника для генерации отметок'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--user_id',
|
||||
type=int,
|
||||
required=True,
|
||||
help='ID пользователя CustomUser - автор всех отметок'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--date_range',
|
||||
type=str,
|
||||
required=True,
|
||||
help='Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--clear',
|
||||
action='store_true',
|
||||
help='Удалить существующие отметки перед генерацией'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
satellite_id = options['satellite_id']
|
||||
user_id = options['user_id']
|
||||
date_range = options['date_range']
|
||||
clear = options['clear']
|
||||
|
||||
# Проверяем существование пользователя
|
||||
try:
|
||||
custom_user = CustomUser.objects.select_related('user').get(id=user_id)
|
||||
except CustomUser.DoesNotExist:
|
||||
raise CommandError(f'Пользователь CustomUser с ID {user_id} не найден')
|
||||
|
||||
# Парсим диапазон дат
|
||||
try:
|
||||
start_str, end_str = date_range.split('-')
|
||||
start_date = datetime.strptime(start_str.strip(), '%d.%m.%Y')
|
||||
end_date = datetime.strptime(end_str.strip(), '%d.%m.%Y')
|
||||
|
||||
# Делаем timezone-aware
|
||||
start_date = timezone.make_aware(start_date)
|
||||
end_date = timezone.make_aware(end_date)
|
||||
|
||||
if start_date > end_date:
|
||||
raise CommandError('Начальная дата должна быть раньше конечной')
|
||||
|
||||
except ValueError as e:
|
||||
raise CommandError(
|
||||
f'Неверный формат даты. Используйте ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025). Ошибка: {e}'
|
||||
)
|
||||
|
||||
# Проверяем существование спутника
|
||||
try:
|
||||
satellite = Satellite.objects.get(id=satellite_id)
|
||||
except Satellite.DoesNotExist:
|
||||
raise CommandError(f'Спутник с ID {satellite_id} не найден')
|
||||
|
||||
# Получаем теханализы для спутника
|
||||
tech_analyzes = list(TechAnalyze.objects.filter(satellite=satellite))
|
||||
ta_count = len(tech_analyzes)
|
||||
|
||||
if ta_count == 0:
|
||||
raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
|
||||
|
||||
self.stdout.write(f'Спутник: {satellite.name}')
|
||||
self.stdout.write(f'Теханализов: {ta_count}')
|
||||
self.stdout.write(f'Пользователь: {custom_user}')
|
||||
self.stdout.write(f'Период: {start_str} - {end_str} (только будние дни)')
|
||||
self.stdout.write(f'Время: 8:00 - 11:00')
|
||||
|
||||
# Удаляем существующие отметки если указан флаг
|
||||
if clear:
|
||||
deleted_count = ObjectMark.objects.filter(
|
||||
tech_analyze__satellite=satellite
|
||||
).delete()[0]
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
|
||||
)
|
||||
|
||||
# Генерируем отметки
|
||||
total_marks = 0
|
||||
marks_to_create = []
|
||||
workdays_count = 0
|
||||
|
||||
current_date = start_date
|
||||
# Включаем конечную дату в диапазон
|
||||
end_date_inclusive = end_date + timedelta(days=1)
|
||||
|
||||
while current_date < end_date_inclusive:
|
||||
# Проверяем, что это будний день (0=пн, 4=пт)
|
||||
if current_date.weekday() < 5:
|
||||
workdays_count += 1
|
||||
|
||||
# Генерируем случайное время в диапазоне 8:00-11:00
|
||||
random_hour = random.randint(8, 10)
|
||||
random_minute = random.randint(0, 59)
|
||||
random_second = random.randint(0, 59)
|
||||
|
||||
mark_time = current_date.replace(
|
||||
hour=random_hour,
|
||||
minute=random_minute,
|
||||
second=random_second,
|
||||
microsecond=0
|
||||
)
|
||||
|
||||
# Создаём отметки для всех теханализов с одинаковым timestamp
|
||||
for ta in tech_analyzes:
|
||||
marks_to_create.append(ObjectMark(
|
||||
tech_analyze=ta,
|
||||
mark=True, # Всегда True
|
||||
timestamp=mark_time,
|
||||
created_by=custom_user,
|
||||
))
|
||||
total_marks += 1
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Bulk create для производительности
|
||||
self.stdout.write(f'Рабочих дней: {workdays_count}')
|
||||
self.stdout.write(f'Создание {total_marks} отметок...')
|
||||
|
||||
# Создаём партиями по 1000
|
||||
batch_size = 1000
|
||||
for i in range(0, len(marks_to_create), batch_size):
|
||||
batch = marks_to_create[i:i + batch_size]
|
||||
ObjectMark.objects.bulk_create(batch)
|
||||
self.stdout.write(f' Создано: {min(i + batch_size, len(marks_to_create))}/{total_marks}')
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Успешно создано {total_marks} отметок для {ta_count} теханализов за {workdays_count} рабочих дней'
|
||||
)
|
||||
)
|
||||
@@ -1,10 +1,9 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-31 13:36
|
||||
# Generated by Django 5.2.7 on 2025-11-12 14:21
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.contrib.gis.db.models.functions
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.db.models.expressions
|
||||
import mainapp.models
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
@@ -14,116 +13,57 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('lyngsatapp', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Band',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Название диапазона', max_length=50, unique=True, verbose_name='Название')),
|
||||
('border_start', models.FloatField(blank=True, null=True, verbose_name='Нижняя граница диапазона, МГц')),
|
||||
('border_end', models.FloatField(blank=True, null=True, verbose_name='Верхняя граница диапазона, МГц')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Диапазон',
|
||||
'verbose_name_plural': 'Диапазоны',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Mirror',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=30, unique=True, verbose_name='Имя зеркала')),
|
||||
('name', models.CharField(db_index=True, help_text='Уникальное название зеркала антенны', max_length=30, unique=True, verbose_name='Имя зеркала')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Зеркало',
|
||||
'verbose_name_plural': 'Зеркала',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Modulation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(db_index=True, max_length=20, unique=True, verbose_name='Модуляция')),
|
||||
('name', models.CharField(db_index=True, help_text='Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)', max_length=20, unique=True, verbose_name='Модуляция')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Модуляция',
|
||||
'verbose_name_plural': 'Модуляции',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Polarization',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=20, unique=True, verbose_name='Поляризация')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Поляризация',
|
||||
'verbose_name_plural': 'Поляризация',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Satellite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(db_index=True, max_length=100, unique=True, verbose_name='Имя спутника')),
|
||||
('norad', models.IntegerField(blank=True, null=True, verbose_name='NORAD ID')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Спутник',
|
||||
'verbose_name_plural': 'Спутники',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SigmaParMark',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('mark', models.BooleanField(blank=True, null=True, verbose_name='Наличие сигнала')),
|
||||
('timestamp', models.DateTimeField(blank=True, null=True, verbose_name='Время')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Отметка',
|
||||
'verbose_name_plural': 'Отметки',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Standard',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=20, unique=True, verbose_name='Стандарт')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Стандарт',
|
||||
'verbose_name_plural': 'Стандарты',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], default='user', max_length=20, verbose_name='Роль пользователя')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Пользователь',
|
||||
'verbose_name_plural': 'Пользователи',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ObjItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Имя объекта')),
|
||||
('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems', to='mainapp.customuser', verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Объект',
|
||||
'verbose_name_plural': 'Объекты',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Parameter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('frequency', models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц')),
|
||||
('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')),
|
||||
('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')),
|
||||
('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ')),
|
||||
('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parameter_added', to='mainapp.customuser', verbose_name='Пользователь')),
|
||||
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations', to='mainapp.modulation', verbose_name='Модуляция')),
|
||||
('objitems', models.ManyToManyField(blank=True, related_name='parameters_obj', to='mainapp.objitem', verbose_name='Источники')),
|
||||
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
|
||||
('id_satellite', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='mainapp.satellite', verbose_name='Спутник')),
|
||||
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards', to='mainapp.standard', verbose_name='Стандарт')),
|
||||
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
|
||||
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
|
||||
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД')),
|
||||
('snr', models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум', null=True, verbose_name='ОСШ')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ВЧ загрузка',
|
||||
@@ -131,74 +71,141 @@ class Migration(migrations.Migration):
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SourceType',
|
||||
name='Polarization',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True, verbose_name='Тип источника')),
|
||||
('objitem', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_type_obj', to='mainapp.objitem', verbose_name='Гео')),
|
||||
('name', models.CharField(db_index=True, help_text='Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)', max_length=20, unique=True, verbose_name='Поляризация')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тип источника',
|
||||
'verbose_name_plural': 'Типы источников',
|
||||
'verbose_name': 'Поляризация',
|
||||
'verbose_name_plural': 'Поляризация',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Satellite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(db_index=True, help_text='Название спутника', max_length=100, unique=True, verbose_name='Имя спутника')),
|
||||
('norad', models.IntegerField(blank=True, help_text='Идентификатор NORAD для отслеживания спутника', null=True, verbose_name='NORAD ID')),
|
||||
('undersat_point', models.FloatField(blank=True, help_text='Подспутниковая точка в градусах. Восточное полушарие с +, западное с -', null=True, verbose_name='Подспутниковая точка, градусы')),
|
||||
('url', models.URLField(blank=True, help_text='Ссылка на сайт, где можно проверить информацию', null=True, verbose_name='Ссылка на источник')),
|
||||
('comment', models.TextField(blank=True, help_text='Любой возможный комменатрий', null=True, verbose_name='Комментарий')),
|
||||
('launch_date', models.DateField(blank=True, help_text='Дата запуска спутника', null=True, verbose_name='Дата запуска')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Спутник',
|
||||
'verbose_name_plural': 'Спутники',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SigmaParameter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('transfer', models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, verbose_name='Перенос по частоте')),
|
||||
('status', models.CharField(blank=True, max_length=20, null=True, verbose_name='Статус')),
|
||||
('frequency', models.FloatField(blank=True, db_index=True, default=0, null=True, verbose_name='Частота, МГц')),
|
||||
('transfer', models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, help_text='Выберите перенос по частоте', verbose_name='Перенос по частоте')),
|
||||
('status', models.CharField(blank=True, help_text='Статус измерения', max_length=20, null=True, verbose_name='Статус')),
|
||||
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
|
||||
('transfer_frequency', models.GeneratedField(db_persist=True, expression=models.ExpressionWrapper(django.db.models.expressions.CombinedExpression(models.F('frequency'), '+', models.F('transfer')), output_field=models.FloatField()), null=True, output_field=models.FloatField(), verbose_name='Частота в Ku, МГц')),
|
||||
('freq_range', models.FloatField(blank=True, default=0, null=True, verbose_name='Полоса частот, МГц')),
|
||||
('power', models.FloatField(blank=True, default=0, null=True, verbose_name='Мощность, дБм')),
|
||||
('bod_velocity', models.FloatField(blank=True, default=0, null=True, verbose_name='Символьная скорость, БОД')),
|
||||
('snr', models.FloatField(blank=True, default=0, null=True, verbose_name='ОСШ, Дб')),
|
||||
('packets', models.BooleanField(blank=True, null=True, verbose_name='Пакетность')),
|
||||
('datetime_begin', models.DateTimeField(blank=True, null=True, verbose_name='Время начала измерения')),
|
||||
('datetime_end', models.DateTimeField(blank=True, null=True, verbose_name='Время окончания измерения')),
|
||||
('id_satellite', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sigmapar_sat', to='mainapp.satellite', verbose_name='Спутник')),
|
||||
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations_sigma', to='mainapp.modulation', verbose_name='Модуляция')),
|
||||
('parameter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.parameter', verbose_name='ВЧ')),
|
||||
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations_sigma', to='mainapp.polarization', verbose_name='Поляризация')),
|
||||
('mark', models.ManyToManyField(blank=True, to='mainapp.sigmaparmark', verbose_name='Отметка')),
|
||||
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards_sigma', to='mainapp.standard', verbose_name='Стандарт')),
|
||||
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот', null=True, verbose_name='Полоса частот, МГц')),
|
||||
('power', models.FloatField(blank=True, default=0, help_text='Мощность сигнала', null=True, verbose_name='Мощность, дБм')),
|
||||
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД')),
|
||||
('snr', models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ, Дб')),
|
||||
('packets', models.BooleanField(blank=True, help_text='Наличие пакетной передачи', null=True, verbose_name='Пакетность')),
|
||||
('datetime_begin', models.DateTimeField(blank=True, help_text='Дата и время начала измерения', null=True, verbose_name='Время начала измерения')),
|
||||
('datetime_end', models.DateTimeField(blank=True, help_text='Дата и время окончания измерения', null=True, verbose_name='Время окончания измерения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'ВЧ sigma',
|
||||
'verbose_name_plural': 'ВЧ sigma',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SigmaParMark',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('mark', models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала')),
|
||||
('timestamp', models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Отметка',
|
||||
'verbose_name_plural': 'Отметки',
|
||||
'ordering': ['-timestamp'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Source',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('coords_kupsat', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, полученные от кубсата (WGS84)', null=True, srid=4326, verbose_name='Координаты Кубсата')),
|
||||
('coords_valid', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, предоставленные оперативным отделом (WGS84)', null=True, srid=4326, verbose_name='Координаты оперативников')),
|
||||
('coords_reference', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, ещё кем-то проверенные (WGS84)', null=True, srid=4326, verbose_name='Координаты справочные')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Источник',
|
||||
'verbose_name_plural': 'Источники',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Standard',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=20, unique=True, verbose_name='Стандарт')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Стандарт',
|
||||
'verbose_name_plural': 'Стандарты',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CustomUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], db_index=True, default='user', help_text='Роль пользователя в системе', max_length=20, verbose_name='Роль пользователя')),
|
||||
('user', models.OneToOneField(help_text='Связанный пользователь Django', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Пользователь',
|
||||
'verbose_name_plural': 'Пользователи',
|
||||
'ordering': ['user__username'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Geo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('timestamp', models.DateTimeField(blank=True, db_index=True, null=True, verbose_name='Время')),
|
||||
('coords', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координата геолокации')),
|
||||
('location', models.CharField(blank=True, max_length=255, null=True, verbose_name='Метоположение')),
|
||||
('comment', models.CharField(blank=True, max_length=255, verbose_name='Комментарий')),
|
||||
('coords_kupsat', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты Кубсата')),
|
||||
('coords_valid', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=4326, verbose_name='Координаты оперативников')),
|
||||
('is_average', models.BooleanField(blank=True, null=True, verbose_name='Усреднённое')),
|
||||
('distance_coords_kup', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между купсатом и гео, км')),
|
||||
('distance_coords_valid', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_valid'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между гео и оперативным отделом, км')),
|
||||
('distance_kup_valid', models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords_valid', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между купсатом и оперативным отделом, км')),
|
||||
('id_user_add', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='geos_added', to='mainapp.customuser', verbose_name='Пользователь')),
|
||||
('mirrors', models.ManyToManyField(related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала')),
|
||||
('objitem', models.OneToOneField(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='geo_obj', to='mainapp.objitem', verbose_name='Гео')),
|
||||
('timestamp', models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации геолокации', null=True, verbose_name='Время')),
|
||||
('location', models.CharField(blank=True, help_text='Текстовое описание местоположения', max_length=255, null=True, verbose_name='Местоположение')),
|
||||
('comment', models.CharField(blank=True, help_text='Дополнительные комментарии', max_length=255, verbose_name='Комментарий')),
|
||||
('is_average', models.BooleanField(blank=True, help_text='Является ли координата усредненной', null=True, verbose_name='Усреднённое')),
|
||||
('coords', django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Основные координаты геолокации (WGS84)', null=True, srid=4326, verbose_name='Координата геолокации')),
|
||||
('mirrors', models.ManyToManyField(blank=True, help_text='Зеркала антенн, использованные для приема', related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Гео',
|
||||
'verbose_name_plural': 'Гео',
|
||||
'constraints': [models.UniqueConstraint(fields=('timestamp', 'coords'), name='unique_geo_combination')],
|
||||
'ordering': ['-timestamp'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='parameter',
|
||||
index=models.Index(fields=['id_satellite', 'frequency'], name='mainapp_par_id_sate_cbfab2_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='parameter',
|
||||
index=models.Index(fields=['frequency', 'polarization'], name='mainapp_par_frequen_75a049_idx'),
|
||||
migrations.CreateModel(
|
||||
name='ObjItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, db_index=True, help_text='Название объекта/источника сигнала', max_length=100, null=True, verbose_name='Имя объекта')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||
('lyngsat_source', models.ForeignKey(blank=True, help_text='Связанный источник из базы LyngSat (ТВ)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems', to='lyngsatapp.lyngsat', verbose_name='Источник LyngSat')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Объект',
|
||||
'verbose_name_plural': 'Объекты',
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
150
dbapp/mainapp/migrations/0002_initial.py
Normal file
150
dbapp/mainapp/migrations/0002_initial.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-12 14:21
|
||||
|
||||
import django.db.models.deletion
|
||||
import mainapp.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0001_initial'),
|
||||
('mapsapp', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='transponder',
|
||||
field=models.ForeignKey(blank=True, help_text='Транспондер, с помощью которого была получена точка', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transponder', to='mapsapp.transponders', verbose_name='Транспондер'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='geo',
|
||||
name='objitem',
|
||||
field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Объект'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parameter',
|
||||
name='modulation',
|
||||
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations', to='mainapp.modulation', verbose_name='Модуляция'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parameter',
|
||||
name='objitem',
|
||||
field=models.OneToOneField(blank=True, help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parameter_obj', to='mainapp.objitem', verbose_name='Объект'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parameter',
|
||||
name='polarization',
|
||||
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations', to='mainapp.polarization', verbose_name='Поляризация'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='satellite',
|
||||
name='band',
|
||||
field=models.ManyToManyField(blank=True, help_text='Диапазоны работы спутника', related_name='bands', to='mainapp.band', verbose_name='Диапазоны'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='satellite',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='satellite',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parameter',
|
||||
name='id_satellite',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='parameters', to='mainapp.satellite', verbose_name='Спутник'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigmaparameter',
|
||||
name='id_satellite',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sigmapar_sat', to='mainapp.satellite', verbose_name='Спутник'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigmaparameter',
|
||||
name='modulation',
|
||||
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='modulations_sigma', to='mainapp.modulation', verbose_name='Модуляция'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigmaparameter',
|
||||
name='parameter',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sigma_parameter', to='mainapp.parameter', verbose_name='ВЧ'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigmaparameter',
|
||||
name='polarization',
|
||||
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='polarizations_sigma', to='mainapp.polarization', verbose_name='Поляризация'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigmaparameter',
|
||||
name='mark',
|
||||
field=models.ManyToManyField(blank=True, to='mainapp.sigmaparmark', verbose_name='Отметка'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='source',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source', to='mainapp.source', verbose_name='ИРИ'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigmaparameter',
|
||||
name='standard',
|
||||
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards_sigma', to='mainapp.standard', verbose_name='Стандарт'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parameter',
|
||||
name='standard',
|
||||
field=models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='standards', to='mainapp.standard', verbose_name='Стандарт'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='geo',
|
||||
index=models.Index(fields=['-timestamp'], name='mainapp_geo_timesta_58a605_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='geo',
|
||||
index=models.Index(fields=['location'], name='mainapp_geo_locatio_b855c9_idx'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='geo',
|
||||
constraint=models.UniqueConstraint(fields=('timestamp', 'coords'), name='unique_geo_combination'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='objitem',
|
||||
index=models.Index(fields=['name'], name='mainapp_obj_name_e4f1e1_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='objitem',
|
||||
index=models.Index(fields=['-updated_at'], name='mainapp_obj_updated_f46b0e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='objitem',
|
||||
index=models.Index(fields=['-created_at'], name='mainapp_obj_created_cba553_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='parameter',
|
||||
index=models.Index(fields=['id_satellite', 'frequency'], name='mainapp_par_id_sate_cbfab2_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='parameter',
|
||||
index=models.Index(fields=['frequency', 'polarization'], name='mainapp_par_frequen_75a049_idx'),
|
||||
),
|
||||
]
|
||||
@@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-31 13:56
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата создания'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='Дата последнего изменения'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-31 14:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0002_objitem_created_at_objitem_created_by_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='objitem',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name='Дата создания'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objitem',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, verbose_name='Дата последнего изменения'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-12 19:41
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0002_initial'),
|
||||
('mapsapp', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='coords_average',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты, полученные от в ходе геолокации (WGS84)', null=True, srid=4326, verbose_name='Координаты ГЛ'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objitem',
|
||||
name='source',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_objitems', to='mainapp.source', verbose_name='ИРИ'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objitem',
|
||||
name='transponder',
|
||||
field=models.ForeignKey(blank=True, help_text='Транспондер, с помощью которого была получена точка', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transponder_objitems', to='mapsapp.transponders', verbose_name='Транспондер'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-13 14:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0003_source_coords_average_alter_objitem_source_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='mirrors',
|
||||
field=models.ManyToManyField(blank=True, help_text='Спутники-зеркала, использованные для приема', related_name='geo_mirrors', to='mainapp.satellite', verbose_name='Зеркала'),
|
||||
),
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-01 07:38
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0003_alter_objitem_created_at_alter_objitem_updated_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='geo',
|
||||
name='id_user_add',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='objitem',
|
||||
name='id_user_add',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='parameter',
|
||||
name='id_user_add',
|
||||
),
|
||||
]
|
||||
@@ -1,19 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-07 19:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0004_remove_geo_id_user_add_remove_objitem_id_user_add_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='objitem',
|
||||
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Гео'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-16 10:01
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0004_change_geo_mirrors_to_satellites'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='sigmaparmark',
|
||||
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка сигнала', 'verbose_name_plural': 'Отметки сигналов'},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ObjectMark',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('mark', models.BooleanField(blank=True, help_text='True - объект обнаружен, False - объект отсутствует', null=True, verbose_name='Наличие объекта')),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Время фиксации отметки', verbose_name='Время')),
|
||||
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший отметку', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='marks_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||
('objitem', models.ForeignKey(help_text='Связанный объект', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.objitem', verbose_name='Объект')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Отметка объекта',
|
||||
'verbose_name_plural': 'Отметки объектов',
|
||||
'ordering': ['-timestamp'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,290 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-07 20:58
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.contrib.gis.db.models.functions
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
import django.db.models.expressions
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0005_alter_geo_objitem'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='customuser',
|
||||
options={'ordering': ['user__username'], 'verbose_name': 'Пользователь', 'verbose_name_plural': 'Пользователи'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='geo',
|
||||
options={'ordering': ['-timestamp'], 'verbose_name': 'Гео', 'verbose_name_plural': 'Гео'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='mirror',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Зеркало', 'verbose_name_plural': 'Зеркала'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='modulation',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Модуляция', 'verbose_name_plural': 'Модуляции'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='objitem',
|
||||
options={'ordering': ['-updated_at'], 'verbose_name': 'Объект', 'verbose_name_plural': 'Объекты'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='polarization',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Поляризация', 'verbose_name_plural': 'Поляризация'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='satellite',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Спутник', 'verbose_name_plural': 'Спутники'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='sigmaparmark',
|
||||
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка', 'verbose_name_plural': 'Отметки'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='sourcetype',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Тип источника', 'verbose_name_plural': 'Типы источников'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='standard',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Стандарт', 'verbose_name_plural': 'Стандарты'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customuser',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('admin', 'Администратор'), ('moderator', 'Модератор'), ('user', 'Пользователь')], db_index=True, default='user', help_text='Роль пользователя в системе', max_length=20, verbose_name='Роль пользователя'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='customuser',
|
||||
name='user',
|
||||
field=models.OneToOneField(help_text='Связанный пользователь Django', on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='comment',
|
||||
field=models.CharField(blank=True, help_text='Дополнительные комментарии', max_length=255, verbose_name='Комментарий'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='coords',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Основные координаты геолокации (WGS84)', null=True, srid=4326, verbose_name='Координата геолокации'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='coords_kupsat',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, полученные от кубсата (WGS84)', null=True, srid=4326, verbose_name='Координаты Кубсата'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='coords_valid',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты, предоставленные оперативным отделом (WGS84)', null=True, srid=4326, verbose_name='Координаты оперативников'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='distance_coords_kup',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между кубсатом и гео, км'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='distance_kup_valid',
|
||||
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.contrib.gis.db.models.functions.Distance('coords_valid', 'coords_kupsat'), '/', models.Value(1000)), null=True, output_field=models.FloatField(), verbose_name='Расстояние между кубсатом и оперативным отделом, км'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='is_average',
|
||||
field=models.BooleanField(blank=True, help_text='Является ли координата усредненной', null=True, verbose_name='Усреднённое'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='location',
|
||||
field=models.CharField(blank=True, help_text='Текстовое описание местоположения', max_length=255, null=True, verbose_name='Местоположение'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='mirrors',
|
||||
field=models.ManyToManyField(blank=True, help_text='Зеркала антенн, использованные для приема', related_name='geo_mirrors', to='mainapp.mirror', verbose_name='Зеркала'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='objitem',
|
||||
field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='geo_obj', to='mainapp.objitem', verbose_name='Объект'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='geo',
|
||||
name='timestamp',
|
||||
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации геолокации', null=True, verbose_name='Время'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='mirror',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, help_text='Уникальное название зеркала антенны', max_length=30, unique=True, verbose_name='Имя зеркала'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='modulation',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, help_text='Тип модуляции сигнала (QPSK, 8PSK, 16APSK и т.д.)', max_length=20, unique=True, verbose_name='Модуляция'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objitem',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objitem',
|
||||
name='created_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_created', to='mainapp.customuser', verbose_name='Создан пользователем'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objitem',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='Название объекта/источника сигнала', max_length=100, null=True, verbose_name='Имя объекта'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objitem',
|
||||
name='updated_at',
|
||||
field=models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objitem',
|
||||
name='updated_by',
|
||||
field=models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_updated', to='mainapp.customuser', verbose_name='Изменен пользователем'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parameter',
|
||||
name='bod_velocity',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Символьная скорость, БОД'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parameter',
|
||||
name='freq_range',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Полоса частот в диапазоне от 0 до 1000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса частот, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parameter',
|
||||
name='frequency',
|
||||
field=models.FloatField(blank=True, db_index=True, default=0, help_text='Частота в диапазоне от 0 до 50000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Частота, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parameter',
|
||||
name='snr',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='polarization',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, help_text='Тип поляризации (H - горизонтальная, V - вертикальная, L - левая круговая, R - правая круговая)', max_length=20, unique=True, verbose_name='Поляризация'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='satellite',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, help_text='Название спутника', max_length=100, unique=True, verbose_name='Имя спутника'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='satellite',
|
||||
name='norad',
|
||||
field=models.IntegerField(blank=True, help_text='Идентификатор NORAD для отслеживания спутника', null=True, verbose_name='NORAD ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='bod_velocity',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, validators=[django.core.validators.MinValueValidator(0)], verbose_name='Символьная скорость, БОД'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='datetime_begin',
|
||||
field=models.DateTimeField(blank=True, help_text='Дата и время начала измерения', null=True, verbose_name='Время начала измерения'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='datetime_end',
|
||||
field=models.DateTimeField(blank=True, help_text='Дата и время окончания измерения', null=True, verbose_name='Время окончания измерения'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='freq_range',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Полоса частот в диапазоне от 0 до 1000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)], verbose_name='Полоса частот, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='frequency',
|
||||
field=models.FloatField(blank=True, db_index=True, default=0, help_text='Частота в диапазоне от 0 до 50000 МГц', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(50000)], verbose_name='Частота, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='packets',
|
||||
field=models.BooleanField(blank=True, help_text='Наличие пакетной передачи', null=True, verbose_name='Пакетность'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='power',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Мощность сигнала в диапазоне от -100 до 100 дБм', null=True, validators=[django.core.validators.MinValueValidator(-100), django.core.validators.MaxValueValidator(100)], verbose_name='Мощность, дБм'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='snr',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум в диапазоне от -50 до 100 дБ', null=True, validators=[django.core.validators.MinValueValidator(-50), django.core.validators.MaxValueValidator(100)], verbose_name='ОСШ, Дб'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='status',
|
||||
field=models.CharField(blank=True, help_text='Статус измерения', max_length=20, null=True, verbose_name='Статус'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='transfer',
|
||||
field=models.FloatField(choices=[(-1.0, '-'), (9750.0, '9750 МГц'), (10750.0, '10750 МГц')], default=-1.0, help_text='Выберите перенос по частоте', verbose_name='Перенос по частоте'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparmark',
|
||||
name='mark',
|
||||
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparmark',
|
||||
name='timestamp',
|
||||
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcetype',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, help_text='Тип источника сигнала', max_length=50, unique=True, verbose_name='Тип источника'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcetype',
|
||||
name='objitem',
|
||||
field=models.OneToOneField(help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_type_obj', to='mainapp.objitem', verbose_name='Объект'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='standard',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=20, unique=True, verbose_name='Стандарт'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='geo',
|
||||
index=models.Index(fields=['-timestamp'], name='mainapp_geo_timesta_58a605_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='geo',
|
||||
index=models.Index(fields=['location'], name='mainapp_geo_locatio_b855c9_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='objitem',
|
||||
index=models.Index(fields=['name'], name='mainapp_obj_name_e4f1e1_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='objitem',
|
||||
index=models.Index(fields=['-updated_at'], name='mainapp_obj_updated_f46b0e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='objitem',
|
||||
index=models.Index(fields=['-created_at'], name='mainapp_obj_created_cba553_idx'),
|
||||
),
|
||||
]
|
||||
27
dbapp/mainapp/migrations/0006_change_objectmark_to_source.py
Normal file
27
dbapp/mainapp/migrations/0006_change_objectmark_to_source.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-16 15:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0005_alter_sigmaparmark_options_objectmark'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='objectmark',
|
||||
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка источника', 'verbose_name_plural': 'Отметки источников'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='objectmark',
|
||||
name='objitem',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objectmark',
|
||||
name='source',
|
||||
field=models.ForeignKey(help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
|
||||
),
|
||||
]
|
||||
19
dbapp/mainapp/migrations/0007_make_source_required.py
Normal file
19
dbapp/mainapp/migrations/0007_make_source_required.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-16 15:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0006_change_objectmark_to_source'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='objectmark',
|
||||
name='source',
|
||||
field=models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
|
||||
),
|
||||
]
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-10 18:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0006_alter_customuser_options_alter_geo_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='parameter',
|
||||
name='objitems',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='parameter',
|
||||
name='objitem',
|
||||
field=models.OneToOneField(blank=True, help_text='Связанный объект', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='parameter_obj', to='mainapp.objitem', verbose_name='Объект'),
|
||||
),
|
||||
]
|
||||
31
dbapp/mainapp/migrations/0008_objectinfo_source_info.py
Normal file
31
dbapp/mainapp/migrations/0008_objectinfo_source_info.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-17 12:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0007_make_source_required'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ObjectInfo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Информация о типе объекта', max_length=255, unique=True, verbose_name='Тип объекта')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тип объекта',
|
||||
'verbose_name_plural': 'Типы объектов',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='info',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
|
||||
),
|
||||
]
|
||||
@@ -1,63 +0,0 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-11 13:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0007_remove_parameter_objitems_parameter_objitem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='sourcetype',
|
||||
name='objitem',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='source_type_id',
|
||||
field=models.ForeignKey(blank=True, help_text='Тип источника сигнала', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objitems_sourcetype', to='mainapp.sourcetype', verbose_name='Тип источника'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parameter',
|
||||
name='bod_velocity',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parameter',
|
||||
name='freq_range',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parameter',
|
||||
name='frequency',
|
||||
field=models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='parameter',
|
||||
name='snr',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Отношение сигнал/шум', null=True, verbose_name='ОСШ'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='bod_velocity',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Символьная скорость должна быть положительной', null=True, verbose_name='Символьная скорость, БОД'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='freq_range',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Полоса частот', null=True, verbose_name='Полоса частот, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='frequency',
|
||||
field=models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sigmaparameter',
|
||||
name='power',
|
||||
field=models.FloatField(blank=True, default=0, help_text='Мощность сигнала', null=True, verbose_name='Мощность, дБм'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-20 11:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0008_objectinfo_source_info'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ObjectOwnership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Принадлежность объекта (страна, организация и т.д.)', max_length=255, unique=True, verbose_name='Принадлежность')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Принадлежность объекта',
|
||||
'verbose_name_plural': 'Принадлежности объектов',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='source',
|
||||
name='info',
|
||||
field=models.ForeignKey(blank=True, help_text='Тип объекта', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='ownership',
|
||||
field=models.ForeignKey(blank=True, help_text='Принадлежность объекта (страна, организация и т.д.)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_ownership', to='mainapp.objectownership', verbose_name='Принадлежность объекта'),
|
||||
),
|
||||
]
|
||||
38
dbapp/mainapp/migrations/0010_set_default_source_type.py
Normal file
38
dbapp/mainapp/migrations/0010_set_default_source_type.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-21 07:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_default_source_type(apps, schema_editor):
|
||||
"""
|
||||
Устанавливает тип "Стационарные" для всех Source, у которых не указан тип.
|
||||
"""
|
||||
Source = apps.get_model('mainapp', 'Source')
|
||||
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||
|
||||
# Создаем или получаем тип "Стационарные"
|
||||
stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
|
||||
|
||||
# Обновляем все Source без типа
|
||||
sources_without_type = Source.objects.filter(info__isnull=True)
|
||||
count = sources_without_type.update(info=stationary_info)
|
||||
|
||||
print(f"Обновлено {count} источников с типом 'Стационарные'")
|
||||
|
||||
|
||||
def reverse_set_default_source_type(apps, schema_editor):
|
||||
"""
|
||||
Обратная миграция - ничего не делаем, так как это безопасная операция.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0009_objectownership_alter_source_info_source_ownership'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_default_source_type, reverse_set_default_source_type),
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-21 07:42
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fix_capitalization(apps, schema_editor):
|
||||
"""
|
||||
Исправляет регистр типов объектов: "стационарные" -> "Стационарные", "подвижные" -> "Подвижные"
|
||||
"""
|
||||
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||
Source = apps.get_model('mainapp', 'Source')
|
||||
|
||||
# Создаем правильные типы с большой буквы
|
||||
stationary_new, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
|
||||
mobile_new, _ = ObjectInfo.objects.get_or_create(name="Подвижные")
|
||||
|
||||
# Находим старые типы с маленькой буквы
|
||||
try:
|
||||
stationary_old = ObjectInfo.objects.get(name="стационарные")
|
||||
# Обновляем все Source, которые используют старый тип
|
||||
count = Source.objects.filter(info=stationary_old).update(info=stationary_new)
|
||||
print(f"Обновлено {count} источников: 'стационарные' -> 'Стационарные'")
|
||||
# Удаляем старый тип
|
||||
stationary_old.delete()
|
||||
except ObjectInfo.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
mobile_old = ObjectInfo.objects.get(name="подвижные")
|
||||
# Обновляем все Source, которые используют старый тип
|
||||
count = Source.objects.filter(info=mobile_old).update(info=mobile_new)
|
||||
print(f"Обновлено {count} источников: 'подвижные' -> 'Подвижные'")
|
||||
# Удаляем старый тип
|
||||
mobile_old.delete()
|
||||
except ObjectInfo.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
def reverse_fix_capitalization(apps, schema_editor):
|
||||
"""
|
||||
Обратная миграция - возвращаем маленькие буквы
|
||||
"""
|
||||
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||
Source = apps.get_model('mainapp', 'Source')
|
||||
|
||||
# Создаем типы с маленькой буквы
|
||||
stationary_old, _ = ObjectInfo.objects.get_or_create(name="стационарные")
|
||||
mobile_old, _ = ObjectInfo.objects.get_or_create(name="подвижные")
|
||||
|
||||
# Находим типы с большой буквы
|
||||
try:
|
||||
stationary_new = ObjectInfo.objects.get(name="Стационарные")
|
||||
Source.objects.filter(info=stationary_new).update(info=stationary_old)
|
||||
stationary_new.delete()
|
||||
except ObjectInfo.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
mobile_new = ObjectInfo.objects.get(name="Подвижные")
|
||||
Source.objects.filter(info=mobile_new).update(info=mobile_old)
|
||||
mobile_new.delete()
|
||||
except ObjectInfo.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0010_set_default_source_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_capitalization, reverse_fix_capitalization),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-21 12:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0011_fix_source_type_capitalization'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='confirm_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Дата и время добавления последней полученной точки ГЛ', null=True, verbose_name='Дата подтверждения'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='last_signal_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Дата и время последней отметки о наличии сигнала', null=True, verbose_name='Последний сигнал'),
|
||||
),
|
||||
]
|
||||
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 19:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0012_source_confirm_at_source_last_signal_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='Mirror',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='sigmaparameter',
|
||||
name='mark',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='is_automatic',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='Если True, точка не добавляется к объектам (Source), а хранится отдельно', verbose_name='Автоматическая'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='SigmaParMark',
|
||||
),
|
||||
]
|
||||
18
dbapp/mainapp/migrations/0014_source_note.py
Normal file
18
dbapp/mainapp/migrations/0014_source_note.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-25 12:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0013_add_is_automatic_to_objitem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='note',
|
||||
field=models.TextField(blank=True, help_text='Дополнительное описание объекта', null=True, verbose_name='Примечание'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-26 20:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0014_source_note'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='satellite',
|
||||
name='international_code',
|
||||
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=20, null=True, verbose_name='Международный код'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-27 07:10
|
||||
|
||||
import django.db.models.deletion
|
||||
import mainapp.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0015_add_international_code_to_satellite'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='satellite',
|
||||
name='international_code',
|
||||
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=50, null=True, verbose_name='Международный код'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TechAnalyze',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(db_index=True, help_text='Уникальное название для технического анализа', max_length=255, unique=True, verbose_name='Имя')),
|
||||
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
|
||||
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
|
||||
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость', null=True, verbose_name='Символьная скорость, БОД')),
|
||||
('note', models.TextField(blank=True, help_text='Дополнительные примечания', null=True, verbose_name='Примечание')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_modulations', to='mainapp.modulation', verbose_name='Модуляция')),
|
||||
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
|
||||
('satellite', models.ForeignKey(help_text='Спутник, к которому относится анализ', on_delete=django.db.models.deletion.PROTECT, related_name='tech_analyzes', to='mainapp.satellite', verbose_name='Спутник')),
|
||||
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_standards', to='mainapp.standard', verbose_name='Стандарт')),
|
||||
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тех. анализ',
|
||||
'verbose_name_plural': 'Тех. анализы',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-01 08:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0016_alter_satellite_international_code_techanalyze'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='satellite',
|
||||
name='alternative_name',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника (например, из скобок)', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='standard',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=80, unique=True, verbose_name='Стандарт'),
|
||||
),
|
||||
]
|
||||
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-08 08:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0017_add_satellite_alternative_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='objectownership',
|
||||
name='name',
|
||||
field=models.CharField(help_text='Принадлежность объекта', max_length=255, unique=True, verbose_name='Принадлежность'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='satellite',
|
||||
name='alternative_name',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SourceRequest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус')),
|
||||
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], db_index=True, default='medium', help_text='Приоритет заявки', max_length=10, verbose_name='Приоритет')),
|
||||
('planned_at', models.DateTimeField(blank=True, help_text='Запланированная дата и время', null=True, verbose_name='Дата и время планирования')),
|
||||
('request_date', models.DateField(blank=True, help_text='Дата подачи заявки', null=True, verbose_name='Дата заявки')),
|
||||
('status_updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления статуса', verbose_name='Дата обновления статуса')),
|
||||
('gso_success', models.BooleanField(blank=True, help_text='Успешность ГСО', null=True, verbose_name='ГСО успешно?')),
|
||||
('kubsat_success', models.BooleanField(blank=True, help_text='Успешность Кубсат', null=True, verbose_name='Кубсат успешно?')),
|
||||
('comment', models.TextField(blank=True, help_text='Дополнительные комментарии к заявке', null=True, verbose_name='Комментарий')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||
('source', models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник')),
|
||||
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Заявка на источник',
|
||||
'verbose_name_plural': 'Заявки на источники',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SourceRequestStatusHistory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('old_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус')),
|
||||
('new_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус')),
|
||||
('changed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время изменения статуса', verbose_name='Дата изменения')),
|
||||
('changed_by', models.ForeignKey(blank=True, help_text='Пользователь, изменивший статус', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||
('source_request', models.ForeignKey(help_text='Связанная заявка', on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='mainapp.sourcerequest', verbose_name='Заявка')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'История статуса заявки',
|
||||
'verbose_name_plural': 'История статусов заявок',
|
||||
'ordering': ['-changed_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequest',
|
||||
index=models.Index(fields=['-created_at'], name='mainapp_sou_created_61d8ae_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequest',
|
||||
index=models.Index(fields=['status'], name='mainapp_sou_status_31dc99_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequest',
|
||||
index=models.Index(fields=['priority'], name='mainapp_sou_priorit_5b5044_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequest',
|
||||
index=models.Index(fields=['source', '-created_at'], name='mainapp_sou_source__6bb459_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequeststatushistory',
|
||||
index=models.Index(fields=['-changed_at'], name='mainapp_sou_changed_9b876e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequeststatushistory',
|
||||
index=models.Index(fields=['source_request', '-changed_at'], name='mainapp_sou_source__957c28_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-08 09:24
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0018_add_source_request_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='coords',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты по выбранным точкам (WGS84)', null=True, srid=4326, verbose_name='Координаты'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='points_count',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Количество точек ГЛ, использованных для расчёта координат', verbose_name='Количество точек'),
|
||||
),
|
||||
]
|
||||
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-08 12:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0019_add_coords_to_source_request'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='satellite',
|
||||
name='location_place',
|
||||
field=models.CharField(choices=[('kr', 'КР'), ('dv', 'ДВ')], default='kr', help_text='К какому комплексу принадлежит спутник', max_length=30, null=True, verbose_name='Комплекс'),
|
||||
),
|
||||
]
|
||||
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-09 12:39
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0020_satellite_location_place'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='card_date',
|
||||
field=models.DateField(blank=True, help_text='Дата формирования карточки', null=True, verbose_name='Дата формирования карточки'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='coords_source',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты источника (WGS84)', null=True, srid=4326, verbose_name='Координаты источника'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='downlink',
|
||||
field=models.FloatField(blank=True, help_text='Частота downlink в МГц', null=True, verbose_name='Частота Downlink, МГц'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='region',
|
||||
field=models.CharField(blank=True, help_text='Район/местоположение', max_length=255, null=True, verbose_name='Район'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='satellite',
|
||||
field=models.ForeignKey(blank=True, help_text='Связанный спутник', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_requests', to='mainapp.satellite', verbose_name='Спутник'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='transfer',
|
||||
field=models.FloatField(blank=True, help_text='Перенос по частоте в МГц', null=True, verbose_name='Перенос, МГц'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='uplink',
|
||||
field=models.FloatField(blank=True, help_text='Частота uplink в МГц', null=True, verbose_name='Частота Uplink, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequest',
|
||||
name='coords',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты ГСО (WGS84)', null=True, srid=4326, verbose_name='Координаты ГСО'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequest',
|
||||
name='source',
|
||||
field=models.ForeignKey(blank=True, help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Миграция для изменения модели ObjectMark:
|
||||
- Удаление всех существующих отметок
|
||||
- Удаление поля source
|
||||
- Добавление поля tech_analyze
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def delete_all_marks(apps, schema_editor):
|
||||
"""Удаляем все существующие отметки перед изменением структуры."""
|
||||
ObjectMark = apps.get_model('mainapp', 'ObjectMark')
|
||||
count = ObjectMark.objects.count()
|
||||
ObjectMark.objects.all().delete()
|
||||
print(f"Удалено {count} отметок ObjectMark")
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
"""Обратная операция - ничего не делаем."""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0021_add_source_request_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Сначала удаляем все отметки
|
||||
migrations.RunPython(delete_all_marks, noop),
|
||||
|
||||
# Удаляем старое поле source
|
||||
migrations.RemoveField(
|
||||
model_name='objectmark',
|
||||
name='source',
|
||||
),
|
||||
|
||||
# Добавляем новое поле tech_analyze
|
||||
migrations.AddField(
|
||||
model_name='objectmark',
|
||||
name='tech_analyze',
|
||||
field=models.ForeignKey(
|
||||
help_text='Связанный технический анализ',
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='marks',
|
||||
to='mainapp.techanalyze',
|
||||
verbose_name='Тех. анализ',
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
|
||||
# Обновляем метаданные модели
|
||||
migrations.AlterModelOptions(
|
||||
name='objectmark',
|
||||
options={
|
||||
'ordering': ['-timestamp'],
|
||||
'verbose_name': 'Отметка сигнала',
|
||||
'verbose_name_plural': 'Отметки сигналов'
|
||||
},
|
||||
),
|
||||
|
||||
# Добавляем индекс для оптимизации запросов
|
||||
migrations.AddIndex(
|
||||
model_name='objectmark',
|
||||
index=models.Index(
|
||||
fields=['tech_analyze', '-timestamp'],
|
||||
name='mainapp_obj_tech_an_idx'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-11 12:08
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0022_change_objectmark_to_techanalyze'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='objectmark',
|
||||
new_name='mainapp_obj_tech_an_b0c804_idx',
|
||||
old_name='mainapp_obj_tech_an_idx',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='coords_object',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты объекта (WGS84)', null=True, srid=4326, verbose_name='Координаты объекта'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objectmark',
|
||||
name='mark',
|
||||
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-12 12:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0023_add_coords_object_to_sourcerequest'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='objectmark',
|
||||
name='timestamp',
|
||||
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequest',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequeststatushistory',
|
||||
name='new_status',
|
||||
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequeststatushistory',
|
||||
name='old_status',
|
||||
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус'),
|
||||
),
|
||||
]
|
||||
@@ -59,20 +59,16 @@ class CoordinateProcessingMixin:
|
||||
Предоставляет методы для извлечения и обработки координат различных типов
|
||||
(геолокация, кубсат, оперативники) из POST запроса и применения их к объекту Geo.
|
||||
|
||||
Example:
|
||||
class MyFormView(CoordinateProcessingMixin, FormView):
|
||||
def form_valid(self, form):
|
||||
geo_instance = Geo()
|
||||
self.process_coordinates(geo_instance)
|
||||
geo_instance.save()
|
||||
return super().form_valid(form)
|
||||
Note: Координаты Кубсата и оперативников теперь хранятся в модели Source,
|
||||
а не в модели Geo, но для совместимости в форме все еще могут быть поля
|
||||
для этих координат.
|
||||
"""
|
||||
|
||||
def process_coordinates(self, geo_instance, prefix: str = "geo") -> None:
|
||||
"""
|
||||
Обрабатывает координаты из POST данных и применяет их к объекту Geo.
|
||||
|
||||
Извлекает координаты геолокации, кубсата и оперативников из POST запроса
|
||||
Извлекает координаты геолокации из POST запроса
|
||||
и устанавливает соответствующие поля объекта Geo.
|
||||
|
||||
Args:
|
||||
@@ -82,28 +78,12 @@ class CoordinateProcessingMixin:
|
||||
Note:
|
||||
Метод ожидает следующие поля в request.POST:
|
||||
- geo_longitude, geo_latitude: координаты геолокации
|
||||
- kupsat_longitude, kupsat_latitude: координаты кубсата
|
||||
- valid_longitude, valid_latitude: координаты оперативников
|
||||
"""
|
||||
# Обрабатываем координаты геолокации
|
||||
geo_coords = self._extract_coordinates("geo")
|
||||
if geo_coords:
|
||||
geo_instance.coords = Point(geo_coords[0], geo_coords[1], srid=4326)
|
||||
|
||||
# Обрабатываем координаты Кубсата
|
||||
kupsat_coords = self._extract_coordinates("kupsat")
|
||||
if kupsat_coords:
|
||||
geo_instance.coords_kupsat = Point(
|
||||
kupsat_coords[0], kupsat_coords[1], srid=4326
|
||||
)
|
||||
|
||||
# Обрабатываем координаты оперативников
|
||||
valid_coords = self._extract_coordinates("valid")
|
||||
if valid_coords:
|
||||
geo_instance.coords_valid = Point(
|
||||
valid_coords[0], valid_coords[1], srid=4326
|
||||
)
|
||||
|
||||
def _extract_coordinates(self, coord_type: str) -> Optional[Tuple[float, float]]:
|
||||
"""
|
||||
Извлекает координаты указанного типа из POST данных.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,17 @@
|
||||
# Django imports
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
# from django.contrib.auth.models import User
|
||||
# from django.db.models.signals import post_save
|
||||
# from django.dispatch import receiver
|
||||
|
||||
# Local imports
|
||||
from .models import CustomUser
|
||||
# # Local imports
|
||||
# from .models import CustomUser
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_or_update_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
CustomUser.objects.create(user=instance)
|
||||
instance.customuser.save()
|
||||
# @receiver(post_save, sender=User)
|
||||
# def create_or_update_user_profile(sender, instance, created, **kwargs):
|
||||
# if created:
|
||||
# CustomUser.objects.get_or_create(user=instance)
|
||||
# else:
|
||||
# # Only save if customuser exists (avoid error if it doesn't)
|
||||
# if hasattr(instance, 'customuser'):
|
||||
# instance.customuser.save()
|
||||
161
dbapp/mainapp/static/css/checkbox-select-multiple.css
Normal file
161
dbapp/mainapp/static/css/checkbox-select-multiple.css
Normal file
@@ -0,0 +1,161 @@
|
||||
.checkbox-multiselect-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.multiselect-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: 38px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #fff;
|
||||
cursor: text;
|
||||
padding: 4px 30px 4px 4px;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.multiselect-input-container:focus-within {
|
||||
border-color: #86b7fe;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.multiselect-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
flex: 1 1 auto;
|
||||
max-width: calc(100% - 150px);
|
||||
}
|
||||
|
||||
.multiselect-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #e9ecef;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multiselect-tag-remove {
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
color: #6c757d;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.multiselect-tag-remove:hover {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.multiselect-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.multiselect-search:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.multiselect-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect-clear:hover {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.multiselect-input-container.has-selections .multiselect-clear {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.multiselect-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Открытие вверх (по умолчанию) */
|
||||
.multiselect-dropdown {
|
||||
bottom: 100%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Открытие вниз (если места сверху недостаточно) */
|
||||
.multiselect-dropdown.dropdown-below {
|
||||
bottom: auto;
|
||||
top: 100%;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.multiselect-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.multiselect-options {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.multiselect-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.multiselect-option:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.multiselect-option input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.multiselect-option .option-label {
|
||||
flex: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.multiselect-option.hidden {
|
||||
display: none;
|
||||
}
|
||||
148
dbapp/mainapp/static/js/SORTING_README.md
Normal file
148
dbapp/mainapp/static/js/SORTING_README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Sorting Functionality Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the centralized sorting functionality implemented for table columns across the Django application.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Created Files:
|
||||
1. **`dbapp/mainapp/static/js/sorting.js`** - Main sorting JavaScript library
|
||||
2. **`dbapp/mainapp/static/js/sorting-test.html`** - Test page for manual verification
|
||||
|
||||
### Modified Files:
|
||||
1. **`dbapp/mainapp/templates/mainapp/base.html`** - Added sorting.js script include
|
||||
2. **`dbapp/mainapp/templates/mainapp/components/_sort_header.html`** - Removed inline script, added data attributes
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Sort Toggle Logic
|
||||
- **First click**: Sort ascending (field)
|
||||
- **Second click**: Sort descending (-field)
|
||||
- **Third click**: Sort ascending again (cycles back)
|
||||
|
||||
### 2. URL Parameter Management
|
||||
- Preserves all existing GET parameters (search, filters, etc.)
|
||||
- Automatically resets page number to 1 when sorting changes
|
||||
- Updates the `sort` parameter in the URL
|
||||
|
||||
### 3. Visual Indicators
|
||||
- Shows up arrow (↑) for ascending sort
|
||||
- Shows down arrow (↓) for descending sort
|
||||
- Automatically initializes indicators on page load
|
||||
- Adds `sort-active` class to currently sorted column
|
||||
|
||||
## Usage
|
||||
|
||||
### In Templates
|
||||
|
||||
Use the `_sort_header.html` component in your table headers:
|
||||
|
||||
```django
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th>{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}</th>
|
||||
<th>{% include 'mainapp/components/_sort_header.html' with field='name' label='Название' current_sort=sort %}</th>
|
||||
<th>{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Дата создания' current_sort=sort %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
```
|
||||
|
||||
### In Views
|
||||
|
||||
Pass the current sort parameter to the template context:
|
||||
|
||||
```python
|
||||
def get(self, request):
|
||||
sort = request.GET.get('sort', '-id') # Default sort
|
||||
|
||||
# Validate allowed sorts
|
||||
allowed_sorts = ['id', '-id', 'name', '-name', 'created_at', '-created_at']
|
||||
if sort not in allowed_sorts:
|
||||
sort = '-id'
|
||||
|
||||
# Apply sorting
|
||||
queryset = Model.objects.all().order_by(sort)
|
||||
|
||||
context = {
|
||||
'sort': sort,
|
||||
'objects': queryset,
|
||||
# ... other context
|
||||
}
|
||||
return render(request, 'template.html', context)
|
||||
```
|
||||
|
||||
## JavaScript API
|
||||
|
||||
### Functions
|
||||
|
||||
#### `updateSort(field)`
|
||||
Updates the sort parameter and reloads the page.
|
||||
|
||||
**Parameters:**
|
||||
- `field` (string): The field name to sort by
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
updateSort('created_at'); // Sort by created_at ascending
|
||||
```
|
||||
|
||||
#### `getCurrentSort()`
|
||||
Gets the current sort field and direction from URL.
|
||||
|
||||
**Returns:**
|
||||
- Object with `field` and `direction` properties
|
||||
- `direction` can be 'asc', 'desc', or null
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
const sort = getCurrentSort();
|
||||
console.log(sort.field); // 'created_at'
|
||||
console.log(sort.direction); // 'asc' or 'desc'
|
||||
```
|
||||
|
||||
#### `initializeSortIndicators()`
|
||||
Automatically called on page load to show current sort state.
|
||||
|
||||
## Requirements Satisfied
|
||||
|
||||
This implementation satisfies the following requirements from the specification:
|
||||
|
||||
- **5.1**: Supports ascending and descending order for sortable columns
|
||||
- **5.2**: Toggles between ascending, descending when clicking column headers
|
||||
- **5.3**: Displays visual indicators (arrow icons) showing sort direction
|
||||
- **5.5**: Preserves sort state in URL parameters during navigation
|
||||
- **5.6**: Preserves other active filters and resets pagination when sorting
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. Open `dbapp/mainapp/static/js/sorting-test.html` in a browser
|
||||
2. Click column headers to test sorting
|
||||
3. Verify URL updates correctly
|
||||
4. Add query parameters (e.g., ?page=5&search=test) and verify they're preserved
|
||||
|
||||
### Integration Testing
|
||||
|
||||
Test in actual Django views:
|
||||
1. Navigate to any list view (sources, objitems, transponders)
|
||||
2. Click column headers to sort
|
||||
3. Verify data is sorted correctly
|
||||
4. Apply filters and verify they're preserved when sorting
|
||||
5. Navigate to page 2+, then sort - verify it resets to page 1
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- Modern browsers supporting ES6 (URLSearchParams)
|
||||
- Chrome 49+
|
||||
- Firefox 44+
|
||||
- Safari 10.1+
|
||||
- Edge 17+
|
||||
|
||||
## Notes
|
||||
|
||||
- The sorting.js file is loaded with `defer` attribute for better performance
|
||||
- All GET parameters are preserved except `page` which is reset to 1
|
||||
- The function is globally available and can be called from any template
|
||||
- Sort indicators are automatically initialized on page load
|
||||
120
dbapp/mainapp/static/js/checkbox-select-multiple.js
Normal file
120
dbapp/mainapp/static/js/checkbox-select-multiple.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Checkbox Select Multiple Widget
|
||||
* Provides a multi-select dropdown with checkboxes and tag display
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize all checkbox multiselect widgets
|
||||
document.querySelectorAll('.checkbox-multiselect-wrapper').forEach(function(wrapper) {
|
||||
initCheckboxMultiselect(wrapper);
|
||||
});
|
||||
});
|
||||
|
||||
function initCheckboxMultiselect(wrapper) {
|
||||
const widgetId = wrapper.dataset.widgetId;
|
||||
const inputContainer = wrapper.querySelector('.multiselect-input-container');
|
||||
const searchInput = wrapper.querySelector('.multiselect-search');
|
||||
const dropdown = wrapper.querySelector('.multiselect-dropdown');
|
||||
const tagsContainer = wrapper.querySelector('.multiselect-tags');
|
||||
const clearButton = wrapper.querySelector('.multiselect-clear');
|
||||
const checkboxes = wrapper.querySelectorAll('input[type="checkbox"]');
|
||||
|
||||
// Show dropdown when clicking on input container
|
||||
inputContainer.addEventListener('click', function(e) {
|
||||
if (e.target !== clearButton) {
|
||||
positionDropdown();
|
||||
dropdown.classList.add('show');
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Position dropdown (up or down based on available space)
|
||||
function positionDropdown() {
|
||||
const rect = inputContainer.getBoundingClientRect();
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const dropdownHeight = 300; // max-height from CSS
|
||||
|
||||
// If more space below and enough space, open downward
|
||||
if (spaceBelow > spaceAbove && spaceBelow >= dropdownHeight) {
|
||||
dropdown.classList.add('dropdown-below');
|
||||
} else {
|
||||
dropdown.classList.remove('dropdown-below');
|
||||
}
|
||||
}
|
||||
|
||||
// Hide dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!wrapper.contains(e.target)) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Search functionality
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
const options = wrapper.querySelectorAll('.multiselect-option');
|
||||
|
||||
options.forEach(function(option) {
|
||||
const label = option.querySelector('.option-label').textContent.toLowerCase();
|
||||
if (label.includes(searchTerm)) {
|
||||
option.classList.remove('hidden');
|
||||
} else {
|
||||
option.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle checkbox changes
|
||||
checkboxes.forEach(function(checkbox) {
|
||||
checkbox.addEventListener('change', function() {
|
||||
updateTags();
|
||||
});
|
||||
});
|
||||
|
||||
// Clear all button
|
||||
clearButton.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
checkboxes.forEach(function(checkbox) {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
updateTags();
|
||||
});
|
||||
|
||||
// Update tags display
|
||||
function updateTags() {
|
||||
tagsContainer.innerHTML = '';
|
||||
let hasSelections = false;
|
||||
|
||||
checkboxes.forEach(function(checkbox) {
|
||||
if (checkbox.checked) {
|
||||
hasSelections = true;
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'multiselect-tag';
|
||||
tag.innerHTML = `
|
||||
<span>${checkbox.dataset.label}</span>
|
||||
<button type="button" class="multiselect-tag-remove" data-value="${checkbox.value}">×</button>
|
||||
`;
|
||||
|
||||
// Remove tag on click
|
||||
tag.querySelector('.multiselect-tag-remove').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
checkbox.checked = false;
|
||||
updateTags();
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(tag);
|
||||
}
|
||||
});
|
||||
|
||||
// Show/hide clear button
|
||||
if (hasSelections) {
|
||||
inputContainer.classList.add('has-selections');
|
||||
} else {
|
||||
inputContainer.classList.remove('has-selections');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tags on load
|
||||
updateTags();
|
||||
}
|
||||
91
dbapp/mainapp/static/js/sorting-test.html
Normal file
91
dbapp/mainapp/static/js/sorting-test.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sorting Test</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<h1>Sorting Functionality Test</h1>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>Current URL:</strong> <span id="currentUrl"></span>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>
|
||||
<a href="javascript:void(0)"
|
||||
onclick="updateSort('id')"
|
||||
class="text-white text-decoration-none"
|
||||
data-sort-field="id">
|
||||
ID
|
||||
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="javascript:void(0)"
|
||||
onclick="updateSort('name')"
|
||||
class="text-white text-decoration-none"
|
||||
data-sort-field="name">
|
||||
Name
|
||||
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="javascript:void(0)"
|
||||
onclick="updateSort('created_at')"
|
||||
class="text-white text-decoration-none"
|
||||
data-sort-field="created_at">
|
||||
Created At
|
||||
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>Test Item 1</td>
|
||||
<td>2024-01-01</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>Test Item 2</td>
|
||||
<td>2024-01-02</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5>Test Instructions:</h5>
|
||||
<ol>
|
||||
<li>Click on any column header (ID, Name, or Created At)</li>
|
||||
<li>The URL should update with ?sort=field_name</li>
|
||||
<li>Click again to toggle to descending (?sort=-field_name)</li>
|
||||
<li>Click a third time to toggle back to ascending</li>
|
||||
<li>Add ?page=5 to the URL and click a header - page should reset to 1</li>
|
||||
<li>Add ?search=test to the URL and click a header - search should be preserved</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="sorting.js"></script>
|
||||
<script>
|
||||
// Display current URL
|
||||
function updateUrlDisplay() {
|
||||
document.getElementById('currentUrl').textContent = window.location.href;
|
||||
}
|
||||
updateUrlDisplay();
|
||||
|
||||
// Update URL display on page load
|
||||
window.addEventListener('load', updateUrlDisplay);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
106
dbapp/mainapp/static/js/sorting.js
Normal file
106
dbapp/mainapp/static/js/sorting.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Sorting functionality for table columns
|
||||
* Handles toggling between ascending, descending, and no sort
|
||||
* Preserves other GET parameters and resets pagination
|
||||
*/
|
||||
|
||||
/**
|
||||
* Updates the sort parameter in the URL and reloads the page
|
||||
* @param {string} field - The field name to sort by
|
||||
*/
|
||||
function updateSort(field) {
|
||||
// Get current URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const currentSort = urlParams.get('sort');
|
||||
|
||||
let newSort;
|
||||
|
||||
// Toggle sort direction logic:
|
||||
// 1. If not sorted by this field -> sort ascending (field)
|
||||
// 2. If sorted ascending -> sort descending (-field)
|
||||
// 3. If sorted descending -> sort ascending (field)
|
||||
if (currentSort === field) {
|
||||
// Currently ascending, switch to descending
|
||||
newSort = '-' + field;
|
||||
} else if (currentSort === '-' + field) {
|
||||
// Currently descending, switch to ascending
|
||||
newSort = field;
|
||||
} else {
|
||||
// Not sorted by this field, start with ascending
|
||||
newSort = field;
|
||||
}
|
||||
|
||||
// Update sort parameter
|
||||
urlParams.set('sort', newSort);
|
||||
|
||||
// Reset to first page when sorting changes
|
||||
urlParams.delete('page');
|
||||
|
||||
// Reload page with new parameters
|
||||
window.location.search = urlParams.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current sort field and direction
|
||||
* @returns {Object} Object with field and direction properties
|
||||
*/
|
||||
function getCurrentSort() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sort = urlParams.get('sort');
|
||||
|
||||
if (!sort) {
|
||||
return { field: null, direction: null };
|
||||
}
|
||||
|
||||
if (sort.startsWith('-')) {
|
||||
return {
|
||||
field: sort.substring(1),
|
||||
direction: 'desc'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
field: sort,
|
||||
direction: 'asc'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes sort indicators on page load
|
||||
* Adds visual indicators to show current sort state
|
||||
*/
|
||||
function initializeSortIndicators() {
|
||||
const currentSort = getCurrentSort();
|
||||
|
||||
if (!currentSort.field) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all sort headers and update their indicators
|
||||
const sortHeaders = document.querySelectorAll('[data-sort-field]');
|
||||
sortHeaders.forEach(header => {
|
||||
const field = header.getAttribute('data-sort-field');
|
||||
|
||||
if (field === currentSort.field) {
|
||||
// Add active class or update icon
|
||||
header.classList.add('sort-active');
|
||||
|
||||
// Update icon if present
|
||||
const icon = header.querySelector('.sort-icon');
|
||||
if (icon) {
|
||||
if (currentSort.direction === 'asc') {
|
||||
icon.className = 'bi bi-arrow-up sort-icon';
|
||||
} else {
|
||||
icon.className = 'bi bi-arrow-down sort-icon';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize sort indicators when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeSortIndicators);
|
||||
} else {
|
||||
initializeSortIndicators();
|
||||
}
|
||||
@@ -9,9 +9,6 @@
|
||||
<p class="lead">Управление данными спутников</p>
|
||||
</div>
|
||||
|
||||
<!-- Alert messages -->
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
<!-- Main feature cards -->
|
||||
<div class="row g-4">
|
||||
<!-- Excel Data Upload Card -->
|
||||
@@ -75,33 +72,14 @@
|
||||
<h3 class="card-title mb-0">Добавление списка спутников</h3>
|
||||
</div>
|
||||
<p class="card-text">Добавьте новый список спутников в базу данных для последующего использования в загрузке данных.</p>
|
||||
<a href="{% url 'mainapp:add_sats' %}" class="btn btn-info">
|
||||
<a href="{% url 'mainapp:add_sats' %}" class="btn btn-info disabled">
|
||||
Добавить список спутников
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transponders Card -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100 shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-2 me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-wifi text-warning" viewBox="0 0 16 16">
|
||||
<path d="M6.002 3.5a5.5 5.5 0 1 1 3.996 9.5H10A5.5 5.5 0 0 1 6.002 3.5M6.002 5.5a3.5 3.5 0 1 0 3.996 5.5H10A3.5 3.5 0 0 0 6.002 5.5"/>
|
||||
<path d="M10.5 12.5a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5.5.5 0 0 0-1 0 .5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5.5.5 0 0 0-1 0 .5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5 3.5 3.5 0 0 1 7 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title mb-0">Добавление транспондеров</h3>
|
||||
</div>
|
||||
<p class="card-text">Добавьте список транспондеров из JSON-файла в базу данных. Требуется наличие файла transponders.json.</p>
|
||||
<a href="{% url 'mainapp:add_trans' %}" class="btn btn-warning">
|
||||
Добавить транспондеры
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- VCH Load Data Card -->
|
||||
<div class="col-lg-6">
|
||||
@@ -184,6 +162,49 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link LyngSat Sources Card -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100 shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle p-2 me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-link-45deg text-primary" viewBox="0 0 16 16">
|
||||
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
|
||||
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title mb-0">Привязка источников LyngSat</h3>
|
||||
</div>
|
||||
<p class="card-text">Автоматическая привязка источников из базы LyngSat к объектам по частоте и поляризации. Объекты с привязанными источниками отображаются как "ТВ".</p>
|
||||
<a href="{% url 'mainapp:link_lyngsat' %}" class="btn btn-primary">
|
||||
Привязать источники
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlink All LyngSat Sources Card -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100 shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-2 me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-unlink text-warning" viewBox="0 0 16 16">
|
||||
<path d="M6.354 5.5H4a3 3 0 0 0 0 6h3a3 3 0 0 0 2.83-4H9q-.13 0-.25.031A2 2 0 0 1 7 10.5H4a2 2 0 1 1 0-4h1.535c.218-.376.495-.714.82-1z"/>
|
||||
<path d="M9 5.5a3 3 0 0 0-2.83 4h1.098A2 2 0 0 1 9 6.5h3a2 2 0 1 1 0 4h-1.535a4 4 0 0 1-.82 1H12a3 3 0 1 0 0-6z"/>
|
||||
<path d="M1 1l14 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title mb-0">Отвязка всех источников LyngSat</h3>
|
||||
</div>
|
||||
<p class="card-text">Отвязать все источники LyngSat от объектов. Все объекты перестанут отображаться как "ТВ" источники. Операция обратима через повторную привязку.</p>
|
||||
<a href="{% url 'mainapp:unlink_all_lyngsat' %}" class="btn btn-warning">
|
||||
Отвязать все источники
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -11,8 +11,6 @@
|
||||
<h2 class="mb-0">Загрузка данных из CSV</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
<p class="card-text">Загрузите CSV-файл для загрузки данных в базу.</p>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
@@ -21,8 +19,21 @@
|
||||
<!-- Form fields with Bootstrap styling -->
|
||||
{% include 'mainapp/components/_form_field.html' with field=form.file %}
|
||||
|
||||
<!-- Automatic checkbox -->
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
{{ form.is_automatic }}
|
||||
<label class="form-check-label" for="{{ form.is_automatic.id_for_label }}">
|
||||
{{ form.is_automatic.label }}
|
||||
</label>
|
||||
{% if form.is_automatic.help_text %}
|
||||
<div class="form-text">{{ form.is_automatic.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||
<button type="submit" class="btn btn-success">Добавить в базу</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<h2 class="mb-0">Загрузка данных из Excel</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
<p class="card-text">Загрузите Excel-файл и выберите спутник для загрузки данных в базу.</p>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
@@ -23,8 +21,21 @@
|
||||
{% include 'mainapp/components/_form_field.html' with field=form.sat_choice %}
|
||||
{% include 'mainapp/components/_form_field.html' with field=form.number_input %}
|
||||
|
||||
<!-- Automatic checkbox -->
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
{{ form.is_automatic }}
|
||||
<label class="form-check-label" for="{{ form.is_automatic.id_for_label }}">
|
||||
{{ form.is_automatic.label }}
|
||||
</label>
|
||||
{% if form.is_automatic.help_text %}
|
||||
<div class="form-text">{{ form.is_automatic.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||
<button type="submit" class="btn btn-primary">Добавить в базу</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}" defer></script>
|
||||
|
||||
<!-- Common sorting functionality -->
|
||||
<script src="{% static 'js/sorting.js' %}" defer></script>
|
||||
|
||||
<!-- Дополнительные скрипты -->
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
|
||||
113
dbapp/mainapp/templates/mainapp/clear_lyngsat_cache.html
Normal file
113
dbapp/mainapp/templates/mainapp/clear_lyngsat_cache.html
Normal file
@@ -0,0 +1,113 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Управление кешем LyngSat{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-database"></i> Управление кешем LyngSat
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<h5><i class="bi bi-info-circle"></i> Информация о кешировании</h5>
|
||||
<ul class="mb-0">
|
||||
<li><strong>Страницы регионов:</strong> кешируются на 7 дней</li>
|
||||
<li><strong>Данные спутников:</strong> кешируются на 1 день</li>
|
||||
<li><strong>Списки спутников:</strong> кешируются на 7 дней</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-4">Очистка кеша</h5>
|
||||
<p class="text-muted">
|
||||
Выберите тип кеша для очистки. Это полезно, если нужно принудительно обновить данные.
|
||||
</p>
|
||||
|
||||
<form method="post" class="mt-3">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Тип кеша для очистки:</label>
|
||||
|
||||
<div class="list-group">
|
||||
<button type="submit" name="cache_type" value="all"
|
||||
class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">
|
||||
<i class="bi bi-trash"></i> Очистить весь кеш
|
||||
</h6>
|
||||
</div>
|
||||
<p class="mb-1 text-muted small">
|
||||
Удалить все кешированные данные LyngSat (регионы, спутники, списки)
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button type="submit" name="cache_type" value="regions"
|
||||
class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">
|
||||
<i class="bi bi-globe"></i> Очистить кеш регионов
|
||||
</h6>
|
||||
</div>
|
||||
<p class="mb-1 text-muted small">
|
||||
Удалить кешированные страницы регионов (Europe, Asia, America, Atlantic)
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button type="submit" name="cache_type" value="satellites"
|
||||
class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h6 class="mb-1">
|
||||
<i class="bi bi-satellite"></i> Очистить кеш спутников
|
||||
</h6>
|
||||
</div>
|
||||
<p class="mb-1 text-muted small">
|
||||
Удалить кешированные данные отдельных спутников
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<h6><i class="bi bi-exclamation-triangle"></i> Внимание</h6>
|
||||
<p class="mb-0 small">
|
||||
После очистки кеша следующий запрос данных будет выполняться дольше,
|
||||
так как данные будут загружаться заново с сайта LyngSat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="{% url 'mainapp:fill_lyngsat_data' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад к заполнению данных
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-terminal"></i> Альтернативные способы очистки
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h6>Через Django Management команду:</h6>
|
||||
<pre class="bg-dark text-light p-3 rounded"><code>python manage.py clear_lyngsat_cache --type all</code></pre>
|
||||
|
||||
<h6 class="mt-3">Через Redis CLI:</h6>
|
||||
<pre class="bg-dark text-light p-3 rounded"><code>redis-cli keys "dbapp:lyngsat*"
|
||||
redis-cli del "dbapp:lyngsat_region:europe"</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{% comment %}
|
||||
Компонент для элемента переключения видимости столбца
|
||||
Использование:
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=0 column_label="Выбрать" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=1 column_label="Имя" checked=True %}
|
||||
{% endcomment %}
|
||||
|
||||
<li>
|
||||
<label class="dropdown-item">
|
||||
<input type="checkbox" class="column-toggle" data-column="{{ column_index }}" {% if checked %}checked{% endif %}
|
||||
onchange="toggleColumn(this)"> {{ column_label }}
|
||||
</label>
|
||||
</li>
|
||||
@@ -0,0 +1,45 @@
|
||||
{% comment %}
|
||||
Компонент для выпадающего списка видимости столбцов
|
||||
Использование:
|
||||
{% include 'mainapp/components/_column_visibility_dropdown.html' %}
|
||||
{% endcomment %}
|
||||
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
||||
id="columnVisibilityDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-gear"></i> Колонки
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="columnVisibilityDropdown" style="z-index: 1050; max-height: 300px; overflow-y: auto;">
|
||||
<li>
|
||||
<label class="dropdown-item">
|
||||
<input type="checkbox" id="select-all-columns" unchecked
|
||||
onchange="toggleAllColumns(this)"> Выбрать всё
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=0 column_label="Выбрать" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=1 column_label="Имя" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=2 column_label="Спутник" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=3 column_label="Транспондер" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=4 column_label="Част, МГц" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=5 column_label="Полоса, МГц" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=6 column_label="Поляризация" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=7 column_label="Сим. V" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=8 column_label="Модул" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=9 column_label="ОСШ" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=10 column_label="Время ГЛ" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=11 column_label="Местоположение" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=12 column_label="Геолокация" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=13 column_label="Обновлено" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=14 column_label="Кем (обновление)" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=15 column_label="Создано" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=16 column_label="Кем (создание)" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=17 column_label="Комментарий" checked=False %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Усреднённое" checked=False %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Стандарт" checked=False %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Тип источника" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Зеркала" checked=True %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
{% comment %}
|
||||
Переиспользуемый компонент панели фильтров (Offcanvas)
|
||||
Параметры:
|
||||
- filters: список HTML-кода фильтров для отображения (опционально)
|
||||
- filter_form: объект формы Django для фильтров (опционально)
|
||||
- reset_url: URL для сброса фильтров (по умолчанию: текущая страница без параметров)
|
||||
|
||||
Использование:
|
||||
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list %}
|
||||
{% include 'mainapp/components/_filter_panel.html' with filter_form=form %}
|
||||
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list reset_url='/sources/' %}
|
||||
|
||||
Примечание:
|
||||
- Можно передать либо список HTML-кода фильтров через 'filters', либо форму Django через 'filter_form'
|
||||
- Форма отправляется методом GET для сохранения параметров в URL
|
||||
- Кнопка "Сбросить" очищает все параметры фильтрации
|
||||
{% endcomment %}
|
||||
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<form method="get" id="filter-form">
|
||||
{% if filter_form %}
|
||||
{# Если передана форма Django, отображаем её поля #}
|
||||
{% for field in filter_form %}
|
||||
<div class="mb-3">
|
||||
<label for="{{ field.id_for_label }}" class="form-label">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<div class="form-text">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif filters %}
|
||||
{# Если переданы готовые HTML-блоки фильтров #}
|
||||
{% for filter in filters %}
|
||||
{{ filter|safe }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Сохраняем параметры сортировки и поиска при применении фильтров #}
|
||||
{% if request.GET.sort %}
|
||||
<input type="hidden" name="sort" value="{{ request.GET.sort }}">
|
||||
{% endif %}
|
||||
{% if request.GET.search %}
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% endif %}
|
||||
{% if request.GET.items_per_page %}
|
||||
<input type="hidden" name="items_per_page" value="{{ request.GET.items_per_page }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="d-grid gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
Применить
|
||||
</button>
|
||||
<a href="{{ reset_url|default:'?' }}" class="btn btn-secondary btn-sm">
|
||||
Сбросить
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update filter counter badge when filters are active
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filterCounter = document.getElementById('filterCounter');
|
||||
|
||||
if (filterCounter) {
|
||||
// Count active filters (excluding pagination, sort, search, and items_per_page)
|
||||
const excludedParams = ['page', 'sort', 'search', 'items_per_page'];
|
||||
let activeFilters = 0;
|
||||
|
||||
for (const [key, value] of urlParams.entries()) {
|
||||
if (!excludedParams.includes(key) && value) {
|
||||
activeFilters++;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeFilters > 0) {
|
||||
filterCounter.textContent = activeFilters;
|
||||
filterCounter.style.display = 'inline-block';
|
||||
} else {
|
||||
filterCounter.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<!-- Frequency Plan Modal -->
|
||||
<div class="modal fade" id="frequencyPlanModal" tabindex="-1" aria-labelledby="frequencyPlanModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="frequencyPlanModalLabel">Частотный план</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="modalLoadingSpinner" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modalFrequencyContent" style="display: none;">
|
||||
<p class="text-muted">Визуализация транспондеров спутника по частотам. <span style="color: #0d6efd;">■</span> Downlink (синий), <span style="color: #fd7e14;">■</span> Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации и связи с парным каналом.</p>
|
||||
|
||||
<div class="frequency-plan">
|
||||
<div class="chart-controls">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="modalResetZoom">
|
||||
<i class="bi bi-arrow-clockwise"></i> Сбросить масштаб
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="frequency-chart-container">
|
||||
<canvas id="modalFrequencyChart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<p><strong>Всего транспондеров:</strong> <span id="modalTransponderCount">0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modalNoData" style="display: none;" class="text-center text-muted py-5">
|
||||
<p>Нет данных о транспондерах для этого спутника</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.frequency-plan {
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.frequency-chart-container {
|
||||
position: relative;
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-controls button {
|
||||
padding: 5px 15px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,833 @@
|
||||
{% load l10n %}
|
||||
<!-- Вкладка фильтров и экспорта -->
|
||||
<form method="get" id="filterForm" class="mb-4">
|
||||
<input type="hidden" name="tab" value="filters">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Фильтры</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<!-- Спутники -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.satellites.id_for_label }}" class="form-label">{{ form.satellites.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('satellites', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('satellites', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.satellites }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Полоса спутника -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.band.id_for_label }}" class="form-label">{{ form.band.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('band', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('band', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.band }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Поляризация -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.polarization.id_for_label }}" class="form-label">{{ form.polarization.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('polarization', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('polarization', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.polarization }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Модуляция -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.modulation.id_for_label }}" class="form-label">{{ form.modulation.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('modulation', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('modulation', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.modulation }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Центральная частота -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Центральная частота (МГц)</label>
|
||||
<div class="input-group">
|
||||
{{ form.frequency_min }}
|
||||
<span class="input-group-text">—</span>
|
||||
{{ form.frequency_max }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Полоса -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Полоса (МГц)</label>
|
||||
<div class="input-group">
|
||||
{{ form.freq_range_min }}
|
||||
<span class="input-group-text">—</span>
|
||||
{{ form.freq_range_max }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Тип объекта -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.object_type.id_for_label }}" class="form-label">{{ form.object_type.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('object_type', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('object_type', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.object_type }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Принадлежность объекта -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.object_ownership.id_for_label }}" class="form-label">{{ form.object_ownership.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('object_ownership', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('object_ownership', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.object_ownership }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Количество ObjItem -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Количество привязанных точек ГЛ</label>
|
||||
<div class="input-group mb-2">
|
||||
{{ form.objitem_count_min }}
|
||||
</div>
|
||||
<div class="input-group">
|
||||
{{ form.objitem_count_max }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Планы на Кубсат -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">{{ form.has_plans.label }}</label>
|
||||
<div>
|
||||
{% for radio in form.has_plans %}
|
||||
<div class="form-check">
|
||||
{{ radio.tag }}
|
||||
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||
{{ radio.choice_label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ГСО успешно -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">{{ form.success_1.label }}</label>
|
||||
<div>
|
||||
{% for radio in form.success_1 %}
|
||||
<div class="form-check">
|
||||
{{ radio.tag }}
|
||||
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||
{{ radio.choice_label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кубсат успешно -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">{{ form.success_2.label }}</label>
|
||||
<div>
|
||||
{% for radio in form.success_2 %}
|
||||
<div class="form-check">
|
||||
{{ radio.tag }}
|
||||
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||
{{ radio.choice_label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Диапазон дат -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Диапазон дат ГЛ:</label>
|
||||
<div class="input-group">
|
||||
{{ form.date_from }}
|
||||
<span class="input-group-text">—</span>
|
||||
{{ form.date_to }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Применить фильтры</button>
|
||||
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">Сбросить</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Кнопка экспорта и статистика -->
|
||||
{% if sources_with_date_info %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||
<!-- Поиск по имени точки -->
|
||||
<div class="input-group" style="max-width: 350px;">
|
||||
<input type="text" id="searchObjitemName" class="form-control"
|
||||
placeholder="Поиск по имени точки..."
|
||||
oninput="filterTableByName()">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
||||
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="createRequestsFromTable()">
|
||||
<i class="bi bi-plus-circle"></i> Создать заявки
|
||||
</button>
|
||||
<span class="text-muted" id="statsCounter">
|
||||
Найдено объектов: {{ sources_with_date_info|length }},
|
||||
точек: {% for source_data in sources_with_date_info %}{{ source_data.objitems_data|length }}{% if not forloop.last %}+{% endif %}{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Таблица результатов -->
|
||||
{% if sources_with_date_info %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
|
||||
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;" id="resultsTable">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th style="min-width: 80px;">ID объекта</th>
|
||||
<th style="min-width: 120px;">Тип объекта</th>
|
||||
<th style="min-width: 150px;">Принадлежность объекта</th>
|
||||
<th class="text-center" style="min-width: 60px;" title="Всего заявок">Заявки</th>
|
||||
<th class="text-center" style="min-width: 80px;">ГСО</th>
|
||||
<th class="text-center" style="min-width: 80px;">Кубсат</th>
|
||||
<th class="text-center" style="min-width: 100px;">Статус заявки</th>
|
||||
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
|
||||
<th style="min-width: 150px;">Усреднённая координата</th>
|
||||
<th style="min-width: 120px;">Имя точки</th>
|
||||
<th style="min-width: 150px;">Спутник</th>
|
||||
<th style="min-width: 100px;">Частота (МГц)</th>
|
||||
<th style="min-width: 100px;">Полоса (МГц)</th>
|
||||
<th style="min-width: 100px;">Поляризация</th>
|
||||
<th style="min-width: 100px;">Модуляция</th>
|
||||
<th style="min-width: 150px;">Координаты ГЛ</th>
|
||||
<th style="min-width: 100px;">Дата ГЛ</th>
|
||||
<th style="min-width: 150px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for source_data in sources_with_date_info %}
|
||||
{% for objitem_data in source_data.objitems_data %}
|
||||
<tr data-source-id="{{ source_data.source.id }}"
|
||||
data-objitem-id="{{ objitem_data.objitem.id }}"
|
||||
data-objitem-name="{{ objitem_data.objitem.name|default:'' }}"
|
||||
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
||||
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}"
|
||||
data-lat="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.y }}{% endif %}"
|
||||
data-lon="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.x }}{% endif %}">
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="source-id-cell">{{ source_data.source.id }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="source-type-cell">{{ source_data.source.info.name|default:"-" }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="source-ownership-cell">
|
||||
{% if source_data.source.ownership %}
|
||||
{% if source_data.source.ownership.name == "ТВ" and source_data.has_lyngsat %}
|
||||
<a href="#" class="text-primary text-decoration-none"
|
||||
onclick="showLyngsatModal({{ source_data.lyngsat_id }}); return false;">
|
||||
<i class="bi bi-tv"></i> {{ source_data.source.ownership.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ source_data.source.ownership.name }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-requests-count-cell">
|
||||
{% if source_data.requests_count > 0 %}
|
||||
<span class="badge bg-info">{{ source_data.requests_count }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-gso-cell">
|
||||
{% if source_data.gso_success == True %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||
{% elif source_data.gso_success == False %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-kubsat-cell">
|
||||
{% if source_data.kubsat_success == True %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||
{% elif source_data.kubsat_success == False %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-status-cell">
|
||||
{% if source_data.request_status %}
|
||||
{% if source_data.request_status_raw == 'successful' or source_data.request_status_raw == 'result_received' %}
|
||||
<span class="badge bg-success">{{ source_data.request_status }}</span>
|
||||
{% elif source_data.request_status_raw == 'unsuccessful' or source_data.request_status_raw == 'no_correlation' or source_data.request_status_raw == 'no_signal' %}
|
||||
<span class="badge bg-danger">{{ source_data.request_status }}</span>
|
||||
{% elif source_data.request_status_raw == 'planned' %}
|
||||
<span class="badge bg-primary">{{ source_data.request_status }}</span>
|
||||
{% elif source_data.request_status_raw == 'downloading' or source_data.request_status_raw == 'processing' %}
|
||||
<span class="badge bg-warning text-dark">{{ source_data.request_status }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ source_data.request_status }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="source-avg-coords-cell"
|
||||
data-avg-lat="{{ source_data.avg_lat|default:''|unlocalize }}"
|
||||
data-avg-lon="{{ source_data.avg_lon|default:''|unlocalize }}">
|
||||
{% if source_data.avg_lat and source_data.avg_lon %}
|
||||
{{ source_data.avg_lat|floatformat:6|unlocalize }}, {{ source_data.avg_lon|floatformat:6|unlocalize }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
<td>{{ objitem_data.objitem.name|default:"-" }}</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %}
|
||||
{{ objitem_data.objitem.parameter_obj.id_satellite.name }}
|
||||
{% if objitem_data.objitem.parameter_obj.id_satellite.norad %}
|
||||
({{ objitem_data.objitem.parameter_obj.id_satellite.norad }})
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj %}
|
||||
{{ objitem_data.objitem.parameter_obj.frequency|default:"-"|floatformat:3 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj %}
|
||||
{{ objitem_data.objitem.parameter_obj.freq_range|default:"-"|floatformat:3 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %}
|
||||
{{ objitem_data.objitem.parameter_obj.polarization.name }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %}
|
||||
{{ objitem_data.objitem.parameter_obj.modulation.name }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}
|
||||
{{ objitem_data.objitem.geo_obj.coords.y|floatformat:6|unlocalize }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6|unlocalize }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.geo_date %}
|
||||
{{ objitem_data.geo_date|date:"d.m.Y" }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="removeObjItem(this)" title="Удалить точку">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% if forloop.first %}
|
||||
<button type="button" class="btn btn-sm btn-warning" onclick="removeSource(this)" title="Удалить весь объект">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif request.GET %}
|
||||
<div class="alert alert-info">
|
||||
По заданным критериям ничего не найдено.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// Функция для пересчёта усреднённых координат источника через Python API
|
||||
// Координаты рассчитываются на сервере с сортировкой по дате ГЛ
|
||||
function recalculateAverageCoords(sourceId) {
|
||||
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||
if (sourceRows.length === 0) return;
|
||||
|
||||
// Собираем ID всех оставшихся точек для этого источника
|
||||
const objitemIds = sourceRows.map(row => row.dataset.objitemId).filter(id => id);
|
||||
|
||||
if (objitemIds.length === 0) {
|
||||
// Нет точек - очищаем координаты
|
||||
updateAvgCoordsCell(sourceId, null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Вызываем Python API для пересчёта координат
|
||||
const formData = new FormData();
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfToken) {
|
||||
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||
}
|
||||
objitemIds.forEach(id => formData.append('objitem_ids', id));
|
||||
|
||||
fetch('{% url "mainapp:kubsat_recalculate_coords" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success && result.results[sourceId]) {
|
||||
const coords = result.results[sourceId];
|
||||
updateAvgCoordsCell(sourceId, coords.avg_lat, coords.avg_lon);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error recalculating coords:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляет ячейку с усреднёнными координатами
|
||||
function updateAvgCoordsCell(sourceId, avgLat, avgLon) {
|
||||
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||
if (sourceRows.length === 0) return;
|
||||
|
||||
const firstRow = sourceRows[0];
|
||||
const avgCoordsCell = firstRow.querySelector('.source-avg-coords-cell');
|
||||
if (avgCoordsCell) {
|
||||
if (avgLat !== null && avgLon !== null) {
|
||||
avgCoordsCell.textContent = `${avgLat.toFixed(6)}, ${avgLon.toFixed(6)}`;
|
||||
avgCoordsCell.dataset.avgLat = avgLat;
|
||||
avgCoordsCell.dataset.avgLon = avgLon;
|
||||
} else {
|
||||
avgCoordsCell.textContent = '-';
|
||||
avgCoordsCell.dataset.avgLat = '';
|
||||
avgCoordsCell.dataset.avgLon = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeObjItem(button) {
|
||||
const row = button.closest('tr');
|
||||
const sourceId = row.dataset.sourceId;
|
||||
const isFirstInSource = row.dataset.isFirstInSource === 'true';
|
||||
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||
|
||||
// All rowspan cells that need to be handled
|
||||
const rowspanCellClasses = [
|
||||
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||
];
|
||||
|
||||
if (sourceRows.length === 1) {
|
||||
row.remove();
|
||||
} else if (isFirstInSource) {
|
||||
const nextRow = sourceRows[1];
|
||||
const cells = rowspanCellClasses.map(cls => row.querySelector(cls)).filter(c => c);
|
||||
|
||||
if (cells.length > 0) {
|
||||
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||
const newRowspan = currentRowspan - 1;
|
||||
|
||||
// Clone and update all rowspan cells
|
||||
const newCells = cells.map(cell => {
|
||||
const newCell = cell.cloneNode(true);
|
||||
newCell.setAttribute('rowspan', newRowspan);
|
||||
if (newCell.classList.contains('source-count-cell')) {
|
||||
newCell.textContent = newRowspan;
|
||||
}
|
||||
return newCell;
|
||||
});
|
||||
|
||||
// Insert cells in reverse order to maintain correct order
|
||||
newCells.reverse().forEach(cell => {
|
||||
nextRow.insertBefore(cell, nextRow.firstChild);
|
||||
});
|
||||
|
||||
const actionsCell = nextRow.querySelector('td:last-child');
|
||||
if (actionsCell) {
|
||||
const btnGroup = actionsCell.querySelector('.btn-group');
|
||||
if (btnGroup && btnGroup.children.length === 1) {
|
||||
const deleteSourceBtn = document.createElement('button');
|
||||
deleteSourceBtn.type = 'button';
|
||||
deleteSourceBtn.className = 'btn btn-sm btn-warning';
|
||||
deleteSourceBtn.onclick = function() { removeSource(this); };
|
||||
deleteSourceBtn.title = 'Удалить весь объект';
|
||||
deleteSourceBtn.innerHTML = '<i class="bi bi-trash-fill"></i>';
|
||||
btnGroup.appendChild(deleteSourceBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
nextRow.dataset.isFirstInSource = 'true';
|
||||
row.remove();
|
||||
// Пересчитываем усреднённые координаты после удаления точки
|
||||
recalculateAverageCoords(sourceId);
|
||||
} else {
|
||||
const firstRow = sourceRows[0];
|
||||
const cells = rowspanCellClasses.map(cls => firstRow.querySelector(cls)).filter(c => c);
|
||||
|
||||
if (cells.length > 0) {
|
||||
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||
const newRowspan = currentRowspan - 1;
|
||||
|
||||
cells.forEach(cell => {
|
||||
cell.setAttribute('rowspan', newRowspan);
|
||||
if (cell.classList.contains('source-count-cell')) {
|
||||
cell.textContent = newRowspan;
|
||||
}
|
||||
});
|
||||
}
|
||||
row.remove();
|
||||
// Пересчитываем усреднённые координаты после удаления точки
|
||||
recalculateAverageCoords(sourceId);
|
||||
}
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function removeSource(button) {
|
||||
const row = button.closest('tr');
|
||||
const sourceId = row.dataset.sourceId;
|
||||
const rows = document.querySelectorAll(`tr[data-source-id="${sourceId}"]`);
|
||||
rows.forEach(r => r.remove());
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function updateCounter() {
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
const counter = document.getElementById('statsCounter');
|
||||
if (counter) {
|
||||
// Подсчитываем уникальные источники и точки (только видимые)
|
||||
const uniqueSources = new Set();
|
||||
let visibleRowsCount = 0;
|
||||
rows.forEach(row => {
|
||||
if (row.style.display !== 'none') {
|
||||
uniqueSources.add(row.dataset.sourceId);
|
||||
visibleRowsCount++;
|
||||
}
|
||||
});
|
||||
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
|
||||
}
|
||||
}
|
||||
|
||||
function exportToExcel() {
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||
|
||||
if (objitemIds.length === 0) {
|
||||
alert('Нет данных для экспорта');
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{% url "mainapp:kubsat_export" %}';
|
||||
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfToken) {
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrfmiddlewaretoken';
|
||||
csrfInput.value = csrfToken.value;
|
||||
form.appendChild(csrfInput);
|
||||
}
|
||||
|
||||
objitemIds.forEach(id => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'objitem_ids';
|
||||
input.value = id;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
}
|
||||
|
||||
function selectAllOptions(selectName, selectAll) {
|
||||
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
||||
if (selectElement) {
|
||||
for (let i = 0; i < selectElement.options.length; i++) {
|
||||
selectElement.options[i].selected = selectAll;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestsFromTable() {
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||
|
||||
if (objitemIds.length === 0) {
|
||||
alert('Нет данных для создания заявок');
|
||||
return;
|
||||
}
|
||||
|
||||
// Подсчитываем уникальные источники
|
||||
const uniqueSources = new Set();
|
||||
rows.forEach(row => uniqueSources.add(row.dataset.sourceId));
|
||||
|
||||
if (!confirm(`Будет создано ${uniqueSources.size} заявок (по одной на каждый источник) со статусом "Запланировано".\n\nКоординаты будут рассчитаны как среднее по выбранным точкам.\n\nПродолжить?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
const btn = event.target.closest('button');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Создание...';
|
||||
|
||||
const formData = new FormData();
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfToken) {
|
||||
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||
}
|
||||
|
||||
objitemIds.forEach(id => {
|
||||
formData.append('objitem_ids', id);
|
||||
});
|
||||
|
||||
fetch('{% url "mainapp:kubsat_create_requests" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
|
||||
if (result.success) {
|
||||
let message = `Создано заявок: ${result.created_count} из ${result.total_sources}`;
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
message += `\n\nОшибки:\n${result.errors.join('\n')}`;
|
||||
}
|
||||
alert(message);
|
||||
// Перезагружаем страницу для обновления данных
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка: ' + result.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
console.error('Error creating requests:', error);
|
||||
alert('Ошибка создания заявок');
|
||||
});
|
||||
}
|
||||
|
||||
// Фильтрация таблицы по имени точки
|
||||
function filterTableByName() {
|
||||
const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim();
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
|
||||
if (!searchValue) {
|
||||
// Показываем все строки
|
||||
rows.forEach(row => {
|
||||
row.style.display = '';
|
||||
});
|
||||
// Восстанавливаем rowspan
|
||||
recalculateRowspans();
|
||||
updateCounter();
|
||||
return;
|
||||
}
|
||||
|
||||
// Группируем строки по source_id
|
||||
const sourceGroups = {};
|
||||
rows.forEach(row => {
|
||||
const sourceId = row.dataset.sourceId;
|
||||
if (!sourceGroups[sourceId]) {
|
||||
sourceGroups[sourceId] = [];
|
||||
}
|
||||
sourceGroups[sourceId].push(row);
|
||||
});
|
||||
|
||||
// Фильтруем по имени точки используя data-атрибут
|
||||
Object.keys(sourceGroups).forEach(sourceId => {
|
||||
const sourceRows = sourceGroups[sourceId];
|
||||
let hasVisibleRows = false;
|
||||
|
||||
sourceRows.forEach(row => {
|
||||
// Используем data-атрибут для получения имени точки
|
||||
const name = (row.dataset.objitemName || '').toLowerCase();
|
||||
|
||||
if (name.includes(searchValue)) {
|
||||
row.style.display = '';
|
||||
hasVisibleRows = true;
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan)
|
||||
if (!hasVisibleRows) {
|
||||
sourceRows.forEach(row => {
|
||||
row.style.display = 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Пересчитываем rowspan для видимых строк
|
||||
recalculateRowspans();
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
// Пересчет rowspan для видимых строк
|
||||
function recalculateRowspans() {
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
|
||||
// Группируем видимые строки по source_id
|
||||
const sourceGroups = {};
|
||||
rows.forEach(row => {
|
||||
if (row.style.display !== 'none') {
|
||||
const sourceId = row.dataset.sourceId;
|
||||
if (!sourceGroups[sourceId]) {
|
||||
sourceGroups[sourceId] = [];
|
||||
}
|
||||
sourceGroups[sourceId].push(row);
|
||||
}
|
||||
});
|
||||
|
||||
// All rowspan cell classes
|
||||
const rowspanCellClasses = [
|
||||
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||
];
|
||||
|
||||
// Обновляем rowspan для каждой группы
|
||||
Object.keys(sourceGroups).forEach(sourceId => {
|
||||
const visibleRows = sourceGroups[sourceId];
|
||||
const newRowspan = visibleRows.length;
|
||||
|
||||
if (visibleRows.length > 0) {
|
||||
const firstRow = visibleRows[0];
|
||||
|
||||
rowspanCellClasses.forEach(cls => {
|
||||
const cell = firstRow.querySelector(cls);
|
||||
if (cell) {
|
||||
cell.setAttribute('rowspan', newRowspan);
|
||||
// Обновляем отображаемое количество точек
|
||||
if (cell.classList.contains('source-count-cell')) {
|
||||
cell.textContent = newRowspan;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Очистка поиска
|
||||
function clearSearch() {
|
||||
document.getElementById('searchObjitemName').value = '';
|
||||
filterTableByName();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateCounter();
|
||||
});
|
||||
</script>
|
||||
@@ -2,24 +2,40 @@
|
||||
Переиспользуемый компонент для отображения сообщений Django
|
||||
Использование:
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
Для отключения автоскрытия добавьте extra_tags='persistent':
|
||||
messages.success(request, "Сообщение", extra_tags='persistent')
|
||||
{% endcomment %}
|
||||
|
||||
{% if messages %}
|
||||
<div class="messages-container">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{% if message.tags == 'error' %}
|
||||
<div class="alert alert-{% if 'error' in message.tags %}danger{% elif 'success' in message.tags %}success{% elif 'warning' in message.tags %}warning{% else %}info{% endif %} alert-dismissible fade show {% if 'persistent' not in message.tags %}auto-dismiss{% endif %}" role="alert">
|
||||
{% if 'error' in message.tags %}
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{% elif message.tags == 'success' %}
|
||||
{% elif 'success' in message.tags %}
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
{% elif message.tags == 'warning' %}
|
||||
{% elif 'warning' in message.tags %}
|
||||
<i class="bi bi-exclamation-circle-fill me-2"></i>
|
||||
{% elif message.tags == 'info' %}
|
||||
{% elif 'info' in message.tags %}
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
{% endif %}
|
||||
{{ message }}
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Автоматическое скрытие уведомлений через 5 секунд (кроме persistent)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const alerts = document.querySelectorAll('.alert.auto-dismiss');
|
||||
alerts.forEach(function(alert) {
|
||||
setTimeout(function() {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -6,24 +6,45 @@
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{% url 'mainapp:home' %}">Геолокация</a>
|
||||
<a class="navbar-brand" href="{% url 'mainapp:source_list' %}">Геолокация</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
{% if user.is_authenticated %}
|
||||
<ul class="navbar-nav me-auto">
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:source_list' %}">Главная</a>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:home' %}">Объекты</a>
|
||||
<a class="nav-link" href="{% url 'mainapp:source_list' %}">Объекты</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Точки</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:satellite_list' %}">Спутники</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:transponder_list' %}">Транспондеры</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a>
|
||||
</li>
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:signal_marks' %}">Отметки сигналов</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
|
||||
</li>
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
|
||||
</li>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mapsapp:2dmap' %}">2D карта</a>
|
||||
<a class="nav-link" href="{% url 'mapsapp:2dmap' %}">Карта</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'admin:index' %}">Админ панель</a>
|
||||
|
||||
126
dbapp/mainapp/templates/mainapp/components/_objitems_table.html
Normal file
126
dbapp/mainapp/templates/mainapp/components/_objitems_table.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!-- ObjItems Table Component -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">Объекты (ObjItems)</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
|
||||
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.9rem;">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя</th>
|
||||
<th>Спутник</th>
|
||||
<th>Частота, МГц</th>
|
||||
<th>Полоса, МГц</th>
|
||||
<th>Поляризация</th>
|
||||
<th>Модуляция</th>
|
||||
<th>Сим. v</th>
|
||||
<th>ОСШ</th>
|
||||
<th>Геолокация</th>
|
||||
<th>Дата гео</th>
|
||||
<th>Объект</th>
|
||||
<th>LyngSat</th>
|
||||
{% if show_marks == '1' %}
|
||||
<th>Отметки</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in processed_objitems %}
|
||||
<tr>
|
||||
<td><a href="{% url 'mainapp:objitem_detail' item.id %}">{{ item.id }}</a></td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.satellite }}</td>
|
||||
<td>{{ item.frequency }}</td>
|
||||
<td>{{ item.freq_range }}</td>
|
||||
<td>{{ item.polarization }}</td>
|
||||
<td>{{ item.modulation }}</td>
|
||||
<td>{{ item.bod_velocity }}</td>
|
||||
<td>{{ item.snr }}</td>
|
||||
<td>{{ item.geo_coords }}</td>
|
||||
<td>{{ item.geo_date }}</td>
|
||||
<td>
|
||||
{% if item.source_id %}
|
||||
<a href="{% url 'mainapp:source_update' item.source_id %}">{{ item.source_id }}</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.lyngsat_id %}
|
||||
<a href="{% url 'admin:lyngsatapp_lyngsat_change' item.lyngsat_id %}" target="_blank">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if show_marks == '1' %}
|
||||
<td>
|
||||
{% if item.marks %}
|
||||
<div style="max-height: 150px; overflow-y: auto;">
|
||||
{% for mark in item.marks %}
|
||||
<div class="mb-1">
|
||||
<span class="mark-badge {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
|
||||
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
|
||||
</span>
|
||||
<br>
|
||||
<small class="text-muted">{{ mark.timestamp|date:"d.m.Y H:i" }}</small>
|
||||
<br>
|
||||
<small class="text-muted">{{ mark.created_by }}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="{% if show_marks == '1' %}14{% else %}13{% endif %}" class="text-center py-4 text-muted">
|
||||
Нет данных для выбранных фильтров
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<div class="card-footer">
|
||||
<nav>
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1">Первая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}">Предыдущая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}">Следующая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}">Последняя</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="text-center mt-2">
|
||||
<small class="text-muted">Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user