Compare commits

..

90 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
4f21c9d7c8 Настроил сеелери, начал привязку lyngsat 2025-11-11 17:23:36 +03:00
65e6c9a323 Добавил форму для загрузки данных с LyngSat 2025-11-10 23:28:06 +03:00
1b345a3fd9 Переделал модель Parameter и связь с ObjItem 2025-11-10 22:32:26 +03:00
b24ef940ce Добавление данных LyngSat в базу 2025-11-10 17:59:35 +03:00
0858961410 Сделал заготовку для кубсатов 2025-11-10 16:15:55 +03:00
9e9468ed34 Новый визуальный функционал 2025-11-10 14:56:21 +03:00
319 changed files with 385502 additions and 28768 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,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,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

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

1
dbapp/.python-version Normal file
View File

@@ -0,0 +1 @@
3.13.7

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

@@ -0,0 +1,7 @@
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
try:
from .celery import app as celery_app
__all__ = ('celery_app',)
except ImportError:
pass

18
dbapp/dbapp/celery.py Normal file
View File

@@ -0,0 +1,18 @@
"""
Celery configuration for dbapp project.
"""
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', os.getenv('DJANGO_SETTINGS_MODULE', 'dbapp.settings.development'))
app = Celery('dbapp')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
@app.task(bind=True, ignore_result=True)
def debug_task(self):
print(f'Request: {self.request!r}')

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

