Compare commits

...

84 Commits

Author SHA1 Message Date
ca7709ebff Добавил абуз вч отметок 2025-12-12 15:46:00 +03:00
9bf701f05a Визуальные изменение. Доработки и фиксы багов 2025-12-12 15:08:10 +03:00
f5875e5b87 Поправил кубсат и добавил библиотеку luxon 2025-12-11 11:27:01 +03:00
f79efd88e5 Ещё поправил статистику 2025-12-11 10:48:27 +03:00
cf3c7ee01a Поправил ошибку. Чутка поменял статистику. 2025-12-11 09:48:38 +03:00
41e8dc30fd Переосмыслил отметки по ВЧ загрузке. Улучшил статистику 2025-12-10 17:43:38 +03:00
4949a03e68 Поправил теханализ 2025-12-10 12:50:52 +03:00
d889dc7b2a Доделал таблицу с кубсатом 2025-12-10 12:29:43 +03:00
8393734dc3 Фикс заголовков для локальной карты 2025-12-09 10:59:44 +03:00
25fe93231f Добавил зоны для спутников 2025-12-08 15:48:46 +03:00
8fb8b08c93 Добавил работу с заявками на кубсат 2025-12-08 15:37:23 +03:00
2b856ff6dc Добавил поле в модель спутников 2025-12-05 16:32:59 +03:00
cff2c73b6a Повторный фикс url 2025-12-05 12:00:11 +03:00
9c095a7229 Добавил URL flaresolverr в переменные среды 2025-12-05 11:12:22 +03:00
09bbedda18 Добавил локальную карту 2025-12-05 09:52:11 +03:00
727c24fb1f Спрятал secret stat 2025-12-04 14:19:48 +03:00
00b85b5bf2 Микрофикс кнопок 2025-12-04 12:37:12 +03:00
f954f77a6d Добавил локально библиотеку chart js. Сделал секретную статистику 2025-12-04 12:35:08 +03:00
027f971f5a Добавил статистики 2025-12-04 11:33:43 +03:00
30b56de709 Немного поправил визуал 2025-12-04 09:27:06 +03:00
24314b84ac Слои на карте. v0.1/ 2025-12-03 17:32:13 +03:00
4164ea2109 Пофиксил баг с координатами 2025-12-03 14:18:09 +03:00
51eb5f3732 Подправил маркеры на карте 2025-12-03 11:47:41 +03:00
d7d85ac834 Второй.1 трай фикса celery 2025-12-02 17:22:40 +03:00
118c86a73c Второй трай фикса celery 2025-12-02 17:12:42 +03:00
3388f787c7 Первый трай фикса celery 2025-12-02 16:44:19 +03:00
889899080a Поменял теханализ, улучшения по простбам 2025-12-02 14:56:29 +03:00
a18071b7ec Поменял усреднение 2025-12-02 11:47:47 +03:00
b9e17df32c Переделал усреднение. Вариант 1 2025-12-02 09:57:09 +03:00
96f961b0f8 Пофиксил умена зеркал при добавлении 2025-12-02 09:16:36 +03:00
ad479a2069 Добавио интервал выходных 2025-12-01 17:14:52 +03:00
300927c7ea Поправил csv импорт 2025-12-01 16:42:17 +03:00
8d75e47abc Исправил импорт данных с привязкой спутников 2025-12-01 15:48:00 +03:00
c72bf12d41 Добавил альтернативное имя у спутника 2025-12-01 12:19:24 +03:00
01871c3e13 Усредение точек в проекции ГК 2025-12-01 09:54:22 +03:00
d521b6baad Начал с усреднениями 2025-11-28 00:18:04 +03:00
908e11879d Поправил общую карту с footprintaми 2025-11-27 17:36:23 +03:00
eba19126ef Добавил локальную библиотеку для таблиц 2025-11-27 12:29:24 +03:00
0be829b97b Поправил вставку данных 2025-11-27 12:17:41 +03:00
810d3a8f7f Добавил теханализ 2025-11-27 11:36:00 +03:00
efb99ea8d5 Дополнил данные по спутникам при добавлении 2025-11-27 09:35:07 +03:00
bd39717e86 Начал редактирование парсинга спутников 2025-11-26 23:57:21 +03:00
d832171325 Добавил плавную анимацию для нескольких источников 2025-11-26 23:09:29 +03:00
cfaaae9360 Добавил форму для отправки данных 2025-11-26 17:35:59 +03:00
27694a3a7d Добавил анимацию в треку. Добавил 2 локальные js библиотеки 2025-11-26 11:12:14 +03:00
609fd5a1da Добавил объединение источников. Вернул норм карту. Удалил ненужные либы 2025-11-26 10:33:07 +03:00
388753ba31 Добавил трек и поле Примечание к Source 2025-11-25 17:45:34 +03:00
68486d2283 Логи и деплой поправил 2025-11-25 10:54:12 +03:00
e24cf8a105 Поправил баг с сортировкой 2025-11-25 10:19:47 +03:00
7879c3d9b5 Добавил формы создания и пофиксил баг с пользователями 2025-11-24 23:47:00 +03:00
1c18ae96f7 На деплой 2025-11-24 13:57:31 +03:00
a591b79656 Поправил частотный план 2025-11-24 12:11:09 +03:00
ed9a79f94a Подправил частотный план 2025-11-23 23:27:09 +03:00
9a9900cfa6 Сделал деплой 2025-11-23 22:55:32 +03:00
0d239ef1de Переделки и улучшения 2025-11-21 16:56:58 +03:00
58838614a5 Внёс мелкие правки и фиксы 2025-11-21 10:31:26 +03:00
c2c8c8799f Сделал вкладку спутников 2025-11-20 13:44:48 +03:00
1d1c42a8e7 Доделал страницу с Кубсатами 2025-11-20 10:50:27 +03:00
66e1929978 Страница с Кубсатами 2025-11-19 17:36:39 +03:00
4d7cc9f667 Сделал 1 карту на LibreMap 2025-11-18 17:15:03 +03:00
c8bcd1adf0 После рефакторинга 2025-11-18 14:44:32 +03:00
55759ec705 Привязка LyngSat сразу в функция импорта 2025-11-18 10:06:31 +03:00
06a39278d2 Поправил баг с LyngSat и добавил локально библиотеку 2025-11-18 09:36:19 +03:00
c0f2f16303 Добавил геофильтры. Теперь нужен рефакторинг. 2025-11-17 17:44:24 +03:00
b889fb29a3 Добавил информацию о типе объекта. Просто фиксы 2025-11-17 15:54:27 +03:00
f438e74946 Поправил геофильтр и отображения источника в отметках 2025-11-17 10:45:32 +03:00
c55a41f5fe Фильтр по дате ГЛ. Пока не работает 2025-11-16 23:58:34 +03:00
8994a0e500 Правки и улучшения визуала. Добавил функционал отметок. 2025-11-16 23:32:29 +03:00
d9cb243388 Исправил отображения объектов в источниках 2025-11-16 00:16:50 +03:00
9a816e62c2 Поправил алгоритм формирования источников 2025-11-15 21:54:13 +03:00
bc226bfc1a Виджет с усреднёнными точками на карте 2025-11-14 16:58:13 +03:00
d61236dee2 Снова улучшения и добавления 2025-11-14 11:41:19 +03:00
6a26991dc0 Добавил транспондеры к ObjItem шаблону 2025-11-14 08:00:23 +03:00
5ab6770809 Виджет для формы выбора зеркал 2025-11-13 21:09:39 +03:00
8e0d32c307 Улучшение и добавления 2025-11-13 17:54:06 +03:00
122fe74e14 Реструктуризация views 2025-11-13 16:11:37 +03:00
d0a53e251e Переделал страницу с ObjItem. Теперь работает корректно. 2025-11-13 14:21:02 +03:00
50498166e5 Добавил разделение по исчтоника и поправил функцию импорта из Excel csv 2025-11-12 23:49:58 +03:00
a7e8f81ef3 Поправил админку для новой модели 2025-11-12 22:03:00 +03:00
7126974aed Процесс переделки 2025-11-12 17:53:25 +03:00
73ce06deec Закончил показ. Теперь полная переделка 2025-11-12 12:46:08 +03:00
902eb23bd8 Поправил привязку вч загрузки, сделал модальное окно. 2025-11-12 00:04:55 +03:00
5e94086bf0 Привязка данных LyngSat 2025-11-11 22:40:52 +03:00
a3c381b9c7 Добавил кеш к lyngsat 2025-11-11 21:43:59 +03:00
294 changed files with 366315 additions and 13822 deletions

View File

@@ -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

View File

@@ -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
View 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
View File

@@ -33,3 +33,5 @@ tiles
# Docker
# docker-*
maplibre-gl-js-5.10.0.zip
cert.pem
templ.json

View File

@@ -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/

View File

@@ -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/)

View File

@@ -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. Протестировать форму заполнения данных через веб-интерфейс
## Примечания
- Процесс заполнения может занять продолжительное время (несколько минут на спутник)
- Рекомендуется начинать с небольшого количества спутников
- Все ошибки логируются и отображаются пользователю
- Существующие записи обновляются, новые создаются

View File

@@ -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)

View File

@@ -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`

View File

@@ -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/
```

View File

@@ -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) для подробностей

View File

@@ -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/

View File

@@ -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 в репозитории с описанием проблемы и логами

View File

@@ -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` (заменена карточка с картами)
## Примечания
- Процесс может занять продолжительное время в зависимости от количества выбранных спутников
- Рекомендуется выбирать небольшое количество спутников для первого запуска
- Существующие записи будут обновлены, новые - созданы
- Все ошибки логируются и отображаются пользователю

View File

@@ -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

View File

@@ -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/`

View File

@@ -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 на наличие ошибок
- Обновите страницу

View File

@@ -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
```

View File

@@ -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
View 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)

View File

@@ -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()

View File

@@ -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':

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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()

View 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
View 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

View 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]} км")

View 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

View 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

View File

View 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}'))

View File

@@ -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',

View File

@@ -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='Дата посленего обновления'),
),
]

View 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='Стандарт'),
),
]

View File

@@ -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]
}

View 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
View 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'),
]

View File

@@ -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}' не найден в базе данных"

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1 @@
# Management commands package

View File

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

View 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} рабочих дней'
)
)

View File

@@ -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'],
},
),
]

View 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'),
),
]

View File

@@ -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='Изменен пользователем'),
),
]

View File

@@ -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='Дата последнего изменения'),
),
]

View File

@@ -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='Транспондер'),
),
]

View File

@@ -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='Зеркала'),
),
]

View File

@@ -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',
),
]

View File

@@ -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='Гео'),
),
]

View File

@@ -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'],
},
),
]

View File

@@ -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'),
),
]

View 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='Источник'),
),
]

View 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='Источник'),
),
]

View File

@@ -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='Объект'),
),
]

View 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='Тип объекта'),
),
]

View File

@@ -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='Мощность, дБм'),
),
]

View File

@@ -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='Принадлежность объекта'),
),
]

View 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),
]

View File

@@ -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),
]

View File

@@ -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='Последний сигнал'),
),
]

View 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',
),
]

View 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='Примечание'),
),
]

View File

@@ -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='Международный код'),
),
]

View File

@@ -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'],
},
),
]

View File

@@ -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='Стандарт'),
),
]

View 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'),
),
]

View File

@@ -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='Количество точек'),
),
]

View 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='Комплекс'),
),
]

View 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='Источник'),
),
]

View File

@@ -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'
),
),
]

View File

@@ -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='Наличие сигнала'),
),
]

View File

@@ -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='Старый статус'),
),
]

View File

@@ -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

View File

@@ -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()

View 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;
}

View 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

View 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();
}

View 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>

View 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();
}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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 %}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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>

View 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