@@ -21,19 +21,21 @@ load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
# GDAL/GEOS configuration for Windows
if os.name == 'nt':
if os.name == "nt":
OSGEO4W = r"C:\Program Files\OSGeo4W"
assert os.path.isdir(OSGEO4W), "Directory does not exist: " + OSGEO4W
os.environ['OSGEO4W_ROOT'] = OSGEO4W
os.environ['PROJ_LIB'] = os.path.join(OSGEO4W, r"share\proj")
os.environ['PATH'] = OSGEO4W + r"\bin;" + os.environ['PATH']
os.environ["OSGEO4W_ROOT"] = OSGEO4W
os.environ["PROJ_LIB"] = os.path.join(OSGEO4W, r"share\proj")
os.environ["PATH"] = OSGEO4W + r"\bin;" + os.environ["PATH"]
# ============================================================================
# SECURITY SETTINGS
# ============================================================================
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq')
SECRET_KEY = os.getenv(
"SECRET_KEY", "django-insecure-7etj5f7buo2a57xv=w3^&llusq8rii7b_gd)9$t_1xcnao!^tq"
)
# SECURITY WARNING: don't run with debug turned on in production!
# This should be overridden in environment-specific settings
@@ -49,40 +51,35 @@ ALLOWED_HOSTS = []
INSTALLED_APPS = [
# Django Autocomplete Light (must be before admin)
'dal',
'dal_select2',
"dal",
"dal_select2",
# Admin interface customization
'admin_interface',
'colorfield',
"admin_interface",
"colorfield",
# Django GIS
'django.contrib.gis',
"django.contrib.gis",
# Django core apps
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
# Third-party apps
'leaflet',
'dynamic_raw_id',
'rangefilter',
'django_admin_multiple_choice_list_filter',
'more_admin_filters',
'import_export',
"leaflet",
"dynamic_raw_id",
"rangefilter",
"django_admin_multiple_choice_list_filter",
"more_admin_filters",
"import_export",
"django_celery_results",
# Project apps
'mainapp',
'mapsapp',
"mainapp",
"mapsapp",
"lyngsatapp",
]
# Note: Custom user model is implemented via OneToOneField relationship
# If you need a custom user model, uncomment and configure:
# AUTH_USER_MODEL = 'mainapp.CustomUser'
# ============================================================================
@@ -90,17 +87,17 @@ INSTALLED_APPS = [
# ============================================================================
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = 'dbapp.urls'
ROOT_URLCONF = "dbapp.urls"
# ============================================================================
# TEMPLATES CONFIGURATION
@@ -108,36 +105,36 @@ ROOT_URLCONF = 'dbapp.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
BASE_DIR / 'templates',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
BASE_DIR / "templates",
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = 'dbapp.wsgi.application'
WSGI_APPLICATION = "dbapp.wsgi.application"
# ============================================================================
# DATABASE CONFIGURATION
# ============================================================================
DATABASES = {
'default': {
'ENGINE': os.getenv('DB_ENGINE', 'django.contrib.gis.db.backends.postgis'),
'NAME': os.getenv('DB_NAME', 'db'),
'USER': os.getenv('DB_USER', 'user'),
'PASSWORD': os.getenv('DB_PASSWORD', 'password'),
'HOST': os.getenv('DB_HOST', 'localhost'),
'PORT': os.getenv('DB_PORT', '5432'),
"default": {
"ENGINE": os.getenv("DB_ENGINE", "django.contrib.gis.db.backends.postgis"),
"NAME": os.getenv("DB_NAME", "db"),
"USER": os.getenv("DB_USER", "user"),
"PASSWORD": os.getenv("DB_PASSWORD", "password"),
"HOST": os.getenv("DB_HOST", "localhost"),
"PORT": os.getenv("DB_PORT", "5432"),
}
}
@@ -148,16 +145,16 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
@@ -165,9 +162,9 @@ AUTH_PASSWORD_VALIDATORS = [
# INTERNATIONALIZATION
# ============================================================================
LANGUAGE_CODE = 'ru'
LANGUAGE_CODE = "ru"
TIME_ZONE = 'Europe/Moscow'
TIME_ZONE = "Europe/Moscow"
USE_I18N = True
@@ -177,18 +174,18 @@ USE_TZ = True
# AUTHENTICATION CONFIGURATION
# ============================================================================
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'home'
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "mainapp:source_list"
LOGOUT_REDIRECT_URL = "mainapp:source_list"
# ============================================================================
# STATIC FILES CONFIGURATION
# ============================================================================
STATIC_URL = '/static/'
STATIC_URL = "/static/"
STATICFILES_DIRS = [
BASE_DIR.parent / 'static',
BASE_DIR.parent / "static",
]
# STATIC_ROOT will be set in production.py
@@ -198,7 +195,9 @@ STATICFILES_DIRS = [
# ============================================================================
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
# ============================================================================
# THIRD-PARTY APP CONFIGURATION
@@ -210,17 +209,82 @@ SILENCED_SYSTEM_CHECKS = ["security.W019"]
# Leaflet Configuration
LEAFLET_CONFIG = {
'ATTRIBUTION_PREFIX': '',
'TILES': [
"ATTRIBUTION_PREFIX": "",
"TILES": [
(
'Satellite',
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{'attribution': '&copy; Esri', 'maxZoom': 16}
"Satellite",
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
{"attribution": "&copy; Esri", "maxZoom": 16},
),
(
'Streets',
'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{'attribution': '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'}
)
"Streets",
"http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
{
"attribution": '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
},
),
],
}
# ============================================================================
# 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_CACHE_BACKEND = "default"
# Celery Task Configuration
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 minutes
CELERY_TASK_SOFT_TIME_LIMIT = 25 * 60 # 25 minutes
CELERY_TASK_ALWAYS_EAGER = False # Set to True for synchronous execution in development
# Celery Beat Configuration (for periodic tasks)
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# Celery Result Backend Configuration
CELERY_RESULT_EXTENDED = True
CELERY_RESULT_EXPIRES = 3600 # Results expire after 1 hour
# Celery Logging
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
CELERY_WORKER_LOG_FORMAT = "[%(asctime)s: %(levelname)s/%(processName)s] %(message)s"
CELERY_WORKER_TASK_LOG_FORMAT = "[%(asctime)s: %(levelname)s/%(processName)s][%(task_name)s(%(task_id)s)] %(message)s"
# Celery Accept Content
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

@@ -3,6 +3,8 @@ from .models import LyngSat
@admin.register(LyngSat)
class LyngSatAdmin(admin.ModelAdmin):
list_display = ("mark", "timestamp")
search_fields = ("mark", )
ordering = ("timestamp",)
list_display = ("id_satellite", "frequency", "polarization", "modulation", "last_update")
search_fields = ("id_satellite__name", "channel_info")
list_filter = ("id_satellite", "polarization", "modulation", "standard")
ordering = ("-last_update",)
readonly_fields = ("last_update",)

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

@@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2025-11-12 14:21
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='LyngSat',
fields=[
('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='Дата посленего обновления')),
('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='Ссылка на страницу')),
],
options={
'verbose_name': 'Источник LyngSat',
'verbose_name_plural': 'Источники LyngSat',
},
),
]

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

@@ -22,9 +22,10 @@ class LyngSat(models.Model):
)
frequency = models.FloatField(default=0, null=True, blank=True, verbose_name="Частота, МГц")
sym_velocity = models.FloatField(default=0, null=True, blank=True, verbose_name="Символьная скорость, БОД")
last_update = models.DateTimeField(null=True, blank=True, verbose_name="Время")
last_update = models.DateTimeField(null=True, blank=True, verbose_name="Дата посленего обновления")
channel_info = models.CharField(max_length=20, blank=True, null=True, verbose_name="Описание источника")
# url = models.URLField(max_length = 200, blank=True, null=True, verbose_name="Ссылка на страницу")
fec = models.CharField(max_length=30, blank=True, null=True, verbose_name="Коэффициент коррекции ошибок")
url = models.URLField(max_length = 200, blank=True, null=True, verbose_name="Ссылка на страницу")
def __str__(self):
return f"Ист {self.frequency}, {self.polarization}"

View File

@@ -4,8 +4,32 @@ from datetime import datetime
import re
import time
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 LyngSatParser:
"""Парсер данных для LyngSat(Для работы нужен flaresolver)"""
def __init__(
self,
flaresolver_url: str = "http://localhost:8191/v1",
@@ -14,35 +38,37 @@ class LyngSatParser:
):
self.flaresolver_url = flaresolver_url
self.regions = regions
self.target_sats = list(map(lambda sat: sat.strip().lower(), target_sats)) if regions else None
self.target_sats = (
list(map(lambda sat: sat.strip().lower(), target_sats)) if regions else None
)
self.regions = regions if regions else ["europe", "asia", "america", "atlantic"]
self.BASE_URL = "https://www.lyngsat.com"
def parse_metadata(self, metadata: str) -> dict:
if not metadata or not metadata.strip():
return {
'standard': None,
'modulation': None,
'symbol_rate': None,
'fec': None
"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)
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()]
core = normalized[: fec_match.start()]
else:
core = normalized
std_match = re.match(r'(DVB-S2?|ABS-S|DVB-T2?|ATSC|ISDB)', core)
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
rest = core[len(standard) :] if standard else core
modulation = None
mod_match = re.match(r'(8PSK|QPSK|16APSK|32APSK|64QAM|256QAM|BPSK)', rest)
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):]
rest = rest[len(modulation) :]
symbol_rate = None
sr_match = re.search(r'(\d+)$', rest)
sr_match = re.search(r"(\d+)$", rest)
if sr_match:
try:
symbol_rate = int(sr_match.group(1))
@@ -50,42 +76,40 @@ class LyngSatParser:
pass
return {
'standard': standard,
'modulation': modulation,
'symbol_rate': symbol_rate,
'fec': fec
"standard": standard,
"modulation": modulation,
"symbol_rate": symbol_rate,
"fec": fec,
}
def extract_date(self, s: str) -> datetime | None:
s = s.strip()
match = re.search(r'(\d{6})$', s)
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()
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': 'Левая'
"V": "Вертикальная",
"H": "Горизонтальная",
"R": "Правая",
"L": "Левая",
}
return polarization_map.get(polarization.upper(), polarization)
def get_region_pages(self) -> list[str]:
def get_region_pages(self, regions: list[str] | None = None) -> list[str]:
html_regions = []
for region in self.regions:
if regions is None:
regions = self.regions
for region in regions:
url = f"{self.BASE_URL}/{region}.html"
payload = {
"cmd": "request.get",
"url": url,
"maxTimeout": 60000
}
payload = {"cmd": "request.get", "url": url, "maxTimeout": 60000}
response = requests.post(self.flaresolver_url, json=payload)
if response.status_code != 200:
continue
@@ -94,66 +118,108 @@ class LyngSatParser:
print(f"Обработал страницу по {region}")
return html_regions
def get_satellites_data(self) -> dict[dict]:
sat_data = {}
for region_page in self.get_region_pages():
def get_satellite_urls(self, html_regions: list[str]):
sat_names = []
sat_urls = []
for region_page in html_regions:
soup = BeautifulSoup(region_page, "html.parser")
col_table = soup.find_all("div", class_="desktab")[0]
tables = col_table.find_next_sibling('table').find_all('table')
tables = col_table.find_next_sibling("table").find_all("table")
trs = []
for table in tables:
trs.extend(table.find_all('tr'))
trs.extend(table.find_all("tr"))
for tr in trs:
sat_name = tr.find('span').text
sat_name = tr.find("span").text
if self.target_sats is not None:
if sat_name.strip().lower() not in self.target_sats:
continue
try:
sat_url = tr.find_all('a')[2]['href']
sat_url = tr.find_all("a")[2]["href"]
except IndexError:
sat_url = tr.find_all('a')[0]['href']
sat_url = tr.find_all("a")[0]["href"]
sat_names.append(sat_name)
sat_urls.append(sat_url)
return sat_names, sat_urls
update_date = tr.find_all('td')[-1].text
sat_response = requests.post(self.flaresolver_url, json={
def get_satellites_data(self) -> dict[dict]:
sat_data = {}
for region_page in self.get_region_pages(self.regions):
soup = BeautifulSoup(region_page, "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
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 = tr.find_all("td")[-1].text
sat_response = requests.post(
self.flaresolver_url,
json={
"cmd": "request.get",
"url": f"{self.BASE_URL}/{sat_url}",
"maxTimeout": 60000
})
html_content = sat_response.json().get("solution", {}).get("response", "")
"maxTimeout": 60000,
},
)
html_content = (
sat_response.json().get("solution", {}).get("response", "")
)
sat_page_data = self.get_satellite_content(html_content)
sat_data[sat_name] = {
"url": f"{self.BASE_URL}/{sat_url}",
"update_date": datetime.strptime(update_date, "%y%m%d").date(),
"sources": sat_page_data
"sources": sat_page_data,
}
return sat_data
def get_satellite_content(self, html_content: str) -> dict:
sat_soup = BeautifulSoup(html_content, "html.parser")
big_table = sat_soup.find('table', class_='bigtable')
all_tables = big_table.find_all("div", class_="desktab")[:-1]
def get_satellite_content(self, html_content: str) -> list[dict]:
data = []
sat_soup = BeautifulSoup(html_content, "html.parser")
try:
big_table = sat_soup.find("table", class_="bigtable")
all_tables = big_table.find_all("div", class_="desktab")[:-1]
for table in all_tables:
trs = table.find_next_sibling('table').find_all('tr')
trs = table.find_next_sibling("table").find_all("tr")
for idx, tr in enumerate(trs):
tds = tr.find_all('td')
tds = tr.find_all("td")
if len(tds) < 9 or idx < 2:
continue
freq, polarization = tds[0].find('b').text.strip().split('\xa0')
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({
data.append(
{
"freq": freq,
"pol": polarization,
"metadata": meta,
"provider_name": provider_name,
"last_update": last_update
})
return data
"last_update": last_update,
}
)
except Exception as e:
print(e)
return data if data else data[{}]
class KingOfSatParser:
@@ -166,17 +232,19 @@ class KingOfSatParser:
self.base_url = base_url
self.max_satellites = max_satellites
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
})
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
}
)
def convert_polarization(self, polarization):
"""Преобразовать код поляризации в понятное название на русском"""
polarization_map = {
'V': 'Вертикальная',
'H': 'Горизонтальная',
'R': 'Правая',
'L': 'Левая'
"V": "Вертикальная",
"H": "Горизонтальная",
"R": "Правая",
"L": "Левая",
}
return polarization_map.get(polarization.upper(), polarization)
@@ -192,23 +260,23 @@ class KingOfSatParser:
def parse_satellite_table(self, html_content):
"""Распарсить таблицу со спутниками"""
soup = BeautifulSoup(html_content, 'html.parser')
soup = BeautifulSoup(html_content, "html.parser")
satellites = []
table = soup.find('table')
table = soup.find("table")
if not table:
print("Таблица не найдена")
return satellites
rows = table.find_all('tr')[1:]
rows = table.find_all("tr")[1:]
for row in rows:
cols = row.find_all('td')
cols = row.find_all("td")
if len(cols) < 13:
continue
try:
position_cell = cols[0].text.strip()
position_match = re.search(r'([\d\.]+)°([EW])', position_cell)
position_match = re.search(r"([\d\.]+)°([EW])", position_cell)
if position_match:
position_value = position_match.group(1)
position_direction = position_match.group(2)
@@ -220,7 +288,7 @@ class KingOfSatParser:
satellite_cell = cols[1]
satellite_name = satellite_cell.get_text(strip=True)
# Удаляем возможные лишние символы или пробелы
satellite_name = re.sub(r'\s+', ' ', satellite_name).strip()
satellite_name = re.sub(r"\s+", " ", satellite_name).strip()
# NORAD (3-я колонка)
norad = cols[2].text.strip()
@@ -229,20 +297,22 @@ class KingOfSatParser:
ini_link = None
ini_cell = cols[3]
ini_img = ini_cell.find('img', src=lambda x: x and 'disquette.gif' in x)
ini_img = ini_cell.find("img", src=lambda x: x and "disquette.gif" in x)
if ini_img and position:
ini_link = f"https://ru.kingofsat.net/dl.php?pos={position}&fkhz=0"
update_date = cols[12].text.strip() if len(cols) > 12 else None
if satellite_name and ini_link and position:
satellites.append({
'position': position,
'name': satellite_name,
'norad': norad,
'ini_url': ini_link,
'update_date': update_date
})
satellites.append(
{
"position": position,
"name": satellite_name,
"norad": norad,
"ini_url": ini_link,
"update_date": update_date,
}
)
except Exception as e:
print(f"Ошибка при обработке строки таблицы: {e}")
@@ -252,11 +322,7 @@ class KingOfSatParser:
def parse_ini_file(self, ini_content):
"""Распарсить содержимое .ini файла"""
data = {
'metadata': {},
'sattype': {},
'dvb': {}
}
data = {"metadata": {}, "sattype": {}, "dvb": {}}
# # Извлекаем метаданные из комментариев
# metadata_match = re.search(r'\[ downloaded from www\.kingofsat\.net \(c\) (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \]', ini_content)
@@ -264,35 +330,35 @@ class KingOfSatParser:
# data['metadata']['downloaded'] = metadata_match.group(1)
# Парсим секцию [SATTYPE]
sattype_match = re.search(r'\[SATTYPE\](.*?)\n\[', ini_content, re.DOTALL)
sattype_match = re.search(r"\[SATTYPE\](.*?)\n\[", ini_content, re.DOTALL)
if sattype_match:
sattype_content = sattype_match.group(1).strip()
for line in sattype_content.split('\n'):
for line in sattype_content.split("\n"):
line = line.strip()
if '=' in line:
key, value = line.split('=', 1)
data['sattype'][key.strip()] = value.strip()
if "=" in line:
key, value = line.split("=", 1)
data["sattype"][key.strip()] = value.strip()
# Парсим секцию [DVB]
dvb_match = re.search(r'\[DVB\](.*?)(?:\n\[|$)', ini_content, re.DOTALL)
dvb_match = re.search(r"\[DVB\](.*?)(?:\n\[|$)", ini_content, re.DOTALL)
if dvb_match:
dvb_content = dvb_match.group(1).strip()
for line in dvb_content.split('\n'):
for line in dvb_content.split("\n"):
line = line.strip()
if '=' in line:
key, value = line.split('=', 1)
params = [p.strip() for p in value.split(',')]
polarization = params[1] if len(params) > 1 else ''
if "=" in line:
key, value = line.split("=", 1)
params = [p.strip() for p in value.split(",")]
polarization = params[1] if len(params) > 1 else ""
if polarization:
polarization = self.convert_polarization(polarization)
data['dvb'][key.strip()] = {
'frequency': params[0] if len(params) > 0 else '',
'polarization': polarization,
'symbol_rate': params[2] if len(params) > 2 else '',
'fec': params[3] if len(params) > 3 else '',
'standard': params[4] if len(params) > 4 else '',
'modulation': params[5] if len(params) > 5 else ''
data["dvb"][key.strip()] = {
"frequency": params[0] if len(params) > 0 else "",
"polarization": polarization,
"symbol_rate": params[2] if len(params) > 2 else "",
"fec": params[3] if len(params) > 3 else "",
"standard": params[4] if len(params) > 4 else "",
"modulation": params[5] if len(params) > 5 else "",
}
return data
@@ -309,14 +375,14 @@ class KingOfSatParser:
def get_all_satellites_data(self):
"""Получить данные всех спутников с учетом ограничения max_satellites"""
html_content = self.fetch_page(self.base_url + '/satellites')
html_content = self.fetch_page(self.base_url + "/satellites")
if not html_content:
return []
satellites = self.parse_satellite_table(html_content)
if self.max_satellites > 0 and len(satellites) > self.max_satellites:
satellites = satellites[:self.max_satellites]
satellites = satellites[: self.max_satellites]
results = []
processed_count = 0
@@ -324,7 +390,7 @@ class KingOfSatParser:
for satellite in satellites:
print(f"Обработка спутника: {satellite['name']} ({satellite['position']})")
ini_content = self.download_ini_file(satellite['ini_url'])
ini_content = self.download_ini_file(satellite["ini_url"])
if not ini_content:
print(f"Не удалось скачать .ini файл для {satellite['name']}")
continue
@@ -332,12 +398,12 @@ class KingOfSatParser:
parsed_ini = self.parse_ini_file(ini_content)
result = {
'satellite_name': satellite['name'],
'position': satellite['position'],
'norad': satellite['norad'],
'update_date': satellite['update_date'],
'ini_url': satellite['ini_url'],
'ini_data': parsed_ini
"satellite_name": satellite["name"],
"position": satellite["position"],
"norad": satellite["norad"],
"update_date": satellite["update_date"],
"ini_url": satellite["ini_url"],
"ini_data": parsed_ini,
}
results.append(result)
@@ -357,15 +423,15 @@ class KingOfSatParser:
for data in satellites_data:
key = f"{data['position']}_{data['satellite_name'].replace(' ', '_').replace('/', '_')}"
satellite_dict[key] = {
'name': data['satellite_name'],
'position': data['position'],
'norad': data['norad'],
'update_date': data['update_date'],
'ini_url': data['ini_url'],
'transponders_count': len(data['ini_data']['dvb']),
'transponders': data['ini_data']['dvb'],
'sattype_info': data['ini_data']['sattype'],
'metadata': data['ini_data']['metadata']
"name": data["satellite_name"],
"position": data["position"],
"norad": data["norad"],
"update_date": data["update_date"],
"ini_url": data["ini_url"],
"transponders_count": len(data["ini_data"]["dvb"]),
"transponders": data["ini_data"]["dvb"],
"sattype_info": data["ini_data"]["sattype"],
"metadata": data["ini_data"]["metadata"],
}
return satellite_dict

201
dbapp/lyngsatapp/tasks.py Normal file
View File

@@ -0,0 +1,201 @@
"""
Celery tasks for Lyngsat data processing.
"""
import logging
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, force_refresh=False, use_cache=True):
"""
Асинхронная задача для заполнения данных 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: Список названий спутников для обработки
regions: Список регионов для парсинга (по умолчанию все)
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 'все'}")
# Обновляем статус задачи
self.update_state(
state='PROGRESS',
meta={
'current': 0,
'total': len(target_sats),
'status': 'Инициализация...'
}
)
try:
# Вызываем старую функцию заполнения данных
stats = fill_lyngsat_data(
target_sats=target_sats,
regions=regions,
task_id=task_id,
update_progress=lambda current, total, status: self.update_state(
state='PROGRESS',
meta={
'current': current,
'total': total,
'status': status
}
)
)
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': 'Ошибка при обработке',
'exc_type': type(e).__name__,
'exc_message': str(e)
}
)
# Возвращаем словарь с ошибкой вместо 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'),
]

180
dbapp/lyngsatapp/utils.py Normal file
View File

@@ -0,0 +1,180 @@
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__)
def fill_lyngsat_data(
target_sats: list[str],
regions: list[str] = None,
task_id: str = None,
update_progress=None
):
"""
Заполняет данные Lyngsat для указанных спутников и регионов.
Args:
target_sats: Список названий спутников для обработки
regions: Список регионов для парсинга (по умолчанию все)
task_id: ID задачи Celery для логирования
update_progress: Функция для обновления прогресса (current, total, status)
Returns:
dict: Статистика обработки с ключами:
- total_satellites: общее количество спутников
- total_sources: общее количество источников
- created: количество созданных записей
- updated: количество обновленных записей
- errors: список ошибок
"""
log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat]"
stats = {
'total_satellites': 0,
'total_sources': 0,
'created': 0,
'updated': 0,
'errors': []
}
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)}")
if update_progress:
update_progress(0, len(target_sats), "Инициализация парсера...")
try:
parser = LyngSatParser(
flaresolver_url=FLARESOLVERR_URL,
target_sats=target_sats,
regions=regions
)
logger.info(f"{log_prefix} Получение данных со спутников...")
if update_progress:
update_progress(0, len(target_sats), "Получение данных со спутников...")
lyngsat_data = parser.get_satellites_data()
stats['total_satellites'] = len(lyngsat_data)
logger.info(f"{log_prefix} Получено данных по {stats['total_satellites']} спутникам")
for idx, (sat_name, data) in enumerate(lyngsat_data.items(), 1):
logger.info(f"{log_prefix} Обработка спутника {idx}/{stats['total_satellites']}: {sat_name}")
if update_progress:
update_progress(idx, stats['total_satellites'], f"Обработка {sat_name}...")
url = data['url']
sources = data['sources']
stats['total_sources'] += len(sources)
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(
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}' не найден в базе данных"
logger.warning(f"{log_prefix} {error_msg}")
stats['errors'].append(error_msg)
continue
except Satellite.MultipleObjectsReturned:
error_msg = f"Найдено несколько спутников с именем '{sat_name}'"
logger.warning(f"{log_prefix} {error_msg}")
stats['errors'].append(error_msg)
continue
for source_idx, source in enumerate(sources, 1):
try:
# Парсим частоту
try:
freq = float(source['freq'])
except (ValueError, TypeError):
freq = -1.0
error_msg = f"Некорректная частота для {sat_name}: {source.get('freq')}"
logger.debug(f"{log_prefix} {error_msg}")
stats['errors'].append(error_msg)
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": url
}
)
if created:
stats['created'] += 1
logger.debug(f"{log_prefix} Создана запись для {sat_name} {freq} МГц")
else:
stats['updated'] += 1
logger.debug(f"{log_prefix} Обновлена запись для {sat_name} {freq} МГц")
# Логируем прогресс каждые 10 источников
if source_idx % 10 == 0:
logger.info(f"{log_prefix} Обработано {source_idx}/{len(sources)} источников для {sat_name}")
except Exception as e:
error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}"
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
stats['errors'].append(error_msg)
continue
logger.info(f"{log_prefix} Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}")
except Exception as e:
error_msg = f"Критическая ошибка: {str(e)}"
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
stats['errors'].append(error_msg)
logger.info(f"{log_prefix} Обработка завершена. Итого: создано {stats['created']}, обновлено {stats['updated']}, ошибок {len(stats['errors'])}")
if update_progress:
update_progress(stats['total_satellites'], stats['total_satellites'], "Завершено")
return stats
def link_lyngsat_to_sources():
pass

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

@@ -0,0 +1,24 @@
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!'))

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

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

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

65
dbapp/mainapp/tasks.py Normal file
View File

@@ -0,0 +1,65 @@
"""
Simple test tasks for Celery functionality.
"""
import time
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task(name='mainapp.test_celery_connection')
def test_celery_connection(message="Hello from Celery!"):
"""
A simple test task to verify Celery is working.
Args:
message (str): Message to return
Returns:
str: Confirmation message with task completion time
"""
logger.info(f"Test task started with message: {message}")
time.sleep(2) # Simulate some work
result = f"Task completed! Received message: {message}"
logger.info(f"Test task completed: {result}")
return result
@shared_task(name='mainapp.add_numbers')
def add_numbers(x, y):
"""
A simple addition task to test Celery functionality.
Args:
x (int): First number
y (int): Second number
Returns:
int: Sum of x and y
"""
logger.info(f"Adding {x} + {y}")
result = x + y
logger.info(f"Addition completed: {x} + {y} = {result}")
return result
@shared_task(name='mainapp.long_running_task')
def long_running_task(duration=10):
"""
A task that runs for a specified duration to test long-running tasks.
Args:
duration (int): Duration in seconds
Returns:
str: Completion message
"""
logger.info(f"Starting long running task for {duration} seconds")
for i in range(duration):
time.sleep(1)
logger.info(f"Long task progress: {i+1}/{duration}")
result = f"Long running task completed after {duration} seconds"
logger.info(result)
return result

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">
@@ -124,23 +102,23 @@
</div>
</div>
<!-- Map Views Card -->
<!-- Lyngsat Data Fill 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-secondary 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-map text-secondary" viewBox="0 0 16 16">
<path d="M15.817.113A.5.5 0 0 1 16 .5v14a.5.5 0 0 1-.402.49l-5 1a.502.502 0 0 1-.196 0L5.5 15.01l-4.902.98A.5.5 0 0 1 0 15.5v-14a.5.5 0 0 1 .402-.49l5-1a.5.5 0 0 1 .196 0L10.5.99l4.902-.98a.5.5 0 0 1 .415.103M10 1.91l-4-.8v12.98l4 .8zM1.61 2.22l4.39.88v10.88l-4.39-.88zm9.18 10.88 4-.8V2.34l-4 .8z"/>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-cloud-download text-secondary" viewBox="0 0 16 16">
<path d="M4.406 1.342A5.53 5.53 0 0 1 8 0c2.69 0 4.923 2 5.166 4.579C14.758 4.804 16 6.137 16 7.773 16 9.569 14.502 11 12.687 11H10a.5.5 0 0 1 0-1h2.688C13.979 10 15 8.988 15 7.773c0-1.216-1.02-2.228-2.313-2.228h-.5v-.5C12.188 2.825 10.328 1 8 1a4.53 4.53 0 0 0-2.941 1.1c-.757.652-1.153 1.438-1.153 2.055v.448l-.445.049C2.064 4.805 1 5.952 1 7.318 1 8.785 2.23 10 3.781 10H6a.5.5 0 0 1 0 1H3.781C1.708 11 0 9.366 0 7.318c0-1.763 1.266-3.223 2.942-3.593.143-.863.698-1.723 1.464-2.383"/>
<path d="M7.646 15.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 14.293V5.5a.5.5 0 0 0-1 0v8.793l-2.146-2.147a.5.5 0 0 0-.708.708z"/>
</svg>
</div>
<h3 class="card-title mb-0">Карты</h3>
</div>
<p class="card-text">Просматривайте данные на 2D и 3D картах для визуализации геолокации спутников.</p>
<div class="mt-2">
<a href="{% url 'mapsapp:2dmap' %}" class="btn btn-secondary me-2">2D Карта</a>
<a href="{% url 'mapsapp:3dmap' %}" class="btn btn-outline-secondary">3D Карта</a>
<h3 class="card-title mb-0">Заполнение данных Lyngsat</h3>
</div>
<p class="card-text">Загрузите данные о транспондерах спутников с сайта Lyngsat. Выберите спутники и регионы для парсинга данных.</p>
<a href="{% url 'mainapp:fill_lyngsat_data' %}" class="btn btn-secondary">
Заполнить данные Lyngsat
</a>
</div>
</div>
</div>
@@ -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>

View File

@@ -5,17 +5,18 @@
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %}
{% endcomment %}
{% if page_obj.has_other_pages %}
{% if page_obj %}
<nav aria-label="Page navigation" class="d-flex align-items-center">
{% if page_obj.has_other_pages %}
<ul class="pagination 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" title="Первая">
<a class="page-link" onclick="updatePage(1)" href="javascript:void(0)" title="Первая">
<i class="bi bi-chevron-bar-left"></i>
</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 }}" title="Предыдущая">
<a class="page-link" onclick="updatePage({{ page_obj.previous_page_number }})" href="javascript:void(0)" title="Предыдущая">
<i class="bi bi-chevron-left"></i>
</a>
</li>
@@ -28,29 +29,74 @@
</li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}">{{ num }}</a>
<a class="page-link" onclick="updatePage({{ num }})" href="javascript:void(0)">{{ num }}</a>
</li>
{% endif %}
{% endfor %}
{% 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 }}" title="Следующая">
<a class="page-link" onclick="updatePage({{ page_obj.next_page_number }})" href="javascript:void(0)" title="Следующая">
<i class="bi bi-chevron-right"></i>
</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 }}" title="Последняя">
<a class="page-link" onclick="updatePage({{ page_obj.paginator.num_pages }})" href="javascript:void(0)" title="Последняя">
<i class="bi bi-chevron-bar-right"></i>
</a>
</li>
{% endif %}
</ul>
{% endif %}
{% if show_info %}
<div class="ms-3 text-muted small">
{% if page_obj.has_other_pages %}
{{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }}
{% else %}
{% if page_obj.paginator.count > 0 %}
{{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }}
{% else %}
0 из 0
{% endif %}
{% endif %}
</div>
{% endif %}
</nav>
{% endif %}
<script>
function getAllFilterParams() {
const filterForm = document.getElementById('filter-form');
if (filterForm) {
const formData = new FormData(filterForm);
const params = new URLSearchParams();
for (let [key, value] of formData.entries()) {
if (key !== 'page') {
params.append(key, value);
}
}
const toolbarSearch = document.getElementById('toolbar-search');
if (toolbarSearch && toolbarSearch.value.trim() !== '') {
params.set('search', toolbarSearch.value);
} else {
params.delete('search');
}
return params.toString();
} else {
const currentParams = new URLSearchParams(window.location.search);
currentParams.delete('page');
return currentParams.toString();
}
}
function updatePage(pageNumber) {
const filterParams = getAllFilterParams();
const urlParams = new URLSearchParams(filterParams);
urlParams.set('page', pageNumber);
window.location.search = urlParams.toString();
}
</script>

View File

@@ -0,0 +1,99 @@
<!-- Satellite Data Modal -->
<div class="modal fade" id="satelliteModal" tabindex="-1" aria-labelledby="satelliteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="satelliteModalLabel">
<i class="bi bi-satellite"></i> Информация о спутнике
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body" id="satelliteModalBody">
<div class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
<script>
// Function to show satellite modal
function showSatelliteModal(satelliteId) {
const modal = new bootstrap.Modal(document.getElementById('satelliteModal'));
modal.show();
const modalBody = document.getElementById('satelliteModalBody');
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-warning" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
fetch('/api/satellite/' + satelliteId + '/')
.then(response => {
if (!response.ok) {
throw new Error('Ошибка загрузки данных спутника');
}
return response.json();
})
.then(data => {
let html = '<div class="container-fluid"><div class="row g-3">' +
'<div class="col-md-6"><div class="card h-100">' +
'<div class="card-header bg-light"><strong><i class="bi bi-info-circle"></i> Основная информация</strong></div>' +
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
'<tr><td class="text-muted" style="width: 40%;">Название:</td><td><strong>' + data.name + '</strong></td></tr>';
if (data.alternative_name && data.alternative_name !== '-') {
html += '<tr><td class="text-muted">Альтернативное название:</td><td><strong>' + data.alternative_name + '</strong></td></tr>';
}
html += '<tr><td class="text-muted">NORAD ID:</td><td>' + (data.norad || '-') + '</td></tr>';
if (data.international_code && data.international_code !== '-') {
html += '<tr><td class="text-muted">Международный код:</td><td>' + data.international_code + '</td></tr>';
}
html += '<tr><td class="text-muted">Подспутниковая точка:</td><td><strong>' + (data.undersat_point !== null ? data.undersat_point + '°' : '-') + '</strong></td></tr>' +
'<tr><td class="text-muted">Диапазоны:</td><td>' + data.bands + '</td></tr>';
if (data.location_place && data.location_place !== '-') {
html += '<tr><td class="text-muted">Комплекс:</td><td><span class="badge bg-secondary">' + data.location_place + '</span></td></tr>';
}
html += '</tbody></table></div></div></div>' +
'<div class="col-md-6"><div class="card h-100">' +
'<div class="card-header bg-light"><strong><i class="bi bi-calendar"></i> Дополнительная информация</strong></div>' +
'<div class="card-body"><table class="table table-sm table-borderless mb-0"><tbody>' +
'<tr><td class="text-muted" style="width: 40%;">Дата запуска:</td><td><strong>' + data.launch_date + '</strong></td></tr>' +
'<tr><td class="text-muted">Создан:</td><td>' + data.created_at + '</td></tr>' +
'<tr><td class="text-muted">Кем создан:</td><td>' + data.created_by + '</td></tr>' +
'<tr><td class="text-muted">Обновлён:</td><td>' + data.updated_at + '</td></tr>' +
'<tr><td class="text-muted">Кем обновлён:</td><td>' + data.updated_by + '</td></tr>' +
'</tbody></table></div></div></div>';
if (data.comment && data.comment !== '-') {
html += '<div class="col-12"><div class="card">' +
'<div class="card-header bg-light"><strong><i class="bi bi-chat-left-text"></i> Комментарий</strong></div>' +
'<div class="card-body"><p class="mb-0">' + data.comment + '</p></div></div></div>';
}
if (data.url) {
html += '<div class="col-12"><div class="card">' +
'<div class="card-header bg-light"><strong><i class="bi bi-link-45deg"></i> Ссылка</strong></div>' +
'<div class="card-body">' +
'<a href="' + data.url + '" target="_blank" class="btn btn-sm btn-outline-primary">' +
'<i class="bi bi-box-arrow-up-right"></i> Открыть ссылку</a>' +
'</div></div></div>';
}
html += '</div></div>';
modalBody.innerHTML = html;
})
.catch(error => {
modalBody.innerHTML = '<div class="alert alert-danger" role="alert">' +
'<i class="bi bi-exclamation-triangle"></i> ' + error.message + '</div>';
});
}
</script>

View File

@@ -0,0 +1,60 @@
<!-- Selected Items Offcanvas Component -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="selectedItemsOffcanvas" aria-labelledby="selectedItemsOffcanvasLabel" style="width: 66vw;">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="selectedItemsOffcanvasLabel">Выбранные элементы</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body p-0">
<div class="d-flex flex-column h-100">
<!-- Action buttons -->
<div class="p-3 border-bottom">
<div class="d-flex gap-2">
<button type="button" class="btn btn-danger btn-sm" onclick="removeSelectedItems()">
<i class="bi bi-trash"></i> Убрать из списка
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="showSelectedItemsOnMap()">
<i class="bi bi-map"></i> Карта
</button>
<button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="offcanvas">
Закрыть
</button>
</div>
</div>
<!-- Table container -->
<div class="flex-grow-1 overflow-auto">
<div class="table-responsive" style="height: 100%;">
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="width: 3%;">
<input type="checkbox" id="select-all-selected" class="form-check-input" onclick="toggleAllSelected(this)">
</th>
<th scope="col">Имя</th>
<th scope="col">Спутник</th>
<th scope="col">Транспондер</th>
<th scope="col">Част, МГц</th>
<th scope="col">Полоса, МГц</th>
<th scope="col">Поляризация</th>
<th scope="col">Сим. V</th>
<th scope="col">Модул</th>
<th scope="col">ОСШ</th>
<th scope="col">Время ГЛ</th>
<th scope="col">Местоположение</th>
<th scope="col">Геолокация</th>
<th scope="col">Обновлено</th>
<th scope="col">Кем(обн)</th>
<th scope="col">Создано</th>
<th scope="col">Кем(созд)</th>
<th scope="col">Зеркала</th>
</tr>
</thead>
<tbody id="selected-items-table-body">
<!-- Selected items will be populated here via JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,236 @@
<!-- SigmaParameter Data Modal -->
<div class="modal fade" id="sigmaParameterModal" tabindex="-1" aria-labelledby="sigmaParameterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header bg-info text-white">
<h5 class="modal-title" id="sigmaParameterModalLabel">
<i class="bi bi-graph-up"></i> Данные SigmaParameter
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body" id="sigmaParameterModalBody">
<div class="text-center py-4">
<div class="spinner-border text-info" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
<script>
function showSigmaParameterModal(parameterId) {
// Показываем модальное окно
const modal = new bootstrap.Modal(document.getElementById('sigmaParameterModal'));
modal.show();
// Показываем индикатор загрузки
const modalBody = document.getElementById('sigmaParameterModalBody');
modalBody.innerHTML = `
<div class="text-center py-4">
<div class="spinner-border text-info" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
`;
// Загружаем данные
fetch(`/api/sigma-parameter/${parameterId}/`)
.then(response => {
if (!response.ok) {
throw new Error('Ошибка загрузки данных');
}
return response.json();
})
.then(data => {
if (data.sigma_parameters.length === 0) {
modalBody.innerHTML = `
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i> Нет связанных SigmaParameter
</div>
`;
return;
}
// Формируем HTML с данными
let html = '<div class="container-fluid">';
data.sigma_parameters.forEach((sigma, index) => {
html += `
<div class="card mb-3">
<div class="card-header bg-light">
<strong><i class="bi bi-broadcast"></i> SigmaParameter #${index + 1}</strong>
</div>
<div class="card-body">
<div class="row g-3">
<!-- Основная информация -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-light">
<strong><i class="bi bi-info-circle"></i> Основная информация</strong>
</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;">Спутник:</td>
<td><strong>${sigma.satellite}</strong></td>
</tr>
<tr>
<td class="text-muted">Частота:</td>
<td><strong>${sigma.frequency} МГц</strong></td>
</tr>
<tr>
<td class="text-muted">Частота в Ku:</td>
<td><strong>${sigma.transfer_frequency} МГц</strong></td>
</tr>
<tr>
<td class="text-muted">Полоса частот:</td>
<td><strong>${sigma.freq_range} МГц</strong></td>
</tr>
<tr>
<td class="text-muted">Поляризация:</td>
<td><span class="badge bg-info">${sigma.polarization}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Технические параметры -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header bg-light">
<strong><i class="bi bi-gear"></i> Технические параметры</strong>
</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;">Модуляция:</td>
<td><span class="badge bg-secondary">${sigma.modulation}</span></td>
</tr>
<tr>
<td class="text-muted">Стандарт:</td>
<td><span class="badge bg-secondary">${sigma.standard}</span></td>
</tr>
<tr>
<td class="text-muted">Сим. скорость:</td>
<td><strong>${sigma.bod_velocity} БОД</strong></td>
</tr>
<tr>
<td class="text-muted">ОСШ:</td>
<td><strong>${sigma.snr} дБ</strong></td>
</tr>
<tr>
<td class="text-muted">Мощность:</td>
<td><strong>${sigma.power} дБм</strong></td>
</tr>
<tr>
<td class="text-muted">Статус:</td>
<td>${sigma.status}</td>
</tr>
<tr>
<td class="text-muted">Пакетность:</td>
<td>${sigma.packets}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Временные параметры -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-light">
<strong><i class="bi bi-clock-history"></i> Временные параметры</strong>
</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr>
<td class="text-muted" style="width: 40%;">Начало измерения:</td>
<td><strong>${sigma.datetime_begin}</strong></td>
</tr>
<tr>
<td class="text-muted">Окончание измерения:</td>
<td><strong>${sigma.datetime_end}</strong></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Отметки -->
<div class="col-md-6">
<div class="card">
<div class="card-header bg-light">
<strong><i class="bi bi-check2-square"></i> Отметки (${sigma.marks.length})</strong>
</div>
<div class="card-body">
`;
if (sigma.marks.length > 0) {
html += `
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">
<table class="table table-sm table-striped table-bordered mb-0">
<thead class="table-light sticky-top">
<tr>
<th style="width: 20%;">Наличие сигнала</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
`;
sigma.marks.forEach(mark => {
const markClass = mark.mark === '+' ? 'text-success' : 'text-danger';
html += `
<tr>
<td class="${markClass}"><strong>${mark.mark}</strong></td>
<td>${mark.date}</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
`;
} else {
html += `
<p class="text-muted mb-0">Нет отметок</p>
`;
}
html += `
</div>
</div>
</div>
</div>
</div>
</div>
`;
});
html += '</div>';
modalBody.innerHTML = html;
})
.catch(error => {
modalBody.innerHTML = `
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle"></i> ${error.message}
</div>
`;
});
}
</script>

Some files were not shown because too many files have changed in this diff Show More