Compare commits
77 Commits
50498166e5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ca7709ebff | |||
| 9bf701f05a | |||
| f5875e5b87 | |||
| f79efd88e5 | |||
| cf3c7ee01a | |||
| 41e8dc30fd | |||
| 4949a03e68 | |||
| d889dc7b2a | |||
| 8393734dc3 | |||
| 25fe93231f | |||
| 8fb8b08c93 | |||
| 2b856ff6dc | |||
| cff2c73b6a | |||
| 9c095a7229 | |||
| 09bbedda18 | |||
| 727c24fb1f | |||
| 00b85b5bf2 | |||
| f954f77a6d | |||
| 027f971f5a | |||
| 30b56de709 | |||
| 24314b84ac | |||
| 4164ea2109 | |||
| 51eb5f3732 | |||
| d7d85ac834 | |||
| 118c86a73c | |||
| 3388f787c7 | |||
| 889899080a | |||
| a18071b7ec | |||
| b9e17df32c | |||
| 96f961b0f8 | |||
| ad479a2069 | |||
| 300927c7ea | |||
| 8d75e47abc | |||
| c72bf12d41 | |||
| 01871c3e13 | |||
| d521b6baad | |||
| 908e11879d | |||
| eba19126ef | |||
| 0be829b97b | |||
| 810d3a8f7f | |||
| efb99ea8d5 | |||
| bd39717e86 | |||
| d832171325 | |||
| cfaaae9360 | |||
| 27694a3a7d | |||
| 609fd5a1da | |||
| 388753ba31 | |||
| 68486d2283 | |||
| e24cf8a105 | |||
| 7879c3d9b5 | |||
| 1c18ae96f7 | |||
| a591b79656 | |||
| ed9a79f94a | |||
| 9a9900cfa6 | |||
| 0d239ef1de | |||
| 58838614a5 | |||
| c2c8c8799f | |||
| 1d1c42a8e7 | |||
| 66e1929978 | |||
| 4d7cc9f667 | |||
| c8bcd1adf0 | |||
| 55759ec705 | |||
| 06a39278d2 | |||
| c0f2f16303 | |||
| b889fb29a3 | |||
| f438e74946 | |||
| c55a41f5fe | |||
| 8994a0e500 | |||
| d9cb243388 | |||
| 9a816e62c2 | |||
| bc226bfc1a | |||
| d61236dee2 | |||
| 6a26991dc0 | |||
| 5ab6770809 | |||
| 8e0d32c307 | |||
| 122fe74e14 | |||
| d0a53e251e |
3
.env.dev
3
.env.dev
@@ -2,7 +2,8 @@
|
||||
|
||||
# Django Settings
|
||||
DEBUG=True
|
||||
ENVIRONMENT=development
|
||||
# ENVIRONMENT=development
|
||||
DJANGO_ENVIRONMENT=development
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.development
|
||||
SECRET_KEY=django-insecure-dev-key-only-for-development
|
||||
|
||||
|
||||
23
.env.prod
23
.env.prod
@@ -1,25 +1,28 @@
|
||||
# Django Settings
|
||||
DEBUG=False
|
||||
ENVIRONMENT=production
|
||||
# ENVIRONMENT=production
|
||||
DJANGO_ENVIRONMENT=production
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.production
|
||||
SECRET_KEY=change-this-to-a-very-long-random-secret-key-in-production
|
||||
SECRET_KEY=django-insecure-dev-key-only-for-production
|
||||
|
||||
# Database Configuration
|
||||
DB_ENGINE=django.contrib.gis.db.backends.postgis
|
||||
DB_NAME=geodb
|
||||
DB_USER=geralt
|
||||
DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
||||
DB_PASSWORD=123456
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Allowed Hosts (comma-separated)
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
|
||||
|
||||
# CSRF Trusted Origins (include port if using non-standard port)
|
||||
CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080
|
||||
|
||||
# PostgreSQL Configuration
|
||||
POSTGRES_DB=geodb
|
||||
POSTGRES_USER=geralt
|
||||
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD
|
||||
POSTGRES_PASSWORD=123456
|
||||
|
||||
# Gunicorn Configuration
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_TIMEOUT=120
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://redis:6379/1
|
||||
CELERY_BROKER_URL=redis://redis:6379/0
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Ensure shell scripts always use LF line endings
|
||||
*.sh text eol=lf
|
||||
entrypoint.sh text eol=lf
|
||||
|
||||
# Python files
|
||||
*.py text eol=lf
|
||||
|
||||
# Docker files
|
||||
Dockerfile text eol=lf
|
||||
docker-compose*.yaml text eol=lf
|
||||
.dockerignore text eol=lf
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -33,3 +33,5 @@ tiles
|
||||
# Docker
|
||||
# docker-*
|
||||
maplibre-gl-js-5.10.0.zip
|
||||
cert.pem
|
||||
templ.json
|
||||
@@ -1,249 +0,0 @@
|
||||
# Чеклист для деплоя в Production
|
||||
|
||||
## Перед деплоем
|
||||
|
||||
### 1. Безопасность
|
||||
|
||||
- [ ] Сгенерирован новый `SECRET_KEY`
|
||||
```bash
|
||||
python generate_secret_key.py
|
||||
```
|
||||
|
||||
- [ ] Изменены все пароли в `.env`:
|
||||
- [ ] `DB_PASSWORD` - сильный пароль для PostgreSQL
|
||||
- [ ] `POSTGRES_PASSWORD` - должен совпадать с `DB_PASSWORD`
|
||||
|
||||
- [ ] Настроен `ALLOWED_HOSTS` в `.env`:
|
||||
```
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
```
|
||||
|
||||
- [ ] `DEBUG=False` в `.env`
|
||||
|
||||
### 2. База данных
|
||||
|
||||
- [ ] Проверены все миграции:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py showmigrations
|
||||
```
|
||||
|
||||
- [ ] Настроен backup БД (cron job):
|
||||
```bash
|
||||
0 2 * * * cd /path/to/project && make backup
|
||||
```
|
||||
|
||||
### 3. Статические файлы
|
||||
|
||||
- [ ] Проверена директория для статики:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput
|
||||
```
|
||||
|
||||
### 4. SSL/HTTPS (опционально, но рекомендуется)
|
||||
|
||||
- [ ] Получены SSL сертификаты (Let's Encrypt, Certbot)
|
||||
- [ ] Сертификаты размещены в `nginx/ssl/`
|
||||
- [ ] Переименован `nginx/conf.d/ssl.conf.example` в `ssl.conf`
|
||||
- [ ] Обновлен `server_name` в `ssl.conf`
|
||||
|
||||
### 5. Nginx
|
||||
|
||||
- [ ] Проверена конфигурация Nginx:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec nginx nginx -t
|
||||
```
|
||||
|
||||
- [ ] Настроены правильные домены в `nginx/conf.d/default.conf`
|
||||
|
||||
### 6. Docker
|
||||
|
||||
- [ ] Проверен `.dockerignore` - исключены ненужные файлы
|
||||
- [ ] Проверен `.gitignore` - не коммитятся секреты
|
||||
|
||||
### 7. Переменные окружения
|
||||
|
||||
Проверьте `.env` файл:
|
||||
|
||||
```bash
|
||||
# Django
|
||||
DEBUG=False
|
||||
ENVIRONMENT=production
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.production
|
||||
SECRET_KEY=<ваш-длинный-секретный-ключ>
|
||||
|
||||
# Database
|
||||
DB_NAME=geodb
|
||||
DB_USER=geralt
|
||||
DB_PASSWORD=<сильный-пароль>
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
|
||||
# PostgreSQL
|
||||
POSTGRES_DB=geodb
|
||||
POSTGRES_USER=geralt
|
||||
POSTGRES_PASSWORD=<тот-же-сильный-пароль>
|
||||
|
||||
# Gunicorn
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_TIMEOUT=120
|
||||
```
|
||||
|
||||
## Деплой
|
||||
|
||||
### 1. Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd <project-directory>
|
||||
```
|
||||
|
||||
### 2. Настройка окружения
|
||||
|
||||
```bash
|
||||
cp .env.prod .env
|
||||
nano .env # Отредактируйте все необходимые переменные
|
||||
```
|
||||
|
||||
### 3. Запуск контейнеров
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
```
|
||||
|
||||
### 4. Проверка статуса
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml ps
|
||||
docker-compose -f docker-compose.prod.yaml logs -f
|
||||
```
|
||||
|
||||
### 5. Создание суперпользователя
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### 6. Проверка работоспособности
|
||||
|
||||
- [ ] Открыть http://yourdomain.com
|
||||
- [ ] Открыть http://yourdomain.com/admin
|
||||
- [ ] Проверить статические файлы
|
||||
- [ ] Проверить медиа файлы
|
||||
- [ ] Проверить TileServer GL: http://yourdomain.com:8080
|
||||
|
||||
## После деплоя
|
||||
|
||||
### 1. Мониторинг
|
||||
|
||||
- [ ] Настроить мониторинг логов:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml logs -f web
|
||||
```
|
||||
|
||||
- [ ] Проверить использование ресурсов:
|
||||
```bash
|
||||
docker stats
|
||||
```
|
||||
|
||||
### 2. Backup
|
||||
|
||||
- [ ] Настроить автоматический backup БД
|
||||
- [ ] Проверить восстановление из backup
|
||||
- [ ] Настроить backup медиа файлов
|
||||
|
||||
### 3. Обновления
|
||||
|
||||
- [ ] Документировать процесс обновления
|
||||
- [ ] Тестировать обновления на dev окружении
|
||||
|
||||
### 4. Безопасность
|
||||
|
||||
- [ ] Настроить firewall (UFW, iptables)
|
||||
- [ ] Ограничить доступ к портам:
|
||||
- Открыть: 80, 443
|
||||
- Закрыть: 5432, 8000 (доступ только внутри Docker сети)
|
||||
|
||||
- [ ] Настроить fail2ban (опционально)
|
||||
|
||||
### 5. Производительность
|
||||
|
||||
- [ ] Настроить кэширование (Redis, Memcached)
|
||||
- [ ] Оптимизировать количество Gunicorn workers
|
||||
- [ ] Настроить CDN для статики (опционально)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Проблема: Контейнеры не запускаются
|
||||
|
||||
```bash
|
||||
# Проверить логи
|
||||
docker-compose -f docker-compose.prod.yaml logs
|
||||
|
||||
# Проверить конфигурацию
|
||||
docker-compose -f docker-compose.prod.yaml config
|
||||
```
|
||||
|
||||
### Проблема: База данных недоступна
|
||||
|
||||
```bash
|
||||
# Проверить статус БД
|
||||
docker-compose -f docker-compose.prod.yaml exec db pg_isready -U geralt
|
||||
|
||||
# Проверить логи БД
|
||||
docker-compose -f docker-compose.prod.yaml logs db
|
||||
```
|
||||
|
||||
### Проблема: Статические файлы не загружаются
|
||||
|
||||
```bash
|
||||
# Пересобрать статику
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput
|
||||
|
||||
# Проверить права доступа
|
||||
docker-compose -f docker-compose.prod.yaml exec web ls -la /app/staticfiles
|
||||
```
|
||||
|
||||
### Проблема: 502 Bad Gateway
|
||||
|
||||
```bash
|
||||
# Проверить, что Django запущен
|
||||
docker-compose -f docker-compose.prod.yaml ps web
|
||||
|
||||
# Проверить логи Gunicorn
|
||||
docker-compose -f docker-compose.prod.yaml logs web
|
||||
|
||||
# Проверить конфигурацию Nginx
|
||||
docker-compose -f docker-compose.prod.yaml exec nginx nginx -t
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Перезапуск сервисов
|
||||
docker-compose -f docker-compose.prod.yaml restart web
|
||||
docker-compose -f docker-compose.prod.yaml restart nginx
|
||||
|
||||
# Обновление кода
|
||||
git pull
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
|
||||
# Backup БД
|
||||
docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql
|
||||
|
||||
# Восстановление БД
|
||||
docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql
|
||||
|
||||
# Просмотр логов
|
||||
docker-compose -f docker-compose.prod.yaml logs -f --tail=100 web
|
||||
|
||||
# Очистка старых образов
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
## Контакты для поддержки
|
||||
|
||||
- Документация: [DOCKER_README.md](DOCKER_README.md)
|
||||
- Быстрый старт: [QUICKSTART.md](QUICKSTART.md)
|
||||
@@ -1,102 +0,0 @@
|
||||
# Инструкция по развертыванию изменений
|
||||
|
||||
## Шаг 1: Применение миграций
|
||||
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
Это создаст таблицу `lyngsatapp_lyngsat` в базе данных.
|
||||
|
||||
## Шаг 2: Запуск FlareSolver (если еще не запущен)
|
||||
|
||||
FlareSolver необходим для обхода защиты Cloudflare на сайте Lyngsat.
|
||||
|
||||
### Вариант 1: Docker
|
||||
```bash
|
||||
docker run -d -p 8191:8191 --name flaresolverr ghcr.io/flaresolverr/flaresolverr:latest
|
||||
```
|
||||
|
||||
### Вариант 2: Docker Compose
|
||||
Добавьте в `docker-compose.yaml`:
|
||||
```yaml
|
||||
services:
|
||||
flaresolverr:
|
||||
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||
container_name: flaresolverr
|
||||
ports:
|
||||
- "8191:8191"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Затем запустите:
|
||||
```bash
|
||||
docker-compose up -d flaresolverr
|
||||
```
|
||||
|
||||
## Шаг 3: Проверка работоспособности
|
||||
|
||||
1. Запустите сервер разработки:
|
||||
```bash
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
2. Откройте браузер и перейдите на:
|
||||
```
|
||||
http://localhost:8000/actions/
|
||||
```
|
||||
|
||||
3. Найдите карточку "Заполнение данных Lyngsat" и нажмите на кнопку
|
||||
|
||||
4. Выберите один-два спутника для тестирования
|
||||
|
||||
5. Выберите регионы (например, только Europe)
|
||||
|
||||
6. Нажмите "Заполнить данные" и дождитесь завершения
|
||||
|
||||
## Шаг 4: Проверка результатов
|
||||
|
||||
1. Перейдите в админ-панель Django:
|
||||
```
|
||||
http://localhost:8000/admin/
|
||||
```
|
||||
|
||||
2. Откройте раздел "Lyngsatapp" → "Источники LyngSat"
|
||||
|
||||
3. Проверьте, что данные загружены корректно
|
||||
|
||||
## Возможные проблемы и решения
|
||||
|
||||
### Проблема: FlareSolver не отвечает
|
||||
**Решение**: Проверьте, что FlareSolver запущен:
|
||||
```bash
|
||||
curl http://localhost:8191/v1
|
||||
```
|
||||
|
||||
### Проблема: Спутники не найдены в базе
|
||||
**Решение**: Убедитесь, что спутники добавлены в базу данных. Используйте функцию "Добавление списка спутников" на странице действий.
|
||||
|
||||
### Проблема: Долгое выполнение
|
||||
**Решение**: Это нормально. Процесс может занять несколько минут на спутник. Начните с 1-2 спутников для тестирования.
|
||||
|
||||
### Проблема: Ошибки при парсинге
|
||||
**Решение**: Проверьте логи. Некоторые ошибки (например, некорректные частоты) не критичны и не прерывают процесс.
|
||||
|
||||
## Откат изменений (если необходимо)
|
||||
|
||||
Если нужно откатить изменения:
|
||||
|
||||
```bash
|
||||
# Откатить миграцию
|
||||
python manage.py migrate lyngsatapp zero
|
||||
|
||||
# Откатить изменения в коде
|
||||
git checkout HEAD -- dbapp/
|
||||
```
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
- Подробное руководство пользователя: `LYNGSAT_FILL_GUIDE.md`
|
||||
- Сводка изменений: `CHANGES_SUMMARY.md`
|
||||
- Документация по проекту: `README.md`
|
||||
262
DOCKER_README.md
262
DOCKER_README.md
@@ -1,262 +0,0 @@
|
||||
# Docker Setup для Django + PostGIS + TileServer GL
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── dbapp/ # Django приложение
|
||||
│ ├── Dockerfile # Универсальный Dockerfile
|
||||
│ ├── entrypoint.sh # Скрипт запуска
|
||||
│ └── ...
|
||||
├── nginx/ # Конфигурация Nginx (только для prod)
|
||||
│ └── conf.d/
|
||||
│ └── default.conf
|
||||
├── tiles/ # Тайлы для TileServer GL
|
||||
├── docker-compose.yaml # Development окружение
|
||||
├── docker-compose.prod.yaml # Production окружение
|
||||
├── .env.dev # Переменные для development
|
||||
└── .env.prod # Переменные для production
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Development
|
||||
|
||||
1. Скопируйте файл окружения:
|
||||
```bash
|
||||
cp .env.dev .env
|
||||
```
|
||||
|
||||
2. Запустите контейнеры:
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
3. Создайте суперпользователя:
|
||||
```bash
|
||||
docker-compose exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
4. Приложение доступно:
|
||||
- Django: http://localhost:8000
|
||||
- TileServer GL: http://localhost:8080
|
||||
- PostgreSQL: localhost:5432
|
||||
|
||||
### Production
|
||||
|
||||
1. Скопируйте и настройте файл окружения:
|
||||
```bash
|
||||
cp .env.prod .env
|
||||
# Отредактируйте .env и измените SECRET_KEY, пароли и ALLOWED_HOSTS
|
||||
```
|
||||
|
||||
2. Запустите контейнеры:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
```
|
||||
|
||||
3. Создайте суперпользователя:
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
4. Приложение доступно:
|
||||
- Nginx: http://localhost (порт 80)
|
||||
- Django (напрямую): http://localhost:8000
|
||||
- TileServer GL: http://localhost:8080
|
||||
- PostgreSQL: localhost:5432
|
||||
|
||||
## Основные команды
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Запуск
|
||||
docker-compose up -d
|
||||
|
||||
# Остановка
|
||||
docker-compose down
|
||||
|
||||
# Просмотр логов
|
||||
docker-compose logs -f web
|
||||
|
||||
# Выполнение команд Django
|
||||
docker-compose exec web python manage.py migrate
|
||||
docker-compose exec web python manage.py createsuperuser
|
||||
docker-compose exec web python manage.py shell
|
||||
|
||||
# Пересборка после изменений в Dockerfile
|
||||
docker-compose up -d --build
|
||||
|
||||
# Полная очистка (включая volumes)
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
```bash
|
||||
# Запуск
|
||||
docker-compose -f docker-compose.prod.yaml up -d
|
||||
|
||||
# Остановка
|
||||
docker-compose -f docker-compose.prod.yaml down
|
||||
|
||||
# Просмотр логов
|
||||
docker-compose -f docker-compose.prod.yaml logs -f web
|
||||
|
||||
# Выполнение команд Django
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
||||
|
||||
# Пересборка
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
```
|
||||
|
||||
## Различия между Dev и Prod
|
||||
|
||||
### Development
|
||||
- Django development server (runserver)
|
||||
- DEBUG=True
|
||||
- Код монтируется как volume (изменения применяются сразу)
|
||||
- Без Nginx
|
||||
- Простые пароли (для локальной разработки)
|
||||
|
||||
### Production
|
||||
- Gunicorn WSGI server
|
||||
- DEBUG=False
|
||||
- Код копируется в образ (не монтируется)
|
||||
- Nginx как reverse proxy
|
||||
- Сильные пароли и SECRET_KEY
|
||||
- Сбор статики (collectstatic)
|
||||
- Оптимизированные настройки безопасности
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
### Основные переменные (.env)
|
||||
|
||||
```bash
|
||||
# Django
|
||||
DEBUG=True/False
|
||||
ENVIRONMENT=development/production
|
||||
DJANGO_SETTINGS_MODULE=dbapp.settings.development/production
|
||||
SECRET_KEY=your-secret-key
|
||||
|
||||
# Database
|
||||
DB_NAME=geodb
|
||||
DB_USER=geralt
|
||||
DB_PASSWORD=your-password
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Allowed Hosts
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
|
||||
|
||||
# Gunicorn (только для production)
|
||||
GUNICORN_WORKERS=3
|
||||
GUNICORN_TIMEOUT=120
|
||||
```
|
||||
|
||||
## Volumes
|
||||
|
||||
### Development
|
||||
- `postgres_data_dev` - данные PostgreSQL
|
||||
- `static_volume_dev` - статические файлы
|
||||
- `media_volume_dev` - медиа файлы
|
||||
- `logs_volume_dev` - логи
|
||||
- `./dbapp:/app` - код приложения (live reload)
|
||||
|
||||
### Production
|
||||
- `postgres_data_prod` - данные PostgreSQL
|
||||
- `static_volume_prod` - статические файлы
|
||||
- `media_volume_prod` - медиа файлы
|
||||
- `logs_volume_prod` - логи
|
||||
|
||||
## TileServer GL
|
||||
|
||||
Для работы TileServer GL поместите ваши тайлы в директорию `./tiles/`.
|
||||
|
||||
Пример структуры:
|
||||
```
|
||||
tiles/
|
||||
├── config.json
|
||||
└── your-tiles.mbtiles
|
||||
```
|
||||
|
||||
## Backup и восстановление БД
|
||||
|
||||
### Backup
|
||||
```bash
|
||||
# Development
|
||||
docker-compose exec db pg_dump -U geralt geodb > backup.sql
|
||||
|
||||
# Production
|
||||
docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql
|
||||
```
|
||||
|
||||
### Восстановление
|
||||
```bash
|
||||
# Development
|
||||
docker-compose exec -T db psql -U geralt geodb < backup.sql
|
||||
|
||||
# Production
|
||||
docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Проблемы с миграциями
|
||||
```bash
|
||||
docker-compose exec web python manage.py migrate --fake-initial
|
||||
```
|
||||
|
||||
### Проблемы с правами доступа
|
||||
```bash
|
||||
docker-compose exec -u root web chown -R app:app /app
|
||||
```
|
||||
|
||||
### Очистка всех данных
|
||||
```bash
|
||||
docker-compose down -v
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
### Проверка логов
|
||||
```bash
|
||||
# Все сервисы
|
||||
docker-compose logs -f
|
||||
|
||||
# Конкретный сервис
|
||||
docker-compose logs -f web
|
||||
docker-compose logs -f db
|
||||
```
|
||||
|
||||
## Безопасность для Production
|
||||
|
||||
1. **Измените SECRET_KEY** - используйте длинный случайный ключ
|
||||
2. **Измените пароли БД** - используйте сильные пароли
|
||||
3. **Настройте ALLOWED_HOSTS** - укажите ваш домен
|
||||
4. **Настройте SSL** - добавьте сертификаты в `nginx/ssl/`
|
||||
5. **Ограничьте доступ к портам** - не открывайте порты БД наружу
|
||||
|
||||
## Генерация SECRET_KEY
|
||||
|
||||
```python
|
||||
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||
```
|
||||
|
||||
## Мониторинг
|
||||
|
||||
### Проверка статуса контейнеров
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### Использование ресурсов
|
||||
```bash
|
||||
docker stats
|
||||
```
|
||||
|
||||
### Healthcheck
|
||||
```bash
|
||||
curl http://localhost:8000/admin/
|
||||
```
|
||||
307
DOCKER_SETUP.md
307
DOCKER_SETUP.md
@@ -1,307 +0,0 @@
|
||||
# Docker Setup - Полное руководство
|
||||
|
||||
## 📋 Обзор
|
||||
|
||||
Этот проект использует Docker для развертывания Django приложения с PostGIS и TileServer GL.
|
||||
|
||||
**Основные компоненты:**
|
||||
- Django 5.2 с PostGIS
|
||||
- PostgreSQL 17 с расширением PostGIS 3.4
|
||||
- TileServer GL для работы с картографическими тайлами
|
||||
- Nginx (только для production)
|
||||
- Gunicorn WSGI сервер (production)
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Development
|
||||
```bash
|
||||
cp .env.dev .env
|
||||
make dev-up
|
||||
make createsuperuser
|
||||
```
|
||||
Откройте http://localhost:8000
|
||||
|
||||
### Production
|
||||
```bash
|
||||
cp .env.prod .env
|
||||
# Отредактируйте .env (SECRET_KEY, пароли, домены)
|
||||
make prod-up
|
||||
make prod-createsuperuser
|
||||
```
|
||||
Откройте http://yourdomain.com
|
||||
|
||||
## 📁 Структура файлов
|
||||
|
||||
```
|
||||
.
|
||||
├── dbapp/ # Django приложение
|
||||
│ ├── Dockerfile # Универсальный Dockerfile
|
||||
│ ├── entrypoint.sh # Скрипт инициализации
|
||||
│ ├── .dockerignore # Исключения для Docker
|
||||
│ └── ...
|
||||
│
|
||||
├── nginx/ # Nginx конфигурация (prod)
|
||||
│ ├── conf.d/
|
||||
│ │ ├── default.conf # HTTP конфигурация
|
||||
│ │ └── ssl.conf.example # HTTPS конфигурация (пример)
|
||||
│ └── ssl/ # SSL сертификаты
|
||||
│
|
||||
├── tiles/ # Тайлы для TileServer GL
|
||||
│ ├── README.md # Инструкция по настройке
|
||||
│ ├── config.json.example # Пример конфигурации
|
||||
│ └── .gitignore
|
||||
│
|
||||
├── docker-compose.yaml # Development окружение
|
||||
├── docker-compose.prod.yaml # Production окружение
|
||||
│
|
||||
├── .env.dev # Переменные для dev
|
||||
├── .env.prod # Переменные для prod (шаблон)
|
||||
│
|
||||
├── Makefile # Удобные команды
|
||||
├── generate_secret_key.py # Генератор SECRET_KEY
|
||||
│
|
||||
└── Документация:
|
||||
├── QUICKSTART.md # Быстрый старт
|
||||
├── DOCKER_README.md # Подробная документация
|
||||
├── DEPLOYMENT_CHECKLIST.md # Чеклист для деплоя
|
||||
└── DOCKER_SETUP.md # Этот файл
|
||||
```
|
||||
|
||||
## 🔧 Конфигурация
|
||||
|
||||
### Dockerfile
|
||||
|
||||
**Один универсальный Dockerfile** для dev и prod:
|
||||
- Multi-stage build для оптимизации размера
|
||||
- Установка GDAL, PostGIS зависимостей
|
||||
- Использование uv для управления зависимостями
|
||||
- Non-root пользователь для безопасности
|
||||
- Healthcheck для мониторинга
|
||||
|
||||
### entrypoint.sh
|
||||
|
||||
Скрипт автоматически:
|
||||
- Ждет готовности PostgreSQL
|
||||
- Выполняет миграции
|
||||
- Собирает статику (только prod)
|
||||
- Запускает runserver (dev) или Gunicorn (prod)
|
||||
|
||||
Поведение определяется переменной `ENVIRONMENT`:
|
||||
- `development` → Django development server
|
||||
- `production` → Gunicorn WSGI server
|
||||
|
||||
### docker-compose.yaml (Development)
|
||||
|
||||
**Сервисы:**
|
||||
- `db` - PostgreSQL с PostGIS
|
||||
- `web` - Django приложение
|
||||
- `tileserver` - TileServer GL
|
||||
|
||||
**Особенности:**
|
||||
- Код монтируется как volume (live reload)
|
||||
- DEBUG=True
|
||||
- Django development server
|
||||
- Простые пароли для локальной разработки
|
||||
|
||||
### docker-compose.prod.yaml (Production)
|
||||
|
||||
**Сервисы:**
|
||||
- `db` - PostgreSQL с PostGIS
|
||||
- `web` - Django с Gunicorn
|
||||
- `tileserver` - TileServer GL
|
||||
- `nginx` - Reverse proxy
|
||||
|
||||
**Особенности:**
|
||||
- Код копируется в образ (не монтируется)
|
||||
- DEBUG=False
|
||||
- Gunicorn WSGI server
|
||||
- Nginx для статики и проксирования
|
||||
- Сильные пароли из .env
|
||||
- Сбор статики (collectstatic)
|
||||
|
||||
## 🔐 Безопасность
|
||||
|
||||
### Для Production обязательно:
|
||||
|
||||
1. **Сгенерируйте SECRET_KEY:**
|
||||
```bash
|
||||
python generate_secret_key.py
|
||||
```
|
||||
|
||||
2. **Измените пароли БД** в `.env`
|
||||
|
||||
3. **Настройте ALLOWED_HOSTS:**
|
||||
```
|
||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||
```
|
||||
|
||||
4. **Настройте SSL/HTTPS** (рекомендуется):
|
||||
- Получите сертификаты (Let's Encrypt)
|
||||
- Поместите в `nginx/ssl/`
|
||||
- Используйте `nginx/conf.d/ssl.conf.example`
|
||||
|
||||
5. **Ограничьте доступ к портам:**
|
||||
- Открыть: 80, 443
|
||||
- Закрыть: 5432, 8000
|
||||
|
||||
## 📊 Мониторинг
|
||||
|
||||
### Логи
|
||||
```bash
|
||||
# Development
|
||||
make dev-logs
|
||||
|
||||
# Production
|
||||
make prod-logs
|
||||
|
||||
# Конкретный сервис
|
||||
docker-compose logs -f web
|
||||
docker-compose logs -f db
|
||||
```
|
||||
|
||||
### Статус
|
||||
```bash
|
||||
make status # Development
|
||||
make prod-status # Production
|
||||
docker stats # Использование ресурсов
|
||||
```
|
||||
|
||||
### Healthcheck
|
||||
```bash
|
||||
curl http://localhost:8000/admin/
|
||||
```
|
||||
|
||||
## 💾 Backup и восстановление
|
||||
|
||||
### Backup
|
||||
```bash
|
||||
make backup
|
||||
# или
|
||||
docker-compose exec db pg_dump -U geralt geodb > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Восстановление
|
||||
```bash
|
||||
docker-compose exec -T db psql -U geralt geodb < backup.sql
|
||||
```
|
||||
|
||||
### Автоматический backup (cron)
|
||||
```bash
|
||||
# Добавьте в crontab
|
||||
0 2 * * * cd /path/to/project && make backup
|
||||
```
|
||||
|
||||
## 🔄 Обновление
|
||||
|
||||
### Development
|
||||
```bash
|
||||
git pull
|
||||
make dev-build
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
git pull
|
||||
make prod-build
|
||||
make prod-migrate
|
||||
```
|
||||
|
||||
## 🗺️ TileServer GL
|
||||
|
||||
Поместите `.mbtiles` файлы в директорию `tiles/`:
|
||||
|
||||
```bash
|
||||
tiles/
|
||||
├── world.mbtiles
|
||||
└── satellite.mbtiles
|
||||
```
|
||||
|
||||
Доступ: http://localhost:8080
|
||||
|
||||
Подробнее: [tiles/README.md](tiles/README.md)
|
||||
|
||||
## 🛠️ Makefile команды
|
||||
|
||||
### Development
|
||||
```bash
|
||||
make dev-up # Запустить
|
||||
make dev-down # Остановить
|
||||
make dev-build # Пересобрать
|
||||
make dev-logs # Логи
|
||||
make dev-restart # Перезапустить web
|
||||
```
|
||||
|
||||
### Production
|
||||
```bash
|
||||
make prod-up # Запустить
|
||||
make prod-down # Остановить
|
||||
make prod-build # Пересобрать
|
||||
make prod-logs # Логи
|
||||
make prod-restart # Перезапустить web
|
||||
```
|
||||
|
||||
### Django
|
||||
```bash
|
||||
make shell # Django shell
|
||||
make migrate # Миграции
|
||||
make makemigrations # Создать миграции
|
||||
make createsuperuser # Создать суперпользователя
|
||||
make collectstatic # Собрать статику
|
||||
```
|
||||
|
||||
### Утилиты
|
||||
```bash
|
||||
make backup # Backup БД
|
||||
make status # Статус контейнеров
|
||||
make clean # Очистка (с volumes)
|
||||
make clean-all # Полная очистка
|
||||
```
|
||||
|
||||
## 📚 Дополнительная документация
|
||||
|
||||
- **[QUICKSTART.md](QUICKSTART.md)** - Быстрый старт для нетерпеливых
|
||||
- **[DOCKER_README.md](DOCKER_README.md)** - Подробная документация по Docker
|
||||
- **[DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md)** - Чеклист для деплоя
|
||||
- **[tiles/README.md](tiles/README.md)** - Настройка TileServer GL
|
||||
|
||||
## ❓ Troubleshooting
|
||||
|
||||
### Контейнеры не запускаются
|
||||
```bash
|
||||
docker-compose logs
|
||||
docker-compose config
|
||||
```
|
||||
|
||||
### База данных недоступна
|
||||
```bash
|
||||
docker-compose exec db pg_isready -U geralt
|
||||
docker-compose logs db
|
||||
```
|
||||
|
||||
### Статические файлы не загружаются
|
||||
```bash
|
||||
docker-compose exec web python manage.py collectstatic --noinput
|
||||
docker-compose exec web ls -la /app/staticfiles
|
||||
```
|
||||
|
||||
### 502 Bad Gateway
|
||||
```bash
|
||||
docker-compose ps web
|
||||
docker-compose logs web
|
||||
docker-compose exec nginx nginx -t
|
||||
```
|
||||
|
||||
## 🎯 Следующие шаги
|
||||
|
||||
1. ✅ Прочитайте [QUICKSTART.md](QUICKSTART.md)
|
||||
2. ✅ Запустите development окружение
|
||||
3. ✅ Изучите [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) перед деплоем
|
||||
4. ✅ Настройте TileServer GL ([tiles/README.md](tiles/README.md))
|
||||
5. ✅ Настройте SSL для production
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
При возникновении проблем:
|
||||
1. Проверьте логи: `make dev-logs` или `make prod-logs`
|
||||
2. Изучите документацию в этой директории
|
||||
3. Проверьте [DOCKER_README.md](DOCKER_README.md) для подробностей
|
||||
@@ -1,240 +0,0 @@
|
||||
# Обзор созданных файлов Docker Setup
|
||||
|
||||
## 🐳 Docker файлы
|
||||
|
||||
### `dbapp/Dockerfile`
|
||||
**Универсальный Dockerfile** для dev и prod окружений.
|
||||
- Multi-stage build для оптимизации
|
||||
- Установка GDAL, PostGIS, PostgreSQL клиента
|
||||
- Использование uv для управления зависимостями
|
||||
- Non-root пользователь для безопасности
|
||||
- Healthcheck для мониторинга
|
||||
|
||||
### `dbapp/entrypoint.sh`
|
||||
**Скрипт инициализации контейнера.**
|
||||
- Ожидание готовности PostgreSQL
|
||||
- Автоматические миграции
|
||||
- Сбор статики (только prod)
|
||||
- Запуск runserver (dev) или Gunicorn (prod)
|
||||
|
||||
### `dbapp/.dockerignore`
|
||||
**Исключения для Docker build.**
|
||||
- Исключает ненужные файлы из образа
|
||||
- Уменьшает размер образа
|
||||
- Ускоряет сборку
|
||||
|
||||
## 🔧 Docker Compose файлы
|
||||
|
||||
### `docker-compose.yaml`
|
||||
**Development окружение.**
|
||||
- PostgreSQL с PostGIS
|
||||
- Django с development server
|
||||
- TileServer GL
|
||||
- Код монтируется как volume (live reload)
|
||||
- DEBUG=True
|
||||
|
||||
### `docker-compose.prod.yaml`
|
||||
**Production окружение.**
|
||||
- PostgreSQL с PostGIS
|
||||
- Django с Gunicorn
|
||||
- TileServer GL
|
||||
- Nginx reverse proxy
|
||||
- Код копируется в образ
|
||||
- DEBUG=False
|
||||
- Оптимизированные настройки
|
||||
|
||||
## 🌐 Nginx конфигурация
|
||||
|
||||
### `nginx/conf.d/default.conf`
|
||||
**HTTP конфигурация для production.**
|
||||
- Проксирование к Django
|
||||
- Раздача статики и медиа
|
||||
- Оптимизированные таймауты
|
||||
- Кэширование статики
|
||||
|
||||
### `nginx/conf.d/ssl.conf.example`
|
||||
**HTTPS конфигурация (пример).**
|
||||
- SSL/TLS настройки
|
||||
- Редирект с HTTP на HTTPS
|
||||
- Security headers
|
||||
- Оптимизированные SSL параметры
|
||||
|
||||
### `nginx/ssl/.gitkeep`
|
||||
**Директория для SSL сертификатов.**
|
||||
- Поместите сюда fullchain.pem и privkey.pem
|
||||
|
||||
## 🗺️ TileServer GL
|
||||
|
||||
### `tiles/README.md`
|
||||
**Инструкция по настройке TileServer GL.**
|
||||
- Как добавить тайлы
|
||||
- Примеры конфигурации
|
||||
- Использование в Django/Leaflet
|
||||
- Где взять тайлы
|
||||
|
||||
### `tiles/config.json.example`
|
||||
**Пример конфигурации TileServer GL.**
|
||||
- Настройки путей
|
||||
- Форматы и качество
|
||||
- Домены
|
||||
|
||||
### `tiles/.gitignore`
|
||||
**Исключения для git.**
|
||||
- Игнорирует большие .mbtiles файлы
|
||||
- Сохраняет примеры конфигурации
|
||||
|
||||
## 🔐 Переменные окружения
|
||||
|
||||
### `.env.dev`
|
||||
**Переменные для development.**
|
||||
- DEBUG=True
|
||||
- Простые пароли для локальной разработки
|
||||
- Настройки БД для dev
|
||||
|
||||
### `.env.prod`
|
||||
**Шаблон переменных для production.**
|
||||
- DEBUG=False
|
||||
- Требует изменения SECRET_KEY и паролей
|
||||
- Настройки для production
|
||||
|
||||
## 🛠️ Утилиты
|
||||
|
||||
### `Makefile`
|
||||
**Удобные команды для работы с Docker.**
|
||||
- `make dev-up` - запуск dev
|
||||
- `make prod-up` - запуск prod
|
||||
- `make migrate` - миграции
|
||||
- `make backup` - backup БД
|
||||
- И многое другое
|
||||
|
||||
### `generate_secret_key.py`
|
||||
**Генератор Django SECRET_KEY.**
|
||||
```bash
|
||||
python generate_secret_key.py
|
||||
```
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
### `QUICKSTART.md`
|
||||
**Быстрый старт.**
|
||||
- Минимальные команды для запуска
|
||||
- Development и Production
|
||||
- Основные команды
|
||||
|
||||
### `DOCKER_README.md`
|
||||
**Подробная документация.**
|
||||
- Полное описание структуры
|
||||
- Все команды с примерами
|
||||
- Troubleshooting
|
||||
- Backup и восстановление
|
||||
|
||||
### `DOCKER_SETUP.md`
|
||||
**Полное руководство.**
|
||||
- Обзор всей системы
|
||||
- Конфигурация
|
||||
- Безопасность
|
||||
- Мониторинг
|
||||
|
||||
### `DEPLOYMENT_CHECKLIST.md`
|
||||
**Чеклист для деплоя.**
|
||||
- Пошаговая инструкция
|
||||
- Проверка безопасности
|
||||
- Настройка production
|
||||
- Troubleshooting
|
||||
|
||||
### `FILES_OVERVIEW.md`
|
||||
**Этот файл.**
|
||||
- Описание всех созданных файлов
|
||||
- Назначение каждого файла
|
||||
|
||||
## 📝 Обновленные файлы
|
||||
|
||||
### `.gitignore`
|
||||
**Обновлен для Docker.**
|
||||
- Исключает .env файлы
|
||||
- Исключает логи и backup
|
||||
- Исключает временные файлы
|
||||
|
||||
## 🎯 Как использовать
|
||||
|
||||
### Для начала работы:
|
||||
1. Прочитайте **QUICKSTART.md**
|
||||
2. Выберите окружение (dev или prod)
|
||||
3. Скопируйте соответствующий .env файл
|
||||
4. Запустите с помощью Makefile
|
||||
|
||||
### Для деплоя:
|
||||
1. Прочитайте **DEPLOYMENT_CHECKLIST.md**
|
||||
2. Следуйте чеклисту пошагово
|
||||
3. Используйте **DOCKER_README.md** для справки
|
||||
|
||||
### Для настройки TileServer:
|
||||
1. Прочитайте **tiles/README.md**
|
||||
2. Добавьте .mbtiles файлы
|
||||
3. Настройте config.json (опционально)
|
||||
|
||||
## 📊 Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── Docker конфигурация
|
||||
│ ├── dbapp/Dockerfile
|
||||
│ ├── dbapp/entrypoint.sh
|
||||
│ ├── dbapp/.dockerignore
|
||||
│ ├── docker-compose.yaml
|
||||
│ └── docker-compose.prod.yaml
|
||||
│
|
||||
├── Nginx
|
||||
│ ├── nginx/conf.d/default.conf
|
||||
│ ├── nginx/conf.d/ssl.conf.example
|
||||
│ └── nginx/ssl/.gitkeep
|
||||
│
|
||||
├── TileServer GL
|
||||
│ ├── tiles/README.md
|
||||
│ ├── tiles/config.json.example
|
||||
│ └── tiles/.gitignore
|
||||
│
|
||||
├── Переменные окружения
|
||||
│ ├── .env.dev
|
||||
│ └── .env.prod
|
||||
│
|
||||
├── Утилиты
|
||||
│ ├── Makefile
|
||||
│ └── generate_secret_key.py
|
||||
│
|
||||
└── Документация
|
||||
├── QUICKSTART.md
|
||||
├── DOCKER_README.md
|
||||
├── DOCKER_SETUP.md
|
||||
├── DEPLOYMENT_CHECKLIST.md
|
||||
└── FILES_OVERVIEW.md
|
||||
```
|
||||
|
||||
## ✅ Что было сделано
|
||||
|
||||
1. ✅ Создан универсальный Dockerfile (один для dev и prod)
|
||||
2. ✅ Настроен entrypoint.sh с автоматической инициализацией
|
||||
3. ✅ Созданы docker-compose.yaml для dev и prod
|
||||
4. ✅ Настроен Nginx для production
|
||||
5. ✅ Добавлена поддержка TileServer GL
|
||||
6. ✅ Созданы .env файлы для разных окружений
|
||||
7. ✅ Добавлен Makefile с удобными командами
|
||||
8. ✅ Написана подробная документация
|
||||
9. ✅ Создан чеклист для деплоя
|
||||
10. ✅ Добавлены утилиты (генератор SECRET_KEY)
|
||||
|
||||
## 🚀 Следующие шаги
|
||||
|
||||
1. Запустите development окружение
|
||||
2. Протестируйте все функции
|
||||
3. Подготовьте production окружение
|
||||
4. Следуйте DEPLOYMENT_CHECKLIST.md
|
||||
5. Настройте мониторинг и backup
|
||||
|
||||
## 💡 Полезные ссылки
|
||||
|
||||
- Django Documentation: https://docs.djangoproject.com/
|
||||
- Docker Documentation: https://docs.docker.com/
|
||||
- PostGIS Documentation: https://postgis.net/documentation/
|
||||
- TileServer GL: https://github.com/maptiler/tileserver-gl
|
||||
- Nginx Documentation: https://nginx.org/en/docs/
|
||||
@@ -1,167 +0,0 @@
|
||||
### Шаг 2: Применение миграций
|
||||
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
Это создаст:
|
||||
- Таблицу `lyngsatapp_lyngsat` для данных Lyngsat
|
||||
- Таблицы `django_celery_results_*` для результатов Celery
|
||||
|
||||
### Шаг 3: Запуск сервисов
|
||||
|
||||
```bash
|
||||
# Запуск Redis и FlareSolver
|
||||
docker-compose up -d redis flaresolverr
|
||||
|
||||
# Проверка
|
||||
redis-cli ping # Должно вернуть PONG
|
||||
curl http://localhost:8191/v1 # Должно вернуть JSON
|
||||
```
|
||||
|
||||
### Шаг 4: Запуск приложения
|
||||
|
||||
**Терминал 1 - Django:**
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
**Терминал 2 - Celery Worker:**
|
||||
```bash
|
||||
cd dbapp
|
||||
celery -A dbapp worker --loglevel=info
|
||||
```
|
||||
|
||||
### Шаг 5: Тестирование
|
||||
|
||||
1. Откройте `http://localhost:8000/actions/`
|
||||
2. Нажмите "Заполнить данные Lyngsat"
|
||||
3. Выберите спутники и регионы
|
||||
4. Наблюдайте за прогрессом!
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Проверка Django
|
||||
```bash
|
||||
python dbapp/manage.py check
|
||||
# Должно вывести: System check identified no issues (0 silenced).
|
||||
```
|
||||
|
||||
### Проверка Celery (если установлен)
|
||||
```bash
|
||||
celery -A dbapp inspect ping
|
||||
# Должно вывести: pong
|
||||
```
|
||||
|
||||
### Проверка Redis (если установлен)
|
||||
```bash
|
||||
redis-cli ping
|
||||
# Должно вывести: PONG
|
||||
```
|
||||
|
||||
### Проверка FlareSolver
|
||||
```bash
|
||||
curl http://localhost:8191/v1
|
||||
# Должно вернуть JSON с информацией о сервисе
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Проблема: FlareSolver не отвечает
|
||||
|
||||
**Решение**: Запустите FlareSolver
|
||||
```bash
|
||||
docker-compose up -d flaresolverr
|
||||
# или
|
||||
docker run -d -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest
|
||||
```
|
||||
|
||||
|
||||
## Дополнительные инструменты
|
||||
|
||||
### Flower - мониторинг Celery
|
||||
|
||||
```bash
|
||||
pip install flower
|
||||
celery -A dbapp flower
|
||||
# Откройте http://localhost:5555
|
||||
```
|
||||
|
||||
### Redis Commander - GUI для Redis
|
||||
|
||||
```bash
|
||||
docker run -d -p 8081:8081 --name redis-commander \
|
||||
--env REDIS_HOSTS=local:localhost:6379 \
|
||||
rediscommander/redis-commander
|
||||
# Откройте http://localhost:8081
|
||||
```
|
||||
|
||||
### pgAdmin - GUI для PostgreSQL
|
||||
|
||||
```bash
|
||||
docker run -d -p 5050:80 --name pgadmin \
|
||||
-e PGADMIN_DEFAULT_EMAIL=admin@admin.com \
|
||||
-e PGADMIN_DEFAULT_PASSWORD=admin \
|
||||
dpage/pgadmin4
|
||||
# Откройте http://localhost:5050
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Остановка сервисов
|
||||
|
||||
```bash
|
||||
# Остановка Docker контейнеров
|
||||
docker-compose down
|
||||
|
||||
# Остановка Celery Worker
|
||||
pkill -f "celery worker"
|
||||
```
|
||||
|
||||
### Удаление данных
|
||||
|
||||
```bash
|
||||
# Удаление Docker volumes
|
||||
docker-compose down -v
|
||||
|
||||
# Удаление виртуального окружения
|
||||
rm -rf dbapp/.venv
|
||||
|
||||
# Удаление миграций (опционально)
|
||||
find dbapp -path "*/migrations/*.py" -not -name "__init__.py" -delete
|
||||
find dbapp -path "*/migrations/*.pyc" -delete
|
||||
```
|
||||
|
||||
# Systemd service для запуска с хоста
|
||||
|
||||
[Unit]
|
||||
Description=Django Application
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/path/to/your/app
|
||||
Environment=PATH=/path/to/venv/bin
|
||||
Environment=DATABASE_URL=postgresql://user:pass@localhost/geodb
|
||||
ExecStart=/path/to/venv/bin/python manage.py runserver 0.0.0.0:8000
|
||||
ExecReload=/bin/kill -s HUP $MAINPID
|
||||
TimeoutSec=300
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
## Поддержка
|
||||
|
||||
1. Проверьте логи:
|
||||
- Django: консоль где запущен runserver
|
||||
- Celery: `dbapp/logs/celery_worker.log`
|
||||
- Docker: `docker-compose logs`
|
||||
|
||||
38
Makefile
38
Makefile
@@ -2,14 +2,26 @@
|
||||
|
||||
help:
|
||||
@echo "Доступные команды:"
|
||||
@echo ""
|
||||
@echo "Development:"
|
||||
@echo " make dev-up - Запустить development окружение"
|
||||
@echo " make dev-down - Остановить development окружение"
|
||||
@echo " make dev-build - Пересобрать development контейнеры"
|
||||
@echo " make dev-logs - Показать логи development"
|
||||
@echo ""
|
||||
@echo "Production:"
|
||||
@echo " make prod-up - Запустить production окружение"
|
||||
@echo " make prod-down - Остановить production окружение"
|
||||
@echo " make prod-build - Пересобрать production контейнеры"
|
||||
@echo " make prod-logs - Показать логи production"
|
||||
@echo ""
|
||||
@echo "Celery (Production):"
|
||||
@echo " make prod-worker-logs - Логи Celery worker"
|
||||
@echo " make prod-beat-logs - Логи Celery beat"
|
||||
@echo " make prod-celery-status - Статус Celery"
|
||||
@echo " make prod-celery-test - Тест Celery подключения"
|
||||
@echo ""
|
||||
@echo "Django:"
|
||||
@echo " make shell - Открыть Django shell"
|
||||
@echo " make migrate - Выполнить миграции"
|
||||
@echo " make createsuperuser - Создать суперпользователя"
|
||||
@@ -97,3 +109,29 @@ status:
|
||||
|
||||
prod-status:
|
||||
docker-compose -f docker-compose.prod.yaml ps
|
||||
|
||||
# Celery команды для production
|
||||
prod-worker-logs:
|
||||
docker-compose -f docker-compose.prod.yaml logs -f worker
|
||||
|
||||
prod-beat-logs:
|
||||
docker-compose -f docker-compose.prod.yaml logs -f beat
|
||||
|
||||
prod-celery-status:
|
||||
docker-compose -f docker-compose.prod.yaml exec web uv run celery -A dbapp inspect active
|
||||
|
||||
prod-celery-test:
|
||||
docker-compose -f docker-compose.prod.yaml exec web uv run python test_celery.py
|
||||
|
||||
prod-redis-test:
|
||||
docker-compose -f docker-compose.prod.yaml exec web uv run python check_redis.py
|
||||
|
||||
# Celery команды для development
|
||||
celery-status:
|
||||
cd dbapp && uv run celery -A dbapp inspect active
|
||||
|
||||
celery-test:
|
||||
cd dbapp && uv run python test_celery.py
|
||||
|
||||
redis-test:
|
||||
cd dbapp && uv run python check_redis.py
|
||||
|
||||
106
QUICKSTART.md
106
QUICKSTART.md
@@ -1,106 +0,0 @@
|
||||
# Быстрый старт с Docker
|
||||
|
||||
## Development (разработка)
|
||||
|
||||
```bash
|
||||
# 1. Скопировать переменные окружения
|
||||
cp .env.dev .env
|
||||
|
||||
# 2. Запустить контейнеры
|
||||
make dev-up
|
||||
# или
|
||||
docker-compose up -d --build
|
||||
|
||||
# 3. Создать суперпользователя
|
||||
make createsuperuser
|
||||
# или
|
||||
docker-compose exec web python manage.py createsuperuser
|
||||
|
||||
# 4. Открыть в браузере
|
||||
# Django: http://localhost:8000
|
||||
# Admin: http://localhost:8000/admin
|
||||
# TileServer: http://localhost:8080
|
||||
```
|
||||
|
||||
## Production (продакшн)
|
||||
|
||||
```bash
|
||||
# 1. Скопировать и настроить переменные
|
||||
cp .env.prod .env
|
||||
nano .env # Измените SECRET_KEY, пароли, ALLOWED_HOSTS
|
||||
|
||||
# 2. Запустить контейнеры
|
||||
make prod-up
|
||||
# или
|
||||
docker-compose -f docker-compose.prod.yaml up -d --build
|
||||
|
||||
# 3. Создать суперпользователя
|
||||
make prod-createsuperuser
|
||||
# или
|
||||
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
|
||||
|
||||
# 4. Открыть в браузере
|
||||
# Nginx: http://localhost
|
||||
# Django: http://localhost:8000
|
||||
# TileServer: http://localhost:8080
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Просмотр логов
|
||||
make dev-logs # development
|
||||
make prod-logs # production
|
||||
|
||||
# Остановка
|
||||
make dev-down # development
|
||||
make prod-down # production
|
||||
|
||||
# Перезапуск после изменений
|
||||
make dev-build # development
|
||||
make prod-build # production
|
||||
|
||||
# Django shell
|
||||
make shell # development
|
||||
make prod-shell # production
|
||||
|
||||
# Миграции
|
||||
make migrate # development
|
||||
make prod-migrate # production
|
||||
|
||||
# Backup БД
|
||||
make backup
|
||||
|
||||
# Статус контейнеров
|
||||
make status # development
|
||||
make prod-status # production
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── dbapp/ # Django приложение
|
||||
│ ├── Dockerfile # Универсальный Dockerfile
|
||||
│ ├── entrypoint.sh # Скрипт запуска
|
||||
│ ├── manage.py
|
||||
│ └── ...
|
||||
├── nginx/ # Nginx (только prod)
|
||||
│ └── conf.d/
|
||||
│ └── default.conf
|
||||
├── tiles/ # Тайлы для TileServer GL
|
||||
│ ├── README.md
|
||||
│ └── config.json.example
|
||||
├── docker-compose.yaml # Development
|
||||
├── docker-compose.prod.yaml # Production
|
||||
├── .env.dev # Переменные dev
|
||||
├── .env.prod # Переменные prod
|
||||
├── Makefile # Команды для удобства
|
||||
└── DOCKER_README.md # Подробная документация
|
||||
```
|
||||
|
||||
## Что дальше?
|
||||
|
||||
1. Прочитайте [DOCKER_README.md](DOCKER_README.md) для подробной информации
|
||||
2. Настройте TileServer GL - см. [tiles/README.md](tiles/README.md)
|
||||
3. Для production настройте SSL сертификаты в `nginx/ssl/`
|
||||
@@ -1,117 +0,0 @@
|
||||
# Быстрый старт: Асинхронное заполнение данных Lyngsat
|
||||
|
||||
## Минимальная настройка (5 минут)
|
||||
|
||||
### 1. Установите зависимости
|
||||
```bash
|
||||
pip install -r dbapp/requirements.txt
|
||||
```
|
||||
|
||||
### 2. Примените миграции
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### 3. Запустите необходимые сервисы
|
||||
|
||||
**Терминал 1 - Redis и FlareSolver:**
|
||||
```bash
|
||||
docker-compose up -d redis flaresolverr
|
||||
```
|
||||
|
||||
**Терминал 2 - Django:**
|
||||
```bash
|
||||
cd dbapp
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
**Терминал 3 - Celery Worker:**
|
||||
```bash
|
||||
cd dbapp
|
||||
celery -A dbapp worker --loglevel=info
|
||||
```
|
||||
|
||||
### 4. Используйте систему
|
||||
|
||||
1. Откройте браузер: `http://localhost:8000/actions/`
|
||||
2. Нажмите "Заполнить данные Lyngsat"
|
||||
3. Выберите 1-2 спутника для теста
|
||||
4. Выберите регион (например, Europe)
|
||||
5. Нажмите "Заполнить данные"
|
||||
6. Наблюдайте за прогрессом в реальном времени!
|
||||
|
||||
## Проверка работоспособности
|
||||
|
||||
### Redis
|
||||
```bash
|
||||
redis-cli ping
|
||||
# Должно вернуть: PONG
|
||||
```
|
||||
|
||||
### FlareSolver
|
||||
```bash
|
||||
curl http://localhost:8191/v1
|
||||
# Должно вернуть JSON с информацией о сервисе
|
||||
```
|
||||
|
||||
### Celery Worker
|
||||
Проверьте вывод в терминале 3 - должны быть сообщения:
|
||||
```
|
||||
[2024-01-15 10:30:00,000: INFO/MainProcess] Connected to redis://localhost:6379/0
|
||||
[2024-01-15 10:30:00,000: INFO/MainProcess] celery@hostname ready.
|
||||
```
|
||||
|
||||
## Остановка сервисов
|
||||
|
||||
```bash
|
||||
# Остановить Docker контейнеры
|
||||
docker-compose down
|
||||
|
||||
# Остановить Django (Ctrl+C в терминале 2)
|
||||
|
||||
# Остановить Celery Worker (Ctrl+C в терминале 3)
|
||||
```
|
||||
|
||||
## Просмотр логов
|
||||
|
||||
```bash
|
||||
# Логи Celery Worker (если запущен с --logfile)
|
||||
tail -f dbapp/logs/celery_worker.log
|
||||
|
||||
# Логи Docker контейнеров
|
||||
docker-compose logs -f redis
|
||||
docker-compose logs -f flaresolverr
|
||||
```
|
||||
|
||||
## Что дальше?
|
||||
|
||||
- Прочитайте полную документацию: `ASYNC_LYNGSAT_GUIDE.md`
|
||||
- Настройте production окружение
|
||||
- Добавьте периодические задачи
|
||||
- Настройте email уведомления
|
||||
|
||||
## Решение проблем
|
||||
|
||||
**Worker не запускается:**
|
||||
```bash
|
||||
# Проверьте Redis
|
||||
redis-cli ping
|
||||
|
||||
# Проверьте переменные окружения
|
||||
echo $CELERY_BROKER_URL
|
||||
```
|
||||
|
||||
**Задача не выполняется:**
|
||||
```bash
|
||||
# Проверьте FlareSolver
|
||||
curl http://localhost:8191/v1
|
||||
|
||||
# Проверьте логи worker
|
||||
tail -f dbapp/logs/celery_worker.log
|
||||
```
|
||||
|
||||
**Прогресс не обновляется:**
|
||||
- Откройте консоль браузера (F12)
|
||||
- Проверьте Network tab на наличие ошибок
|
||||
- Обновите страницу
|
||||
@@ -1,57 +1,53 @@
|
||||
FROM python:3.13-slim
|
||||
FROM python:3.13.7-slim AS builder
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gdal-bin \
|
||||
libgdal-dev \
|
||||
proj-bin \
|
||||
proj-data \
|
||||
libproj-dev \
|
||||
libproj25 \
|
||||
libgeos-dev \
|
||||
libgeos-c1v5 \
|
||||
# Устанавливаем системные библиотеки для GIS, Postgres, сборки пакетов
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
postgresql-client \
|
||||
gdal-bin libgdal-dev \
|
||||
libproj-dev proj-bin \
|
||||
libpq-dev \
|
||||
libpq5 \
|
||||
netcat-openbsd \
|
||||
gcc \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade pip
|
||||
RUN pip install --upgrade pip
|
||||
# Устанавливаем uv пакетно-менеджер глобально
|
||||
RUN pip install --no-cache-dir uv
|
||||
|
||||
# Copy requirements file
|
||||
COPY requirements.txt ./
|
||||
# Копируем зависимости
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
# Синхронизируем зависимости (включая prod + dev), чтобы билдить
|
||||
RUN uv sync --locked
|
||||
|
||||
# Copy project files
|
||||
# Копируем весь код приложения
|
||||
COPY . .
|
||||
|
||||
# Create directories
|
||||
RUN mkdir -p /app/staticfiles /app/logs /app/media
|
||||
# --- рантайм-стадия — минимальный образ для продакшена ---
|
||||
FROM python:3.13.7-slim
|
||||
|
||||
# Set permissions for entrypoint
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd --create-home --shell /bin/bash app && \
|
||||
chown -R app:app /app
|
||||
# Устанавливаем только runtime-системные библиотеки
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gdal-bin \
|
||||
libproj-dev proj-bin \
|
||||
libpq5 \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER app
|
||||
# Копируем всё из билдера
|
||||
COPY --from=builder /usr/local/lib/python3.13 /usr/local/lib/python3.13
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
COPY --from=builder /app /app
|
||||
|
||||
# Загружаем переменные окружения из .env (см. docker-compose)
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PATH="/usr/local/bin:$PATH"
|
||||
|
||||
# Делаем entrypoint скрипты исполняемыми
|
||||
RUN chmod +x /app/entrypoint.sh /app/entrypoint-celery.sh
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run entrypoint script
|
||||
# Используем entrypoint для инициализации (миграции, статика)
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
96
dbapp/check_redis.py
Normal file
96
dbapp/check_redis.py
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Скрипт для проверки подключения к Redis.
|
||||
Запуск: python check_redis.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import redis
|
||||
except ImportError:
|
||||
print("❌ Redis библиотека не установлена")
|
||||
print("Установите: pip install redis")
|
||||
sys.exit(1)
|
||||
|
||||
def check_redis():
|
||||
"""Проверка подключения к Redis"""
|
||||
print("=" * 60)
|
||||
print("ПРОВЕРКА REDIS")
|
||||
print("=" * 60)
|
||||
|
||||
# Получаем URL из переменных окружения
|
||||
broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||
cache_url = os.getenv("REDIS_URL", "redis://localhost:6379/1")
|
||||
|
||||
print(f"\n1. Broker URL: {broker_url}")
|
||||
print(f"2. Cache URL: {cache_url}")
|
||||
|
||||
# Проверка broker (database 0)
|
||||
print("\n3. Проверка Celery Broker (db 0)...")
|
||||
try:
|
||||
r_broker = redis.from_url(broker_url)
|
||||
r_broker.ping()
|
||||
print(" ✓ Подключение успешно")
|
||||
|
||||
# Проверка ключей
|
||||
keys = r_broker.keys("*")
|
||||
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||
|
||||
# Проверка очереди celery
|
||||
queue_length = r_broker.llen("celery")
|
||||
print(f" ✓ Задач в очереди 'celery': {queue_length}")
|
||||
|
||||
except redis.ConnectionError as e:
|
||||
print(f" ✗ Ошибка подключения: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ✗ Ошибка: {e}")
|
||||
return False
|
||||
|
||||
# Проверка cache (database 1)
|
||||
print("\n4. Проверка Django Cache (db 1)...")
|
||||
try:
|
||||
r_cache = redis.from_url(cache_url)
|
||||
r_cache.ping()
|
||||
print(" ✓ Подключение успешно")
|
||||
|
||||
# Проверка ключей
|
||||
keys = r_cache.keys("*")
|
||||
print(f" ✓ Ключей в базе: {len(keys)}")
|
||||
|
||||
except redis.ConnectionError as e:
|
||||
print(f" ✗ Ошибка подключения: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" ✗ Ошибка: {e}")
|
||||
return False
|
||||
|
||||
# Тест записи/чтения
|
||||
print("\n5. Тест записи/чтения...")
|
||||
try:
|
||||
test_key = "test:celery:connection"
|
||||
test_value = "OK"
|
||||
|
||||
r_broker.set(test_key, test_value, ex=10) # TTL 10 секунд
|
||||
result = r_broker.get(test_key)
|
||||
|
||||
if result and result.decode() == test_value:
|
||||
print(f" ✓ Запись/чтение работает")
|
||||
r_broker.delete(test_key)
|
||||
else:
|
||||
print(f" ✗ Ошибка: ожидалось '{test_value}', получено '{result}'")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f" ✗ Ошибка: {e}")
|
||||
return False
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✓ ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = check_redis()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -4,18 +4,12 @@ Celery configuration for dbapp project.
|
||||
import os
|
||||
from celery import Celery
|
||||
|
||||
# Use the environment variable to determine the settings module
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', os.getenv('DJANGO_SETTINGS_MODULE', 'dbapp.settings.development'))
|
||||
|
||||
app = Celery('dbapp')
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
|
||||
|
||||
@@ -175,8 +175,8 @@ USE_TZ = True
|
||||
# ============================================================================
|
||||
|
||||
LOGIN_URL = "login"
|
||||
LOGIN_REDIRECT_URL = "mainapp:home"
|
||||
LOGOUT_REDIRECT_URL = "mainapp:home"
|
||||
LOGIN_REDIRECT_URL = "mainapp:source_list"
|
||||
LOGOUT_REDIRECT_URL = "mainapp:source_list"
|
||||
|
||||
# ============================================================================
|
||||
# STATIC FILES CONFIGURATION
|
||||
@@ -197,6 +197,8 @@ STATICFILES_DIRS = [
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
|
||||
|
||||
# ============================================================================
|
||||
# THIRD-PARTY APP CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
@@ -19,23 +19,29 @@ DEBUG = False
|
||||
# In production, specify allowed hosts explicitly from environment variable
|
||||
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||
|
||||
# CSRF trusted origins (required for forms to work behind proxy)
|
||||
CSRF_TRUSTED_ORIGINS = os.getenv(
|
||||
"CSRF_TRUSTED_ORIGINS",
|
||||
"http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080"
|
||||
).split(",")
|
||||
|
||||
# ============================================================================
|
||||
# SECURITY SETTINGS
|
||||
# ============================================================================
|
||||
|
||||
# SSL/HTTPS settings
|
||||
SECURE_SSL_REDIRECT = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
# SSL/HTTPS settings (disable for local testing without SSL)
|
||||
SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "False") == "True"
|
||||
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "False") == "True"
|
||||
CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "False") == "True"
|
||||
|
||||
# Security headers
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
|
||||
# HSTS settings
|
||||
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
SECURE_HSTS_PRELOAD = True
|
||||
# HSTS settings (disable for local testing)
|
||||
SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "0"))
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = os.getenv("SECURE_HSTS_INCLUDE_SUBDOMAINS", "False") == "True"
|
||||
SECURE_HSTS_PRELOAD = os.getenv("SECURE_HSTS_PRELOAD", "False") == "True"
|
||||
|
||||
# Additional security settings
|
||||
SECURE_REDIRECT_EXEMPT = []
|
||||
@@ -51,7 +57,7 @@ TEMPLATES = [
|
||||
"DIRS": [
|
||||
BASE_DIR / "templates",
|
||||
],
|
||||
"APP_DIRS": True,
|
||||
"APP_DIRS": False,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
@@ -82,6 +88,13 @@ STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesSto
|
||||
# ============================================================================
|
||||
# LOGGING CONFIGURATION
|
||||
# ============================================================================
|
||||
LOGS_DIR = BASE_DIR.parent / "logs"
|
||||
LOGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ============================================================================
|
||||
# CELERY LOGGING CONFIGURATION
|
||||
# ============================================================================
|
||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
@@ -110,7 +123,13 @@ LOGGING = {
|
||||
"file": {
|
||||
"level": "ERROR",
|
||||
"class": "logging.FileHandler",
|
||||
"filename": BASE_DIR.parent / "logs" / "django_errors.log",
|
||||
"filename": LOGS_DIR / "django_errors.log",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
"celery_file": {
|
||||
"level": "INFO",
|
||||
"class": "logging.FileHandler",
|
||||
"filename": LOGS_DIR / "celery.log",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
"mail_admins": {
|
||||
@@ -131,5 +150,24 @@ LOGGING = {
|
||||
"level": "ERROR",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery": {
|
||||
"handlers": ["console", "celery_file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.task": {
|
||||
"handlers": ["console", "celery_file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
"celery.worker": {
|
||||
"handlers": ["console", "celery_file"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Force Celery to log to stdout for Docker
|
||||
CELERY_WORKER_REDIRECT_STDOUTS = True
|
||||
CELERY_WORKER_REDIRECT_STDOUTS_LEVEL = "INFO"
|
||||
|
||||
@@ -14,17 +14,23 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from mainapp import views
|
||||
from mainapp.views import custom_logout
|
||||
from django.contrib.auth import views as auth_views
|
||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls, name='admin'),
|
||||
path('', include('mainapp.urls', namespace='mainapp')),
|
||||
path('', include('mapsapp.urls', namespace='mapsapp')),
|
||||
path('lyngsat/', include('lyngsatapp.urls', namespace='lyngsatapp')),
|
||||
# Authentication URLs
|
||||
path('login/', auth_views.LoginView.as_view(), name='login'),
|
||||
path('logout/', views.custom_logout, name='logout'),
|
||||
] + debug_toolbar_urls()
|
||||
path('logout/', custom_logout, name='logout'),
|
||||
]
|
||||
|
||||
# Only include debug toolbar in development
|
||||
if settings.DEBUG:
|
||||
from debug_toolbar.toolbar import debug_toolbar_urls
|
||||
urlpatterns += debug_toolbar_urls()
|
||||
|
||||
26
dbapp/entrypoint-celery.sh
Normal file
26
dbapp/entrypoint-celery.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Starting Celery Worker..."
|
||||
|
||||
# Ждем PostgreSQL
|
||||
echo "Waiting for PostgreSQL..."
|
||||
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL started"
|
||||
|
||||
# Ждем Redis (проверяем через Python, т.к. redis-cli не установлен)
|
||||
echo "Waiting for Redis..."
|
||||
until uv run python -c "import redis; r = redis.from_url('${CELERY_BROKER_URL}'); r.ping()" 2>/dev/null; do
|
||||
echo "Redis is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
echo "Redis started"
|
||||
|
||||
# Создаем директорию для логов
|
||||
mkdir -p /app/logs
|
||||
|
||||
# Запускаем команду (celery worker или beat)
|
||||
exec "$@"
|
||||
25
dbapp/entrypoint.sh
Executable file → Normal file
25
dbapp/entrypoint.sh
Executable file → Normal file
@@ -6,32 +6,35 @@ ENVIRONMENT=${ENVIRONMENT:-production}
|
||||
|
||||
echo "Starting in $ENVIRONMENT mode..."
|
||||
|
||||
# Ждем PostgreSQL
|
||||
if [ -d "logs" ]; then
|
||||
echo "Directory logs already exists."
|
||||
else
|
||||
echo "Creating logs directory..."
|
||||
mkdir -p logs
|
||||
fi
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
while ! nc -z $DB_HOST $DB_PORT; do
|
||||
sleep 0.1
|
||||
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL started"
|
||||
|
||||
# Выполняем миграции
|
||||
echo "Running migrations..."
|
||||
python manage.py migrate --noinput
|
||||
uv run python manage.py migrate --noinput
|
||||
|
||||
# Собираем статику (только для production)
|
||||
if [ "$ENVIRONMENT" = "production" ]; then
|
||||
echo "Collecting static files..."
|
||||
python manage.py collectstatic --noinput
|
||||
uv run python manage.py collectstatic --noinput
|
||||
fi
|
||||
|
||||
# Запускаем сервер в зависимости от окружения
|
||||
if [ "$ENVIRONMENT" = "development" ]; then
|
||||
echo "Starting Django development server..."
|
||||
exec python manage.py runserver 0.0.0.0:8000
|
||||
exec uv run python manage.py runserver 0.0.0.0:8000
|
||||
else
|
||||
echo "Starting Gunicorn..."
|
||||
exec gunicorn --bind 0.0.0.0:8000 \
|
||||
exec uv run gunicorn --bind 0.0.0.0:8000 \
|
||||
--workers ${GUNICORN_WORKERS:-3} \
|
||||
--timeout ${GUNICORN_TIMEOUT:-120} \
|
||||
--reload \
|
||||
dbapp.wsgi:application
|
||||
fi
|
||||
|
||||
@@ -8,92 +8,121 @@
|
||||
4. Иначе создать новый Source с coords_average = координаты geo_obj
|
||||
"""
|
||||
|
||||
import os
|
||||
import django
|
||||
# import os
|
||||
# import django
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dbapp.settings")
|
||||
django.setup()
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
|
||||
return math.sqrt((lon2 - lon1) ** 2 + (lat2 - lat1) ** 2)
|
||||
# Пример использования
|
||||
lat1, lon1 = 56.15465080269812, 38.140518028837285
|
||||
lat2, lon2 = 56.0852, 38.0852
|
||||
midpoint = calculate_mean_coords((lat1, lon1), (lat2, lon2)) #56.15465080269812, 38.140518028837285
|
||||
|
||||
|
||||
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()
|
||||
print(f"Средняя точка: {midpoint[0]}")
|
||||
print(f"Расстояние: {midpoint[1]} км")
|
||||
@@ -6,6 +6,7 @@ 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__)
|
||||
|
||||
@@ -53,9 +54,13 @@ def process_single_satellite(
|
||||
|
||||
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(name__icontains=sat_name)
|
||||
sat_obj = Satellite.objects.get(
|
||||
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
|
||||
)
|
||||
logger.debug(f"Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
||||
except Satellite.DoesNotExist:
|
||||
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
||||
@@ -185,7 +190,7 @@ def fill_lyngsat_data_async(
|
||||
try:
|
||||
# Создаем парсер
|
||||
parser = AsyncLyngSatParser(
|
||||
flaresolver_url="http://localhost:8191/v1",
|
||||
flaresolver_url=FLARESOLVERR_URL,
|
||||
target_sats=target_sats,
|
||||
regions=regions,
|
||||
use_cache=use_cache
|
||||
|
||||
151
dbapp/lyngsatapp/templates/lyngsatapp/lyngsat_list.html
Normal file
151
dbapp/lyngsatapp/templates/lyngsatapp/lyngsat_list.html
Normal file
@@ -0,0 +1,151 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
|
||||
{% block title %}Источники LyngSat{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.table-responsive tr.selected {
|
||||
background-color: #d4edff;
|
||||
}
|
||||
.sticky-top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3">
|
||||
<!-- Page Header -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h2>Данные по ИРИ с ресурса LyngSat</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar Component -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=True search_placeholder="Поиск по ID..." action_buttons=action_buttons_html %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Panel Component -->
|
||||
{% include 'mainapp/components/_filter_panel.html' with filters=filter_html_list %}
|
||||
|
||||
<!-- Main Table -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
|
||||
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.85rem;">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th scope="col" class="text-center" style="min-width: 60px;">
|
||||
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
|
||||
</th>
|
||||
<th scope="col" style="min-width: 120px;">Спутник</th>
|
||||
<th scope="col" style="min-width: 100px;">
|
||||
{% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
|
||||
</th>
|
||||
<th scope="col" style="min-width: 100px;">Поляризация</th>
|
||||
<th scope="col" style="min-width: 120px;">
|
||||
{% include 'mainapp/components/_sort_header.html' with field='sym_velocity' label='Сим. скорость, БОД' current_sort=sort %}
|
||||
</th>
|
||||
<th scope="col" style="min-width: 100px;">Модуляция</th>
|
||||
<th scope="col" style="min-width: 100px;">Стандарт</th>
|
||||
<th scope="col" style="min-width: 80px;">FEC</th>
|
||||
<th scope="col" style="min-width: 150px;">Описание</th>
|
||||
<th scope="col" style="min-width: 120px;">
|
||||
{% include 'mainapp/components/_sort_header.html' with field='last_update' label='Обновлено' current_sort=sort %}
|
||||
</th>
|
||||
<th scope="col" style="min-width: 100px;">Ссылка</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in lyngsat_items %}
|
||||
<tr>
|
||||
<td class="text-center">{{ item.id }}</td>
|
||||
<td>
|
||||
{% if item.id_satellite %}
|
||||
<a href="#" class="text-decoration-underline"
|
||||
onclick="showSatelliteModal({{ item.id_satellite.id }}); return false;">
|
||||
{{ item.id_satellite.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.frequency|floatformat:3|default:"-" }}</td>
|
||||
<td>{{ item.polarization.name|default:"-" }}</td>
|
||||
<td>{{ item.sym_velocity|floatformat:0|default:"-" }}</td>
|
||||
<td>{{ item.modulation.name|default:"-" }}</td>
|
||||
<td>{{ item.standard.name|default:"-" }}</td>
|
||||
<td>{{ item.fec|default:"-" }}</td>
|
||||
<td>{{ item.channel_info|default:"-" }}</td>
|
||||
<td>{{ item.last_update|date:"d.m.Y"|default:"-" }}</td>
|
||||
<td>
|
||||
{% if item.url %}
|
||||
<a href="{{ item.url }}" target="_blank" class="btn btn-sm btn-outline-primary" title="Открыть ссылку">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="11" class="text-center text-muted">Нет данных для отображения</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% load static %}
|
||||
<!-- Include sorting functionality -->
|
||||
<script src="{% static 'js/sorting.js' %}"></script>
|
||||
|
||||
<script>
|
||||
// Function to select/deselect all options in a select element
|
||||
function selectAllOptions(selectName, selectAll) {
|
||||
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
||||
if (selectElement) {
|
||||
for (let i = 0; i < selectElement.options.length; i++) {
|
||||
selectElement.options[i].selected = selectAll;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced filter counter for multi-select fields
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('filter-form');
|
||||
if (form) {
|
||||
// Add event listeners to multi-select fields
|
||||
const selectFields = form.querySelectorAll('select[multiple]');
|
||||
selectFields.forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
// Trigger the filter counter update from _filter_panel.html
|
||||
const event = new Event('change', { bubbles: true });
|
||||
form.dispatchEvent(event);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Include the satellite modal component -->
|
||||
{% include 'mainapp/components/_satellite_modal.html' %}
|
||||
|
||||
{% endblock %}
|
||||
8
dbapp/lyngsatapp/urls.py
Normal file
8
dbapp/lyngsatapp/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'lyngsatapp'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.LyngSatListView.as_view(), name='lyngsat_list'),
|
||||
]
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
from .parser import LyngSatParser
|
||||
from .models import LyngSat
|
||||
from mainapp.models import Polarization, Standard, Modulation, Satellite
|
||||
from dbapp.settings.base import FLARESOLVERR_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,7 +51,7 @@ def fill_lyngsat_data(
|
||||
|
||||
try:
|
||||
parser = LyngSatParser(
|
||||
flaresolver_url="http://localhost:8191/v1",
|
||||
flaresolver_url=FLARESOLVERR_URL,
|
||||
target_sats=target_sats,
|
||||
regions=regions
|
||||
)
|
||||
@@ -76,9 +77,13 @@ def fill_lyngsat_data(
|
||||
|
||||
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
|
||||
|
||||
# Находим спутник в базе
|
||||
# Находим спутник в базе по имени или альтернативному имени (lowercase)
|
||||
from django.db.models import Q
|
||||
sat_name_lower = sat_name.lower()
|
||||
try:
|
||||
sat_obj = Satellite.objects.get(name__icontains=sat_name)
|
||||
sat_obj = Satellite.objects.get(
|
||||
Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
|
||||
)
|
||||
logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
|
||||
except Satellite.DoesNotExist:
|
||||
error_msg = f"Спутник '{sat_name}' не найден в базе данных"
|
||||
|
||||
@@ -1,3 +1,285 @@
|
||||
from django.shortcuts import render
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Q
|
||||
from django.views.generic import ListView
|
||||
|
||||
# Create your views here.
|
||||
from .models import LyngSat
|
||||
from mainapp.models import Satellite, Polarization, Modulation, Standard
|
||||
from mainapp.utils import parse_pagination_params
|
||||
|
||||
|
||||
class LyngSatListView(LoginRequiredMixin, ListView):
|
||||
"""
|
||||
Представление для отображения списка источников LyngSat с фильтрацией и пагинацией.
|
||||
"""
|
||||
model = LyngSat
|
||||
template_name = 'lyngsatapp/lyngsat_list.html'
|
||||
context_object_name = 'lyngsat_items'
|
||||
paginate_by = 50
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Возвращает отфильтрованный и отсортированный queryset.
|
||||
"""
|
||||
queryset = LyngSat.objects.select_related(
|
||||
'id_satellite',
|
||||
'polarization',
|
||||
'modulation',
|
||||
'standard'
|
||||
).all()
|
||||
|
||||
# Поиск по ID
|
||||
search_query = self.request.GET.get('search', '').strip()
|
||||
if search_query:
|
||||
try:
|
||||
search_id = int(search_query)
|
||||
queryset = queryset.filter(id=search_id)
|
||||
except ValueError:
|
||||
queryset = queryset.none()
|
||||
|
||||
# Фильтр по спутнику
|
||||
satellite_ids = self.request.GET.getlist('satellite_id')
|
||||
if satellite_ids:
|
||||
queryset = queryset.filter(id_satellite_id__in=satellite_ids)
|
||||
|
||||
# Фильтр по поляризации
|
||||
polarization_ids = self.request.GET.getlist('polarization_id')
|
||||
if polarization_ids:
|
||||
queryset = queryset.filter(polarization_id__in=polarization_ids)
|
||||
|
||||
# Фильтр по модуляции
|
||||
modulation_ids = self.request.GET.getlist('modulation_id')
|
||||
if modulation_ids:
|
||||
queryset = queryset.filter(modulation_id__in=modulation_ids)
|
||||
|
||||
# Фильтр по стандарту
|
||||
standard_ids = self.request.GET.getlist('standard_id')
|
||||
if standard_ids:
|
||||
queryset = queryset.filter(standard_id__in=standard_ids)
|
||||
|
||||
# Фильтр по частоте
|
||||
freq_min = self.request.GET.get('freq_min', '').strip()
|
||||
freq_max = self.request.GET.get('freq_max', '').strip()
|
||||
if freq_min:
|
||||
try:
|
||||
queryset = queryset.filter(frequency__gte=float(freq_min))
|
||||
except ValueError:
|
||||
pass
|
||||
if freq_max:
|
||||
try:
|
||||
queryset = queryset.filter(frequency__lte=float(freq_max))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Фильтр по символьной скорости
|
||||
sym_min = self.request.GET.get('sym_min', '').strip()
|
||||
sym_max = self.request.GET.get('sym_max', '').strip()
|
||||
if sym_min:
|
||||
try:
|
||||
queryset = queryset.filter(sym_velocity__gte=float(sym_min))
|
||||
except ValueError:
|
||||
pass
|
||||
if sym_max:
|
||||
try:
|
||||
queryset = queryset.filter(sym_velocity__lte=float(sym_max))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Фильтр по дате обновления
|
||||
date_from = self.request.GET.get('date_from', '').strip()
|
||||
date_to = self.request.GET.get('date_to', '').strip()
|
||||
if date_from:
|
||||
queryset = queryset.filter(last_update__gte=date_from)
|
||||
if date_to:
|
||||
queryset = queryset.filter(last_update__lte=date_to)
|
||||
|
||||
# Сортировка
|
||||
sort = self.request.GET.get('sort', '-id')
|
||||
valid_sort_fields = ['id', '-id', 'frequency', '-frequency', 'sym_velocity', '-sym_velocity', 'last_update', '-last_update']
|
||||
if sort in valid_sort_fields:
|
||||
queryset = queryset.order_by(sort)
|
||||
else:
|
||||
queryset = queryset.order_by('-id')
|
||||
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Добавляет дополнительный контекст для шаблона.
|
||||
"""
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Параметры пагинации
|
||||
page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
|
||||
context['items_per_page'] = items_per_page
|
||||
context['available_items_per_page'] = [25, 50, 100, 200, 500]
|
||||
|
||||
# Пагинация
|
||||
paginator = Paginator(self.get_queryset(), items_per_page)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
context['page_obj'] = page_obj
|
||||
context['lyngsat_items'] = page_obj.object_list
|
||||
|
||||
# Параметры поиска и фильтрации
|
||||
context['search_query'] = self.request.GET.get('search', '')
|
||||
context['sort'] = self.request.GET.get('sort', '-id')
|
||||
|
||||
# Данные для фильтров - только спутники с существующими записями LyngSat
|
||||
satellites = Satellite.objects.filter(
|
||||
lyngsat__isnull=False
|
||||
).distinct().order_by('name')
|
||||
polarizations = Polarization.objects.all().order_by('name')
|
||||
modulations = Modulation.objects.all().order_by('name')
|
||||
standards = Standard.objects.all().order_by('name')
|
||||
|
||||
# Выбранные фильтры
|
||||
selected_satellites = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
|
||||
selected_polarizations = [int(x) for x in self.request.GET.getlist('polarization_id') if x.isdigit()]
|
||||
selected_modulations = [int(x) for x in self.request.GET.getlist('modulation_id') if x.isdigit()]
|
||||
selected_standards = [int(x) for x in self.request.GET.getlist('standard_id') if x.isdigit()]
|
||||
|
||||
# Параметры фильтров
|
||||
freq_min = self.request.GET.get('freq_min', '')
|
||||
freq_max = self.request.GET.get('freq_max', '')
|
||||
sym_min = self.request.GET.get('sym_min', '')
|
||||
sym_max = self.request.GET.get('sym_max', '')
|
||||
date_from = self.request.GET.get('date_from', '')
|
||||
date_to = self.request.GET.get('date_to', '')
|
||||
|
||||
# Action buttons HTML for toolbar component
|
||||
from django.urls import reverse
|
||||
action_buttons_html = f'''
|
||||
<a href="{reverse('mainapp:fill_lyngsat_data')}" class="btn btn-secondary btn-sm" title="Заполнить данные Lyngsat">
|
||||
<i class="bi bi-cloud-download"></i> Добавить данные
|
||||
</a>
|
||||
<a href="{reverse('mainapp:link_lyngsat')}" class="btn btn-primary btn-sm" title="Привязать источники LyngSat">
|
||||
<i class="bi bi-link-45deg"></i> Привязать
|
||||
</a>
|
||||
<a href="{reverse('mainapp:unlink_all_lyngsat')}" class="btn btn-warning btn-sm" title="Отвязать все источники LyngSat">
|
||||
<i class="bi bi-x-circle"></i> Отвязать
|
||||
</a>
|
||||
'''
|
||||
context['action_buttons_html'] = action_buttons_html
|
||||
|
||||
# Build filter HTML list for filter_panel component
|
||||
filter_html_list = []
|
||||
|
||||
# Satellite filter
|
||||
satellite_options = ''.join([
|
||||
f'<option value="{sat.id}" {"selected" if sat.id in selected_satellites else ""}>{sat.name}</option>'
|
||||
for sat in satellites
|
||||
])
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Спутник:</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('satellite_id', false)">Снять</button>
|
||||
</div>
|
||||
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
|
||||
{satellite_options}
|
||||
</select>
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Polarization filter
|
||||
polarization_options = ''.join([
|
||||
f'<option value="{pol.id}" {"selected" if pol.id in selected_polarizations else ""}>{pol.name}</option>'
|
||||
for pol in polarizations
|
||||
])
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Поляризация:</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('polarization_id', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('polarization_id', false)">Снять</button>
|
||||
</div>
|
||||
<select name="polarization_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||
{polarization_options}
|
||||
</select>
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Modulation filter
|
||||
modulation_options = ''.join([
|
||||
f'<option value="{mod.id}" {"selected" if mod.id in selected_modulations else ""}>{mod.name}</option>'
|
||||
for mod in modulations
|
||||
])
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Модуляция:</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('modulation_id', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('modulation_id', false)">Снять</button>
|
||||
</div>
|
||||
<select name="modulation_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||
{modulation_options}
|
||||
</select>
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Standard filter
|
||||
standard_options = ''.join([
|
||||
f'<option value="{std.id}" {"selected" if std.id in selected_standards else ""}>{std.name}</option>'
|
||||
for std in standards
|
||||
])
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Стандарт:</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('standard_id', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('standard_id', false)">Снять</button>
|
||||
</div>
|
||||
<select name="standard_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||
{standard_options}
|
||||
</select>
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Frequency filter
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Частота, МГц:</label>
|
||||
<input type="number" step="0.001" name="freq_min" class="form-control form-control-sm mb-1"
|
||||
placeholder="От" value="{freq_min}">
|
||||
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm"
|
||||
placeholder="До" value="{freq_max}">
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Symbol rate filter
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Символьная скорость, БОД:</label>
|
||||
<input type="number" step="0.001" name="sym_min" class="form-control form-control-sm mb-1"
|
||||
placeholder="От" value="{sym_min}">
|
||||
<input type="number" step="0.001" name="sym_max" class="form-control form-control-sm"
|
||||
placeholder="До" value="{sym_max}">
|
||||
</div>
|
||||
''')
|
||||
|
||||
# Date filter
|
||||
filter_html_list.append(f'''
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Дата обновления:</label>
|
||||
<input type="date" name="date_from" id="date_from" class="form-control form-control-sm mb-1"
|
||||
placeholder="От" value="{date_from}">
|
||||
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm"
|
||||
placeholder="До" value="{date_to}">
|
||||
</div>
|
||||
''')
|
||||
|
||||
context['filter_html_list'] = filter_html_list
|
||||
|
||||
# Enable full width layout
|
||||
context['full_width_page'] = True
|
||||
|
||||
return context
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
# Third-party imports
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
from sklearn.cluster import DBSCAN, HDBSCAN, KMeans
|
||||
|
||||
# Local imports
|
||||
from .models import ObjItem
|
||||
|
||||
def get_clusters(coords: list[tuple[float, float]]):
|
||||
coords = np.radians(coords)
|
||||
lat, lon = coords[:, 0], coords[:, 1]
|
||||
db = DBSCAN(eps=0.06, min_samples=5, algorithm='ball_tree', metric='haversine')
|
||||
# db = HDBSCAN()
|
||||
cluster_labels = db.fit_predict(coords)
|
||||
plt.figure(figsize=(10, 8))
|
||||
unique_labels = set(cluster_labels)
|
||||
colors = plt.cm.tab10(np.linspace(0, 1, len(unique_labels)))
|
||||
|
||||
for label, color in zip(unique_labels, colors):
|
||||
if label == -1:
|
||||
color = 'k'
|
||||
label_name = 'Шум'
|
||||
else:
|
||||
label_name = f'Кластер {label}'
|
||||
|
||||
mask = cluster_labels == label
|
||||
plt.scatter(lon[mask], lat[mask], c=[color], label=label_name, s=30)
|
||||
|
||||
plt.xlabel('Долгота')
|
||||
plt.ylabel('Широта')
|
||||
plt.title('Кластеризация геоданных с DBSCAN (метрика Хаверсина)')
|
||||
plt.legend()
|
||||
plt.grid(True)
|
||||
plt.show()
|
||||
File diff suppressed because it is too large
Load Diff
1
dbapp/mainapp/management/__init__.py
Normal file
1
dbapp/mainapp/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Management commands package
|
||||
1
dbapp/mainapp/management/commands/__init__.py
Normal file
1
dbapp/mainapp/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Commands package
|
||||
169
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
169
dbapp/mainapp/management/commands/generate_test_marks.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Management command для генерации тестовых отметок сигналов.
|
||||
|
||||
Использование:
|
||||
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025
|
||||
|
||||
Параметры:
|
||||
--satellite_id: ID спутника (обязательный)
|
||||
--user_id: ID пользователя CustomUser (обязательный)
|
||||
--date_range: Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (обязательный)
|
||||
--clear: Удалить существующие отметки перед генерацией
|
||||
|
||||
Особенности:
|
||||
- Генерирует отметки только в будние дни (пн-пт)
|
||||
- Время отметок: утро с 8:00 до 11:00
|
||||
- Одна отметка в день для всех сигналов спутника
|
||||
- Все отметки в один день имеют одинаковый timestamp (пакетное сохранение)
|
||||
- Все отметки имеют значение True (сигнал присутствует)
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
|
||||
from mainapp.models import TechAnalyze, ObjectMark, Satellite, CustomUser
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Генерирует тестовые отметки сигналов для теханализов выбранного спутника'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--satellite_id',
|
||||
type=int,
|
||||
required=True,
|
||||
help='ID спутника для генерации отметок'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--user_id',
|
||||
type=int,
|
||||
required=True,
|
||||
help='ID пользователя CustomUser - автор всех отметок'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--date_range',
|
||||
type=str,
|
||||
required=True,
|
||||
help='Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--clear',
|
||||
action='store_true',
|
||||
help='Удалить существующие отметки перед генерацией'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
satellite_id = options['satellite_id']
|
||||
user_id = options['user_id']
|
||||
date_range = options['date_range']
|
||||
clear = options['clear']
|
||||
|
||||
# Проверяем существование пользователя
|
||||
try:
|
||||
custom_user = CustomUser.objects.select_related('user').get(id=user_id)
|
||||
except CustomUser.DoesNotExist:
|
||||
raise CommandError(f'Пользователь CustomUser с ID {user_id} не найден')
|
||||
|
||||
# Парсим диапазон дат
|
||||
try:
|
||||
start_str, end_str = date_range.split('-')
|
||||
start_date = datetime.strptime(start_str.strip(), '%d.%m.%Y')
|
||||
end_date = datetime.strptime(end_str.strip(), '%d.%m.%Y')
|
||||
|
||||
# Делаем timezone-aware
|
||||
start_date = timezone.make_aware(start_date)
|
||||
end_date = timezone.make_aware(end_date)
|
||||
|
||||
if start_date > end_date:
|
||||
raise CommandError('Начальная дата должна быть раньше конечной')
|
||||
|
||||
except ValueError as e:
|
||||
raise CommandError(
|
||||
f'Неверный формат даты. Используйте ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025). Ошибка: {e}'
|
||||
)
|
||||
|
||||
# Проверяем существование спутника
|
||||
try:
|
||||
satellite = Satellite.objects.get(id=satellite_id)
|
||||
except Satellite.DoesNotExist:
|
||||
raise CommandError(f'Спутник с ID {satellite_id} не найден')
|
||||
|
||||
# Получаем теханализы для спутника
|
||||
tech_analyzes = list(TechAnalyze.objects.filter(satellite=satellite))
|
||||
ta_count = len(tech_analyzes)
|
||||
|
||||
if ta_count == 0:
|
||||
raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
|
||||
|
||||
self.stdout.write(f'Спутник: {satellite.name}')
|
||||
self.stdout.write(f'Теханализов: {ta_count}')
|
||||
self.stdout.write(f'Пользователь: {custom_user}')
|
||||
self.stdout.write(f'Период: {start_str} - {end_str} (только будние дни)')
|
||||
self.stdout.write(f'Время: 8:00 - 11:00')
|
||||
|
||||
# Удаляем существующие отметки если указан флаг
|
||||
if clear:
|
||||
deleted_count = ObjectMark.objects.filter(
|
||||
tech_analyze__satellite=satellite
|
||||
).delete()[0]
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
|
||||
)
|
||||
|
||||
# Генерируем отметки
|
||||
total_marks = 0
|
||||
marks_to_create = []
|
||||
workdays_count = 0
|
||||
|
||||
current_date = start_date
|
||||
# Включаем конечную дату в диапазон
|
||||
end_date_inclusive = end_date + timedelta(days=1)
|
||||
|
||||
while current_date < end_date_inclusive:
|
||||
# Проверяем, что это будний день (0=пн, 4=пт)
|
||||
if current_date.weekday() < 5:
|
||||
workdays_count += 1
|
||||
|
||||
# Генерируем случайное время в диапазоне 8:00-11:00
|
||||
random_hour = random.randint(8, 10)
|
||||
random_minute = random.randint(0, 59)
|
||||
random_second = random.randint(0, 59)
|
||||
|
||||
mark_time = current_date.replace(
|
||||
hour=random_hour,
|
||||
minute=random_minute,
|
||||
second=random_second,
|
||||
microsecond=0
|
||||
)
|
||||
|
||||
# Создаём отметки для всех теханализов с одинаковым timestamp
|
||||
for ta in tech_analyzes:
|
||||
marks_to_create.append(ObjectMark(
|
||||
tech_analyze=ta,
|
||||
mark=True, # Всегда True
|
||||
timestamp=mark_time,
|
||||
created_by=custom_user,
|
||||
))
|
||||
total_marks += 1
|
||||
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# Bulk create для производительности
|
||||
self.stdout.write(f'Рабочих дней: {workdays_count}')
|
||||
self.stdout.write(f'Создание {total_marks} отметок...')
|
||||
|
||||
# Создаём партиями по 1000
|
||||
batch_size = 1000
|
||||
for i in range(0, len(marks_to_create), batch_size):
|
||||
batch = marks_to_create[i:i + batch_size]
|
||||
ObjectMark.objects.bulk_create(batch)
|
||||
self.stdout.write(f' Создано: {min(i + batch_size, len(marks_to_create))}/{total_marks}')
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Успешно создано {total_marks} отметок для {ta_count} теханализов за {workdays_count} рабочих дней'
|
||||
)
|
||||
)
|
||||
@@ -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='Зеркала'),
|
||||
),
|
||||
]
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
27
dbapp/mainapp/migrations/0006_change_objectmark_to_source.py
Normal file
27
dbapp/mainapp/migrations/0006_change_objectmark_to_source.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-16 15:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0005_alter_sigmaparmark_options_objectmark'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='objectmark',
|
||||
options={'ordering': ['-timestamp'], 'verbose_name': 'Отметка источника', 'verbose_name_plural': 'Отметки источников'},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='objectmark',
|
||||
name='objitem',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objectmark',
|
||||
name='source',
|
||||
field=models.ForeignKey(help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
|
||||
),
|
||||
]
|
||||
19
dbapp/mainapp/migrations/0007_make_source_required.py
Normal file
19
dbapp/mainapp/migrations/0007_make_source_required.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-16 15:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0006_change_objectmark_to_source'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='objectmark',
|
||||
name='source',
|
||||
field=models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='marks', to='mainapp.source', verbose_name='Источник'),
|
||||
),
|
||||
]
|
||||
31
dbapp/mainapp/migrations/0008_objectinfo_source_info.py
Normal file
31
dbapp/mainapp/migrations/0008_objectinfo_source_info.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-17 12:26
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0007_make_source_required'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ObjectInfo',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Информация о типе объекта', max_length=255, unique=True, verbose_name='Тип объекта')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тип объекта',
|
||||
'verbose_name_plural': 'Типы объектов',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='info',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-20 11:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0008_objectinfo_source_info'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ObjectOwnership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='Принадлежность объекта (страна, организация и т.д.)', max_length=255, unique=True, verbose_name='Принадлежность')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Принадлежность объекта',
|
||||
'verbose_name_plural': 'Принадлежности объектов',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='source',
|
||||
name='info',
|
||||
field=models.ForeignKey(blank=True, help_text='Тип объекта', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='ownership',
|
||||
field=models.ForeignKey(blank=True, help_text='Принадлежность объекта (страна, организация и т.д.)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_ownership', to='mainapp.objectownership', verbose_name='Принадлежность объекта'),
|
||||
),
|
||||
]
|
||||
38
dbapp/mainapp/migrations/0010_set_default_source_type.py
Normal file
38
dbapp/mainapp/migrations/0010_set_default_source_type.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-21 07:35
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_default_source_type(apps, schema_editor):
|
||||
"""
|
||||
Устанавливает тип "Стационарные" для всех Source, у которых не указан тип.
|
||||
"""
|
||||
Source = apps.get_model('mainapp', 'Source')
|
||||
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||
|
||||
# Создаем или получаем тип "Стационарные"
|
||||
stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
|
||||
|
||||
# Обновляем все Source без типа
|
||||
sources_without_type = Source.objects.filter(info__isnull=True)
|
||||
count = sources_without_type.update(info=stationary_info)
|
||||
|
||||
print(f"Обновлено {count} источников с типом 'Стационарные'")
|
||||
|
||||
|
||||
def reverse_set_default_source_type(apps, schema_editor):
|
||||
"""
|
||||
Обратная миграция - ничего не делаем, так как это безопасная операция.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0009_objectownership_alter_source_info_source_ownership'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_default_source_type, reverse_set_default_source_type),
|
||||
]
|
||||
@@ -0,0 +1,74 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-21 07:42
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fix_capitalization(apps, schema_editor):
|
||||
"""
|
||||
Исправляет регистр типов объектов: "стационарные" -> "Стационарные", "подвижные" -> "Подвижные"
|
||||
"""
|
||||
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||
Source = apps.get_model('mainapp', 'Source')
|
||||
|
||||
# Создаем правильные типы с большой буквы
|
||||
stationary_new, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
|
||||
mobile_new, _ = ObjectInfo.objects.get_or_create(name="Подвижные")
|
||||
|
||||
# Находим старые типы с маленькой буквы
|
||||
try:
|
||||
stationary_old = ObjectInfo.objects.get(name="стационарные")
|
||||
# Обновляем все Source, которые используют старый тип
|
||||
count = Source.objects.filter(info=stationary_old).update(info=stationary_new)
|
||||
print(f"Обновлено {count} источников: 'стационарные' -> 'Стационарные'")
|
||||
# Удаляем старый тип
|
||||
stationary_old.delete()
|
||||
except ObjectInfo.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
mobile_old = ObjectInfo.objects.get(name="подвижные")
|
||||
# Обновляем все Source, которые используют старый тип
|
||||
count = Source.objects.filter(info=mobile_old).update(info=mobile_new)
|
||||
print(f"Обновлено {count} источников: 'подвижные' -> 'Подвижные'")
|
||||
# Удаляем старый тип
|
||||
mobile_old.delete()
|
||||
except ObjectInfo.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
def reverse_fix_capitalization(apps, schema_editor):
|
||||
"""
|
||||
Обратная миграция - возвращаем маленькие буквы
|
||||
"""
|
||||
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
|
||||
Source = apps.get_model('mainapp', 'Source')
|
||||
|
||||
# Создаем типы с маленькой буквы
|
||||
stationary_old, _ = ObjectInfo.objects.get_or_create(name="стационарные")
|
||||
mobile_old, _ = ObjectInfo.objects.get_or_create(name="подвижные")
|
||||
|
||||
# Находим типы с большой буквы
|
||||
try:
|
||||
stationary_new = ObjectInfo.objects.get(name="Стационарные")
|
||||
Source.objects.filter(info=stationary_new).update(info=stationary_old)
|
||||
stationary_new.delete()
|
||||
except ObjectInfo.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
mobile_new = ObjectInfo.objects.get(name="Подвижные")
|
||||
Source.objects.filter(info=mobile_new).update(info=mobile_old)
|
||||
mobile_new.delete()
|
||||
except ObjectInfo.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0010_set_default_source_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_capitalization, reverse_fix_capitalization),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-21 12:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0011_fix_source_type_capitalization'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='confirm_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Дата и время добавления последней полученной точки ГЛ', null=True, verbose_name='Дата подтверждения'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='last_signal_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Дата и время последней отметки о наличии сигнала', null=True, verbose_name='Последний сигнал'),
|
||||
),
|
||||
]
|
||||
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
28
dbapp/mainapp/migrations/0013_add_is_automatic_to_objitem.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-24 19:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0012_source_confirm_at_source_last_signal_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name='Mirror',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='sigmaparameter',
|
||||
name='mark',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='objitem',
|
||||
name='is_automatic',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='Если True, точка не добавляется к объектам (Source), а хранится отдельно', verbose_name='Автоматическая'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='SigmaParMark',
|
||||
),
|
||||
]
|
||||
18
dbapp/mainapp/migrations/0014_source_note.py
Normal file
18
dbapp/mainapp/migrations/0014_source_note.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-25 12:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0013_add_is_automatic_to_objitem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='source',
|
||||
name='note',
|
||||
field=models.TextField(blank=True, help_text='Дополнительное описание объекта', null=True, verbose_name='Примечание'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-26 20:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0014_source_note'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='satellite',
|
||||
name='international_code',
|
||||
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=20, null=True, verbose_name='Международный код'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-27 07:10
|
||||
|
||||
import django.db.models.deletion
|
||||
import mainapp.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0015_add_international_code_to_satellite'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='satellite',
|
||||
name='international_code',
|
||||
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=50, null=True, verbose_name='Международный код'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TechAnalyze',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(db_index=True, help_text='Уникальное название для технического анализа', max_length=255, unique=True, verbose_name='Имя')),
|
||||
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
|
||||
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
|
||||
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость', null=True, verbose_name='Символьная скорость, БОД')),
|
||||
('note', models.TextField(blank=True, help_text='Дополнительные примечания', null=True, verbose_name='Примечание')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
|
||||
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_modulations', to='mainapp.modulation', verbose_name='Модуляция')),
|
||||
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
|
||||
('satellite', models.ForeignKey(help_text='Спутник, к которому относится анализ', on_delete=django.db.models.deletion.PROTECT, related_name='tech_analyzes', to='mainapp.satellite', verbose_name='Спутник')),
|
||||
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_standards', to='mainapp.standard', verbose_name='Стандарт')),
|
||||
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тех. анализ',
|
||||
'verbose_name_plural': 'Тех. анализы',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-01 08:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0016_alter_satellite_international_code_techanalyze'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='satellite',
|
||||
name='alternative_name',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника (например, из скобок)', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='standard',
|
||||
name='name',
|
||||
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=80, unique=True, verbose_name='Стандарт'),
|
||||
),
|
||||
]
|
||||
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
87
dbapp/mainapp/migrations/0018_add_source_request_models.py
Normal file
@@ -0,0 +1,87 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-08 08:45
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0017_add_satellite_alternative_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='objectownership',
|
||||
name='name',
|
||||
field=models.CharField(help_text='Принадлежность объекта', max_length=255, unique=True, verbose_name='Принадлежность'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='satellite',
|
||||
name='alternative_name',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника', max_length=100, null=True, verbose_name='Альтернативное имя'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SourceRequest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус')),
|
||||
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], db_index=True, default='medium', help_text='Приоритет заявки', max_length=10, verbose_name='Приоритет')),
|
||||
('planned_at', models.DateTimeField(blank=True, help_text='Запланированная дата и время', null=True, verbose_name='Дата и время планирования')),
|
||||
('request_date', models.DateField(blank=True, help_text='Дата подачи заявки', null=True, verbose_name='Дата заявки')),
|
||||
('status_updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления статуса', verbose_name='Дата обновления статуса')),
|
||||
('gso_success', models.BooleanField(blank=True, help_text='Успешность ГСО', null=True, verbose_name='ГСО успешно?')),
|
||||
('kubsat_success', models.BooleanField(blank=True, help_text='Успешность Кубсат', null=True, verbose_name='Кубсат успешно?')),
|
||||
('comment', models.TextField(blank=True, help_text='Дополнительные комментарии к заявке', null=True, verbose_name='Комментарий')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
|
||||
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
|
||||
('source', models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник')),
|
||||
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Заявка на источник',
|
||||
'verbose_name_plural': 'Заявки на источники',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SourceRequestStatusHistory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('old_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус')),
|
||||
('new_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус')),
|
||||
('changed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время изменения статуса', verbose_name='Дата изменения')),
|
||||
('changed_by', models.ForeignKey(blank=True, help_text='Пользователь, изменивший статус', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to='mainapp.customuser', verbose_name='Изменен пользователем')),
|
||||
('source_request', models.ForeignKey(help_text='Связанная заявка', on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='mainapp.sourcerequest', verbose_name='Заявка')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'История статуса заявки',
|
||||
'verbose_name_plural': 'История статусов заявок',
|
||||
'ordering': ['-changed_at'],
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequest',
|
||||
index=models.Index(fields=['-created_at'], name='mainapp_sou_created_61d8ae_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequest',
|
||||
index=models.Index(fields=['status'], name='mainapp_sou_status_31dc99_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequest',
|
||||
index=models.Index(fields=['priority'], name='mainapp_sou_priorit_5b5044_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequest',
|
||||
index=models.Index(fields=['source', '-created_at'], name='mainapp_sou_source__6bb459_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequeststatushistory',
|
||||
index=models.Index(fields=['-changed_at'], name='mainapp_sou_changed_9b876e_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='sourcerequeststatushistory',
|
||||
index=models.Index(fields=['source_request', '-changed_at'], name='mainapp_sou_source__957c28_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-08 09:24
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0018_add_source_request_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='coords',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты по выбранным точкам (WGS84)', null=True, srid=4326, verbose_name='Координаты'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='points_count',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Количество точек ГЛ, использованных для расчёта координат', verbose_name='Количество точек'),
|
||||
),
|
||||
]
|
||||
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
18
dbapp/mainapp/migrations/0020_satellite_location_place.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-08 12:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0019_add_coords_to_source_request'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='satellite',
|
||||
name='location_place',
|
||||
field=models.CharField(choices=[('kr', 'КР'), ('dv', 'ДВ')], default='kr', help_text='К какому комплексу принадлежит спутник', max_length=30, null=True, verbose_name='Комплекс'),
|
||||
),
|
||||
]
|
||||
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
60
dbapp/mainapp/migrations/0021_add_source_request_fields.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-09 12:39
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0020_satellite_location_place'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='card_date',
|
||||
field=models.DateField(blank=True, help_text='Дата формирования карточки', null=True, verbose_name='Дата формирования карточки'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='coords_source',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты источника (WGS84)', null=True, srid=4326, verbose_name='Координаты источника'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='downlink',
|
||||
field=models.FloatField(blank=True, help_text='Частота downlink в МГц', null=True, verbose_name='Частота Downlink, МГц'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='region',
|
||||
field=models.CharField(blank=True, help_text='Район/местоположение', max_length=255, null=True, verbose_name='Район'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='satellite',
|
||||
field=models.ForeignKey(blank=True, help_text='Связанный спутник', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_requests', to='mainapp.satellite', verbose_name='Спутник'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='transfer',
|
||||
field=models.FloatField(blank=True, help_text='Перенос по частоте в МГц', null=True, verbose_name='Перенос, МГц'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='uplink',
|
||||
field=models.FloatField(blank=True, help_text='Частота uplink в МГц', null=True, verbose_name='Частота Uplink, МГц'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequest',
|
||||
name='coords',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты ГСО (WGS84)', null=True, srid=4326, verbose_name='Координаты ГСО'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequest',
|
||||
name='source',
|
||||
field=models.ForeignKey(blank=True, help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Миграция для изменения модели ObjectMark:
|
||||
- Удаление всех существующих отметок
|
||||
- Удаление поля source
|
||||
- Добавление поля tech_analyze
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def delete_all_marks(apps, schema_editor):
|
||||
"""Удаляем все существующие отметки перед изменением структуры."""
|
||||
ObjectMark = apps.get_model('mainapp', 'ObjectMark')
|
||||
count = ObjectMark.objects.count()
|
||||
ObjectMark.objects.all().delete()
|
||||
print(f"Удалено {count} отметок ObjectMark")
|
||||
|
||||
|
||||
def noop(apps, schema_editor):
|
||||
"""Обратная операция - ничего не делаем."""
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0021_add_source_request_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Сначала удаляем все отметки
|
||||
migrations.RunPython(delete_all_marks, noop),
|
||||
|
||||
# Удаляем старое поле source
|
||||
migrations.RemoveField(
|
||||
model_name='objectmark',
|
||||
name='source',
|
||||
),
|
||||
|
||||
# Добавляем новое поле tech_analyze
|
||||
migrations.AddField(
|
||||
model_name='objectmark',
|
||||
name='tech_analyze',
|
||||
field=models.ForeignKey(
|
||||
help_text='Связанный технический анализ',
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='marks',
|
||||
to='mainapp.techanalyze',
|
||||
verbose_name='Тех. анализ',
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
|
||||
# Обновляем метаданные модели
|
||||
migrations.AlterModelOptions(
|
||||
name='objectmark',
|
||||
options={
|
||||
'ordering': ['-timestamp'],
|
||||
'verbose_name': 'Отметка сигнала',
|
||||
'verbose_name_plural': 'Отметки сигналов'
|
||||
},
|
||||
),
|
||||
|
||||
# Добавляем индекс для оптимизации запросов
|
||||
migrations.AddIndex(
|
||||
model_name='objectmark',
|
||||
index=models.Index(
|
||||
fields=['tech_analyze', '-timestamp'],
|
||||
name='mainapp_obj_tech_an_idx'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-11 12:08
|
||||
|
||||
import django.contrib.gis.db.models.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0022_change_objectmark_to_techanalyze'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name='objectmark',
|
||||
new_name='mainapp_obj_tech_an_b0c804_idx',
|
||||
old_name='mainapp_obj_tech_an_idx',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sourcerequest',
|
||||
name='coords_object',
|
||||
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты объекта (WGS84)', null=True, srid=4326, verbose_name='Координаты объекта'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objectmark',
|
||||
name='mark',
|
||||
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-12-12 12:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('mainapp', '0023_add_coords_object_to_sourcerequest'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='objectmark',
|
||||
name='timestamp',
|
||||
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequest',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequeststatushistory',
|
||||
name='new_status',
|
||||
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='sourcerequeststatushistory',
|
||||
name='old_status',
|
||||
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус'),
|
||||
),
|
||||
]
|
||||
@@ -59,20 +59,16 @@ class CoordinateProcessingMixin:
|
||||
Предоставляет методы для извлечения и обработки координат различных типов
|
||||
(геолокация, кубсат, оперативники) из POST запроса и применения их к объекту Geo.
|
||||
|
||||
Example:
|
||||
class MyFormView(CoordinateProcessingMixin, FormView):
|
||||
def form_valid(self, form):
|
||||
geo_instance = Geo()
|
||||
self.process_coordinates(geo_instance)
|
||||
geo_instance.save()
|
||||
return super().form_valid(form)
|
||||
Note: Координаты Кубсата и оперативников теперь хранятся в модели Source,
|
||||
а не в модели Geo, но для совместимости в форме все еще могут быть поля
|
||||
для этих координат.
|
||||
"""
|
||||
|
||||
def process_coordinates(self, geo_instance, prefix: str = "geo") -> None:
|
||||
"""
|
||||
Обрабатывает координаты из POST данных и применяет их к объекту Geo.
|
||||
|
||||
Извлекает координаты геолокации, кубсата и оперативников из POST запроса
|
||||
Извлекает координаты геолокации из POST запроса
|
||||
и устанавливает соответствующие поля объекта Geo.
|
||||
|
||||
Args:
|
||||
@@ -82,28 +78,12 @@ class CoordinateProcessingMixin:
|
||||
Note:
|
||||
Метод ожидает следующие поля в request.POST:
|
||||
- geo_longitude, geo_latitude: координаты геолокации
|
||||
- kupsat_longitude, kupsat_latitude: координаты кубсата
|
||||
- valid_longitude, valid_latitude: координаты оперативников
|
||||
"""
|
||||
# Обрабатываем координаты геолокации
|
||||
geo_coords = self._extract_coordinates("geo")
|
||||
if geo_coords:
|
||||
geo_instance.coords = Point(geo_coords[0], geo_coords[1], srid=4326)
|
||||
|
||||
# Обрабатываем координаты Кубсата
|
||||
kupsat_coords = self._extract_coordinates("kupsat")
|
||||
if kupsat_coords:
|
||||
geo_instance.coords_kupsat = Point(
|
||||
kupsat_coords[0], kupsat_coords[1], srid=4326
|
||||
)
|
||||
|
||||
# Обрабатываем координаты оперативников
|
||||
valid_coords = self._extract_coordinates("valid")
|
||||
if valid_coords:
|
||||
geo_instance.coords_valid = Point(
|
||||
valid_coords[0], valid_coords[1], srid=4326
|
||||
)
|
||||
|
||||
def _extract_coordinates(self, coord_type: str) -> Optional[Tuple[float, float]]:
|
||||
"""
|
||||
Извлекает координаты указанного типа из POST данных.
|
||||
|
||||
@@ -67,12 +67,49 @@ class CustomUser(models.Model):
|
||||
verbose_name_plural = "Пользователи"
|
||||
ordering = ["user__username"]
|
||||
|
||||
class ObjectInfo(models.Model):
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
verbose_name="Тип объекта",
|
||||
help_text="Информация о типе объекта",
|
||||
)
|
||||
|
||||
class SigmaParMark(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Тип объекта"
|
||||
verbose_name_plural = "Типы объектов"
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
class ObjectOwnership(models.Model):
|
||||
"""
|
||||
Модель принадлежности объекта.
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
verbose_name="Принадлежность",
|
||||
help_text="Принадлежность объекта",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Принадлежность объекта"
|
||||
verbose_name_plural = "Принадлежности объектов"
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
class ObjectMark(models.Model):
|
||||
"""
|
||||
Модель отметки о наличии сигнала.
|
||||
|
||||
Используется для фиксации моментов времени когда сигнал был обнаружен или потерян.
|
||||
Используется для фиксации моментов времени когда сигнал был обнаружен или отсутствовал.
|
||||
Привязывается к записям технического анализа (TechAnalyze).
|
||||
"""
|
||||
|
||||
# Основные поля
|
||||
@@ -83,49 +120,95 @@ class SigmaParMark(models.Model):
|
||||
help_text="True - сигнал обнаружен, False - сигнал отсутствует",
|
||||
)
|
||||
timestamp = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Время",
|
||||
db_index=True,
|
||||
help_text="Время фиксации отметки",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
tech_analyze = models.ForeignKey(
|
||||
'TechAnalyze',
|
||||
on_delete=models.CASCADE,
|
||||
related_name="marks",
|
||||
verbose_name="Тех. анализ",
|
||||
help_text="Связанный технический анализ",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="marks_created",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Создан пользователем",
|
||||
help_text="Пользователь, создавший отметку",
|
||||
)
|
||||
|
||||
def can_edit(self):
|
||||
"""Проверка возможности редактирования отметки (в течение 5 минут)"""
|
||||
from datetime import timedelta
|
||||
if not self.timestamp:
|
||||
return False
|
||||
time_diff = timezone.now() - self.timestamp
|
||||
return time_diff < timedelta(minutes=5)
|
||||
|
||||
def can_add_new_mark_for_object(self):
|
||||
"""Проверка возможности добавления новой отметки для объекта (прошло 5 минут с последней)"""
|
||||
from datetime import timedelta
|
||||
if not self.timestamp:
|
||||
return True
|
||||
time_diff = timezone.now() - self.timestamp
|
||||
return time_diff >= timedelta(minutes=5)
|
||||
|
||||
def __str__(self):
|
||||
if self.timestamp:
|
||||
timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
|
||||
return f"+ {timestamp}" if self.mark else f"- {timestamp}"
|
||||
tech_name = self.tech_analyze.name if self.tech_analyze else "?"
|
||||
mark_str = "+" if self.mark else "-"
|
||||
return f"{tech_name}: {mark_str} {timestamp}"
|
||||
return "Отметка без времени"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Отметка"
|
||||
verbose_name_plural = "Отметки"
|
||||
verbose_name = "Отметка сигнала"
|
||||
verbose_name_plural = "Отметки сигналов"
|
||||
ordering = ["-timestamp"]
|
||||
indexes = [
|
||||
models.Index(fields=["tech_analyze", "-timestamp"]),
|
||||
]
|
||||
|
||||
|
||||
class Mirror(models.Model):
|
||||
"""
|
||||
Модель зеркала антенны.
|
||||
# Для обратной совместимости с SigmaParameter
|
||||
# class SigmaParMark(models.Model):
|
||||
# """
|
||||
# Модель отметки о наличии сигнала (для Sigma).
|
||||
|
||||
Представляет физическое зеркало антенны для приема спутникового сигнала.
|
||||
"""
|
||||
# Используется для фиксации моментов времени когда сигнал был обнаружен или потерян.
|
||||
# """
|
||||
|
||||
# Основные поля
|
||||
name = models.CharField(
|
||||
max_length=30,
|
||||
unique=True,
|
||||
verbose_name="Имя зеркала",
|
||||
db_index=True,
|
||||
help_text="Уникальное название зеркала антенны",
|
||||
)
|
||||
# # Основные поля
|
||||
# mark = models.BooleanField(
|
||||
# null=True,
|
||||
# blank=True,
|
||||
# verbose_name="Наличие сигнала",
|
||||
# help_text="True - сигнал обнаружен, False - сигнал отсутствует",
|
||||
# )
|
||||
# timestamp = models.DateTimeField(
|
||||
# null=True,
|
||||
# blank=True,
|
||||
# verbose_name="Время",
|
||||
# db_index=True,
|
||||
# help_text="Время фиксации отметки",
|
||||
# )
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Зеркало"
|
||||
verbose_name_plural = "Зеркала"
|
||||
ordering = ["name"]
|
||||
# def __str__(self):
|
||||
# if self.timestamp:
|
||||
# timestamp = self.timestamp.strftime("%d.%m.%Y %H:%M")
|
||||
# return f"+ {timestamp}" if self.mark else f"- {timestamp}"
|
||||
# return "Отметка без времени"
|
||||
|
||||
# class Meta:
|
||||
# verbose_name = "Отметка сигнала"
|
||||
# verbose_name_plural = "Отметки сигналов"
|
||||
# ordering = ["-timestamp"]
|
||||
|
||||
class Polarization(models.Model):
|
||||
"""
|
||||
@@ -186,7 +269,7 @@ class Standard(models.Model):
|
||||
|
||||
# Основные поля
|
||||
name = models.CharField(
|
||||
max_length=20,
|
||||
max_length=80,
|
||||
unique=True,
|
||||
verbose_name="Стандарт",
|
||||
db_index=True,
|
||||
@@ -231,7 +314,10 @@ class Satellite(models.Model):
|
||||
|
||||
Представляет спутник связи с его основными характеристиками.
|
||||
"""
|
||||
|
||||
PLACES = [
|
||||
("kr", "КР"),
|
||||
("dv", "ДВ")
|
||||
]
|
||||
# Основные поля
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
@@ -240,12 +326,35 @@ class Satellite(models.Model):
|
||||
db_index=True,
|
||||
help_text="Название спутника",
|
||||
)
|
||||
alternative_name = models.CharField(
|
||||
max_length=100,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Альтернативное имя",
|
||||
db_index=True,
|
||||
help_text="Альтернативное название спутника",
|
||||
)
|
||||
location_place = models.CharField(
|
||||
max_length=30,
|
||||
choices=PLACES,
|
||||
null=True,
|
||||
default="kr",
|
||||
verbose_name="Комплекс",
|
||||
help_text="К какому комплексу принадлежит спутник",
|
||||
)
|
||||
norad = models.IntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="NORAD ID",
|
||||
help_text="Идентификатор NORAD для отслеживания спутника",
|
||||
)
|
||||
international_code = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Международный код",
|
||||
help_text="Международный идентификатор спутника (например, 2011-074A)",
|
||||
)
|
||||
band = models.ManyToManyField(
|
||||
Band,
|
||||
related_name="bands",
|
||||
@@ -364,11 +473,165 @@ class ObjItemManager(models.Manager):
|
||||
return self.get_queryset().by_user(user)
|
||||
|
||||
|
||||
class TechAnalyze(models.Model):
|
||||
"""
|
||||
Модель технического анализа сигнала.
|
||||
|
||||
Хранит информацию о технических параметрах сигнала для анализа.
|
||||
"""
|
||||
|
||||
# Основные поля
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
unique=True,
|
||||
verbose_name="Имя",
|
||||
db_index=True,
|
||||
help_text="Уникальное название для технического анализа",
|
||||
)
|
||||
satellite = models.ForeignKey(
|
||||
Satellite,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="tech_analyzes",
|
||||
verbose_name="Спутник",
|
||||
help_text="Спутник, к которому относится анализ",
|
||||
)
|
||||
polarization = models.ForeignKey(
|
||||
Polarization,
|
||||
default=get_default_polarization,
|
||||
on_delete=models.SET_DEFAULT,
|
||||
related_name="tech_analyze_polarizations",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Поляризация",
|
||||
)
|
||||
frequency = models.FloatField(
|
||||
default=0,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Частота, МГц",
|
||||
db_index=True,
|
||||
help_text="Центральная частота сигнала",
|
||||
)
|
||||
freq_range = models.FloatField(
|
||||
default=0,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Полоса частот, МГц",
|
||||
help_text="Полоса частот сигнала",
|
||||
)
|
||||
bod_velocity = models.FloatField(
|
||||
default=0,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Символьная скорость, БОД",
|
||||
help_text="Символьная скорость",
|
||||
)
|
||||
modulation = models.ForeignKey(
|
||||
Modulation,
|
||||
default=get_default_modulation,
|
||||
on_delete=models.SET_DEFAULT,
|
||||
related_name="tech_analyze_modulations",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Модуляция",
|
||||
)
|
||||
standard = models.ForeignKey(
|
||||
Standard,
|
||||
default=get_default_standard,
|
||||
on_delete=models.SET_DEFAULT,
|
||||
related_name="tech_analyze_standards",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Стандарт",
|
||||
)
|
||||
note = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Примечание",
|
||||
help_text="Дополнительные примечания",
|
||||
)
|
||||
|
||||
# Метаданные
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name="Дата создания",
|
||||
help_text="Дата и время создания записи",
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="tech_analyze_created",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Создан пользователем",
|
||||
help_text="Пользователь, создавший запись",
|
||||
)
|
||||
updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name="Дата последнего изменения",
|
||||
help_text="Дата и время последнего изменения",
|
||||
)
|
||||
updated_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="tech_analyze_updated",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Изменен пользователем",
|
||||
help_text="Пользователь, последним изменивший запись",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.satellite.name if self.satellite else '-'})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Тех. анализ"
|
||||
verbose_name_plural = "Тех. анализы"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
|
||||
class Source(models.Model):
|
||||
"""
|
||||
Модель источника сигнала.
|
||||
"""
|
||||
|
||||
info = models.ForeignKey(
|
||||
ObjectInfo,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="source_info",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Тип объекта",
|
||||
help_text="Тип объекта",
|
||||
)
|
||||
ownership = models.ForeignKey(
|
||||
'ObjectOwnership',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="source_ownership",
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Принадлежность объекта",
|
||||
help_text="Принадлежность объекта (страна, организация и т.д.)",
|
||||
)
|
||||
note = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Примечание",
|
||||
help_text="Дополнительное описание объекта",
|
||||
)
|
||||
confirm_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Дата подтверждения",
|
||||
help_text="Дата и время добавления последней полученной точки ГЛ",
|
||||
)
|
||||
last_signal_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Последний сигнал",
|
||||
help_text="Дата и время последней отметки о наличии сигнала",
|
||||
)
|
||||
|
||||
coords_average = gis.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
@@ -427,6 +690,125 @@ class Source(models.Model):
|
||||
help_text="Пользователь, последним изменивший запись",
|
||||
)
|
||||
|
||||
def update_coords_average(self, new_coord_tuple):
|
||||
"""
|
||||
Обновляет coords_average в зависимости от типа объекта (info).
|
||||
|
||||
Логика:
|
||||
- Если info == "Подвижные": coords_average = последняя добавленная координата
|
||||
- Иначе (Стационарные и др.): coords_average = инкрементальное среднее
|
||||
|
||||
Args:
|
||||
new_coord_tuple: кортеж (longitude, latitude) новой координаты
|
||||
"""
|
||||
from django.contrib.gis.geos import Point
|
||||
from .utils import calculate_mean_coords
|
||||
|
||||
# Если тип объекта "Подвижные" - просто устанавливаем последнюю координату
|
||||
if self.info and self.info.name == "Подвижные":
|
||||
self.coords_average = Point(new_coord_tuple, srid=4326)
|
||||
else:
|
||||
# Для стационарных объектов - вычисляем среднее
|
||||
if self.coords_average:
|
||||
# Есть предыдущее среднее - вычисляем новое среднее
|
||||
current_coord = (self.coords_average.x, self.coords_average.y)
|
||||
new_avg, _ = calculate_mean_coords(current_coord, new_coord_tuple)
|
||||
self.coords_average = Point(new_avg, srid=4326)
|
||||
else:
|
||||
# Первая координата - просто устанавливаем её
|
||||
self.coords_average = Point(new_coord_tuple, srid=4326)
|
||||
|
||||
def get_last_geo_coords(self):
|
||||
"""
|
||||
Получает координаты последней добавленной точки ГЛ для этого источника.
|
||||
Сортировка по ID (последняя добавленная в базу).
|
||||
|
||||
Returns:
|
||||
tuple: (longitude, latitude) или None если точек нет
|
||||
"""
|
||||
# Получаем последний ObjItem для этого Source (по ID)
|
||||
last_objitem = self.source_objitems.filter(
|
||||
geo_obj__coords__isnull=False
|
||||
).select_related('geo_obj').order_by('-id').first()
|
||||
|
||||
if last_objitem and last_objitem.geo_obj and last_objitem.geo_obj.coords:
|
||||
return (last_objitem.geo_obj.coords.x, last_objitem.geo_obj.coords.y)
|
||||
|
||||
return None
|
||||
|
||||
def update_confirm_at(self):
|
||||
"""
|
||||
Обновляет дату confirm_at на дату создания последней добавленной точки ГЛ.
|
||||
"""
|
||||
last_objitem = self.source_objitems.order_by('-created_at').first()
|
||||
if last_objitem:
|
||||
self.confirm_at = last_objitem.created_at
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Переопределенный метод save для автоматического обновления coords_average
|
||||
при изменении типа объекта.
|
||||
"""
|
||||
from django.contrib.gis.geos import Point
|
||||
|
||||
# Проверяем, изменился ли тип объекта
|
||||
if self.pk: # Объект уже существует
|
||||
try:
|
||||
old_instance = Source.objects.get(pk=self.pk)
|
||||
old_info = old_instance.info
|
||||
new_info = self.info
|
||||
|
||||
# Если тип изменился на "Подвижные"
|
||||
if new_info and new_info.name == "Подвижные" and (not old_info or old_info.name != "Подвижные"):
|
||||
# Устанавливаем координату последней точки
|
||||
last_coords = self.get_last_geo_coords()
|
||||
if last_coords:
|
||||
self.coords_average = Point(last_coords, srid=4326)
|
||||
|
||||
# Если тип изменился с "Подвижные" на что-то другое
|
||||
elif old_info and old_info.name == "Подвижные" and (not new_info or new_info.name != "Подвижные"):
|
||||
# Пересчитываем среднюю координату по всем точкам
|
||||
self._recalculate_average_coords()
|
||||
|
||||
except Source.DoesNotExist:
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def _recalculate_average_coords(self):
|
||||
"""
|
||||
Пересчитывает среднюю координату по всем точкам источника.
|
||||
Используется при переключении с "Подвижные" на "Стационарные".
|
||||
|
||||
Сортировка по ID (порядок добавления в базу), инкрементальное усреднение
|
||||
как в функциях импорта.
|
||||
"""
|
||||
from django.contrib.gis.geos import Point
|
||||
from .utils import calculate_mean_coords
|
||||
|
||||
# Получаем все точки для этого источника, сортируем по ID (порядок добавления)
|
||||
objitems = self.source_objitems.filter(
|
||||
geo_obj__coords__isnull=False
|
||||
).select_related('geo_obj').order_by('id')
|
||||
|
||||
if not objitems.exists():
|
||||
return
|
||||
|
||||
# Вычисляем среднюю координату инкрементально (как в функциях импорта)
|
||||
coords_average = None
|
||||
for objitem in objitems:
|
||||
if objitem.geo_obj and objitem.geo_obj.coords:
|
||||
coord = (objitem.geo_obj.coords.x, objitem.geo_obj.coords.y)
|
||||
if coords_average is None:
|
||||
# Первая точка - просто устанавливаем её
|
||||
coords_average = coord
|
||||
else:
|
||||
# Последующие точки - вычисляем среднее между текущим средним и новой точкой
|
||||
coords_average, _ = calculate_mean_coords(coords_average, coord)
|
||||
|
||||
if coords_average:
|
||||
self.coords_average = Point(coords_average, srid=4326)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Источник"
|
||||
verbose_name_plural = "Источники"
|
||||
@@ -464,6 +846,12 @@ class ObjItem(models.Model):
|
||||
verbose_name="Транспондер",
|
||||
help_text="Транспондер, с помощью которого была получена точка",
|
||||
)
|
||||
is_automatic = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Автоматическая",
|
||||
db_index=True,
|
||||
help_text="Если True, точка не добавляется к объектам (Source), а хранится отдельно",
|
||||
)
|
||||
|
||||
# Метаданные
|
||||
created_at = models.DateTimeField(
|
||||
@@ -763,7 +1151,7 @@ class SigmaParameter(models.Model):
|
||||
verbose_name="Время окончания измерения",
|
||||
help_text="Дата и время окончания измерения",
|
||||
)
|
||||
mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True)
|
||||
# mark = models.ManyToManyField(SigmaParMark, verbose_name="Отметка", blank=True)
|
||||
parameter = models.ForeignKey(
|
||||
Parameter,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -800,6 +1188,299 @@ class SigmaParameter(models.Model):
|
||||
verbose_name_plural = "ВЧ sigma"
|
||||
|
||||
|
||||
class SourceRequest(models.Model):
|
||||
"""
|
||||
Модель заявки на источник.
|
||||
|
||||
Хранит информацию о заявках на обработку источников с различными статусами.
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('planned', 'Запланировано'),
|
||||
('canceled_gso', 'Отменено ГСО'),
|
||||
('canceled_kub', 'Отменено МКА'),
|
||||
('conducted', 'Проведён'),
|
||||
('successful', 'Успешно'),
|
||||
('no_correlation', 'Нет корреляции'),
|
||||
('no_signal', 'Нет сигнала в спектре'),
|
||||
('unsuccessful', 'Неуспешно'),
|
||||
('downloading', 'Скачивание'),
|
||||
('processing', 'Обработка'),
|
||||
('result_received', 'Результат получен'),
|
||||
]
|
||||
|
||||
PRIORITY_CHOICES = [
|
||||
('low', 'Низкий'),
|
||||
('medium', 'Средний'),
|
||||
('high', 'Высокий'),
|
||||
]
|
||||
|
||||
# Связь с источником (опционально для заявок без привязки)
|
||||
source = models.ForeignKey(
|
||||
Source,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='source_requests',
|
||||
verbose_name='Источник',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Связанный источник',
|
||||
)
|
||||
|
||||
# Связь со спутником
|
||||
satellite = models.ForeignKey(
|
||||
Satellite,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='satellite_requests',
|
||||
verbose_name='Спутник',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text='Связанный спутник',
|
||||
)
|
||||
|
||||
# Основные поля
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=STATUS_CHOICES,
|
||||
default='planned',
|
||||
verbose_name='Статус',
|
||||
db_index=True,
|
||||
help_text='Текущий статус заявки',
|
||||
)
|
||||
priority = models.CharField(
|
||||
max_length=10,
|
||||
choices=PRIORITY_CHOICES,
|
||||
default='medium',
|
||||
verbose_name='Приоритет',
|
||||
db_index=True,
|
||||
help_text='Приоритет заявки',
|
||||
)
|
||||
|
||||
# Даты
|
||||
planned_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата и время планирования',
|
||||
help_text='Запланированная дата и время',
|
||||
)
|
||||
request_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата заявки',
|
||||
help_text='Дата подачи заявки',
|
||||
)
|
||||
card_date = models.DateField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Дата формирования карточки',
|
||||
help_text='Дата формирования карточки',
|
||||
)
|
||||
status_updated_at = models.DateTimeField(
|
||||
auto_now=True,
|
||||
verbose_name='Дата обновления статуса',
|
||||
help_text='Дата и время последнего обновления статуса',
|
||||
)
|
||||
|
||||
# Частоты и перенос
|
||||
downlink = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Частота Downlink, МГц',
|
||||
help_text='Частота downlink в МГц',
|
||||
)
|
||||
uplink = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Частота Uplink, МГц',
|
||||
help_text='Частота uplink в МГц',
|
||||
)
|
||||
transfer = models.FloatField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Перенос, МГц',
|
||||
help_text='Перенос по частоте в МГц',
|
||||
)
|
||||
|
||||
# Результаты
|
||||
gso_success = models.BooleanField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='ГСО успешно?',
|
||||
help_text='Успешность ГСО',
|
||||
)
|
||||
kubsat_success = models.BooleanField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Кубсат успешно?',
|
||||
help_text='Успешность Кубсат',
|
||||
)
|
||||
|
||||
# Район
|
||||
region = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Район',
|
||||
help_text='Район/местоположение',
|
||||
)
|
||||
|
||||
# Комментарий
|
||||
comment = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Комментарий',
|
||||
help_text='Дополнительные комментарии к заявке',
|
||||
)
|
||||
|
||||
# Координаты ГСО (усреднённые по выбранным точкам)
|
||||
coords = gis.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Координаты ГСО',
|
||||
help_text='Координаты ГСО (WGS84)',
|
||||
)
|
||||
|
||||
# Координаты источника
|
||||
coords_source = gis.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Координаты источника',
|
||||
help_text='Координаты источника (WGS84)',
|
||||
)
|
||||
|
||||
# Координаты объекта
|
||||
coords_object = gis.PointField(
|
||||
srid=4326,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Координаты объекта',
|
||||
help_text='Координаты объекта (WGS84)',
|
||||
)
|
||||
|
||||
# Количество точек, использованных для расчёта координат
|
||||
points_count = models.PositiveIntegerField(
|
||||
default=0,
|
||||
verbose_name='Количество точек',
|
||||
help_text='Количество точек ГЛ, использованных для расчёта координат',
|
||||
)
|
||||
|
||||
# Метаданные
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата создания',
|
||||
help_text='Дата и время создания записи',
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='source_requests_created',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Создан пользователем',
|
||||
help_text='Пользователь, создавший запись',
|
||||
)
|
||||
updated_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='source_requests_updated',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Изменен пользователем',
|
||||
help_text='Пользователь, последним изменивший запись',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"Заявка #{self.pk} - {self.source_id} ({self.get_status_display()})"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Определяем, изменился ли статус
|
||||
old_status = None
|
||||
if self.pk:
|
||||
try:
|
||||
old_instance = SourceRequest.objects.get(pk=self.pk)
|
||||
old_status = old_instance.status
|
||||
except SourceRequest.DoesNotExist:
|
||||
pass
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Если статус изменился, создаем запись в истории
|
||||
if old_status is not None and old_status != self.status:
|
||||
SourceRequestStatusHistory.objects.create(
|
||||
source_request=self,
|
||||
old_status=old_status,
|
||||
new_status=self.status,
|
||||
changed_by=self.updated_by,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Заявка на источник'
|
||||
verbose_name_plural = 'Заявки на источники'
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['-created_at']),
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['priority']),
|
||||
models.Index(fields=['source', '-created_at']),
|
||||
]
|
||||
|
||||
|
||||
class SourceRequestStatusHistory(models.Model):
|
||||
"""
|
||||
Модель истории изменений статусов заявок.
|
||||
|
||||
Хранит полную хронологию изменений статусов заявок.
|
||||
"""
|
||||
|
||||
source_request = models.ForeignKey(
|
||||
SourceRequest,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='status_history',
|
||||
verbose_name='Заявка',
|
||||
help_text='Связанная заявка',
|
||||
)
|
||||
old_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=SourceRequest.STATUS_CHOICES,
|
||||
verbose_name='Старый статус',
|
||||
help_text='Статус до изменения',
|
||||
)
|
||||
new_status = models.CharField(
|
||||
max_length=20,
|
||||
choices=SourceRequest.STATUS_CHOICES,
|
||||
verbose_name='Новый статус',
|
||||
help_text='Статус после изменения',
|
||||
)
|
||||
changed_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
verbose_name='Дата изменения',
|
||||
db_index=True,
|
||||
help_text='Дата и время изменения статуса',
|
||||
)
|
||||
changed_by = models.ForeignKey(
|
||||
CustomUser,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='status_changes',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Изменен пользователем',
|
||||
help_text='Пользователь, изменивший статус',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.source_request_id}: {self.get_old_status_display()} → {self.get_new_status_display()}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'История статуса заявки'
|
||||
verbose_name_plural = 'История статусов заявок'
|
||||
ordering = ['-changed_at']
|
||||
indexes = [
|
||||
models.Index(fields=['-changed_at']),
|
||||
models.Index(fields=['source_request', '-changed_at']),
|
||||
]
|
||||
|
||||
|
||||
class Geo(models.Model):
|
||||
"""
|
||||
Модель геолокационных данных.
|
||||
@@ -873,11 +1554,11 @@ class Geo(models.Model):
|
||||
|
||||
# Связи
|
||||
mirrors = models.ManyToManyField(
|
||||
Mirror,
|
||||
Satellite,
|
||||
related_name="geo_mirrors",
|
||||
verbose_name="Зеркала",
|
||||
blank=True,
|
||||
help_text="Зеркала антенн, использованные для приема",
|
||||
help_text="Спутники-зеркала, использованные для приема",
|
||||
)
|
||||
objitem = models.OneToOneField(
|
||||
ObjItem,
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
# Django imports
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
# from django.contrib.auth.models import User
|
||||
# from django.db.models.signals import post_save
|
||||
# from django.dispatch import receiver
|
||||
|
||||
# Local imports
|
||||
from .models import CustomUser
|
||||
# # Local imports
|
||||
# from .models import CustomUser
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_or_update_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
CustomUser.objects.create(user=instance)
|
||||
instance.customuser.save()
|
||||
# @receiver(post_save, sender=User)
|
||||
# def create_or_update_user_profile(sender, instance, created, **kwargs):
|
||||
# if created:
|
||||
# CustomUser.objects.get_or_create(user=instance)
|
||||
# else:
|
||||
# # Only save if customuser exists (avoid error if it doesn't)
|
||||
# if hasattr(instance, 'customuser'):
|
||||
# instance.customuser.save()
|
||||
161
dbapp/mainapp/static/css/checkbox-select-multiple.css
Normal file
161
dbapp/mainapp/static/css/checkbox-select-multiple.css
Normal file
@@ -0,0 +1,161 @@
|
||||
.checkbox-multiselect-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.multiselect-input-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
min-height: 38px;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
background-color: #fff;
|
||||
cursor: text;
|
||||
padding: 4px 30px 4px 4px;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.multiselect-input-container:focus-within {
|
||||
border-color: #86b7fe;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.multiselect-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
flex: 1 1 auto;
|
||||
max-width: calc(100% - 150px);
|
||||
}
|
||||
|
||||
.multiselect-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: #e9ecef;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.multiselect-tag-remove {
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
color: #6c757d;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.multiselect-tag-remove:hover {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.multiselect-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 120px;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.multiselect-search:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.multiselect-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: #6c757d;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect-clear:hover {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.multiselect-input-container.has-selections .multiselect-clear {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.multiselect-dropdown {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Открытие вверх (по умолчанию) */
|
||||
.multiselect-dropdown {
|
||||
bottom: 100%;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Открытие вниз (если места сверху недостаточно) */
|
||||
.multiselect-dropdown.dropdown-below {
|
||||
bottom: auto;
|
||||
top: 100%;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.multiselect-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.multiselect-options {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.multiselect-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.multiselect-option:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.multiselect-option input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.multiselect-option .option-label {
|
||||
flex: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.multiselect-option.hidden {
|
||||
display: none;
|
||||
}
|
||||
148
dbapp/mainapp/static/js/SORTING_README.md
Normal file
148
dbapp/mainapp/static/js/SORTING_README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Sorting Functionality Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the centralized sorting functionality implemented for table columns across the Django application.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Created Files:
|
||||
1. **`dbapp/mainapp/static/js/sorting.js`** - Main sorting JavaScript library
|
||||
2. **`dbapp/mainapp/static/js/sorting-test.html`** - Test page for manual verification
|
||||
|
||||
### Modified Files:
|
||||
1. **`dbapp/mainapp/templates/mainapp/base.html`** - Added sorting.js script include
|
||||
2. **`dbapp/mainapp/templates/mainapp/components/_sort_header.html`** - Removed inline script, added data attributes
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Sort Toggle Logic
|
||||
- **First click**: Sort ascending (field)
|
||||
- **Second click**: Sort descending (-field)
|
||||
- **Third click**: Sort ascending again (cycles back)
|
||||
|
||||
### 2. URL Parameter Management
|
||||
- Preserves all existing GET parameters (search, filters, etc.)
|
||||
- Automatically resets page number to 1 when sorting changes
|
||||
- Updates the `sort` parameter in the URL
|
||||
|
||||
### 3. Visual Indicators
|
||||
- Shows up arrow (↑) for ascending sort
|
||||
- Shows down arrow (↓) for descending sort
|
||||
- Automatically initializes indicators on page load
|
||||
- Adds `sort-active` class to currently sorted column
|
||||
|
||||
## Usage
|
||||
|
||||
### In Templates
|
||||
|
||||
Use the `_sort_header.html` component in your table headers:
|
||||
|
||||
```django
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th>{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}</th>
|
||||
<th>{% include 'mainapp/components/_sort_header.html' with field='name' label='Название' current_sort=sort %}</th>
|
||||
<th>{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Дата создания' current_sort=sort %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
```
|
||||
|
||||
### In Views
|
||||
|
||||
Pass the current sort parameter to the template context:
|
||||
|
||||
```python
|
||||
def get(self, request):
|
||||
sort = request.GET.get('sort', '-id') # Default sort
|
||||
|
||||
# Validate allowed sorts
|
||||
allowed_sorts = ['id', '-id', 'name', '-name', 'created_at', '-created_at']
|
||||
if sort not in allowed_sorts:
|
||||
sort = '-id'
|
||||
|
||||
# Apply sorting
|
||||
queryset = Model.objects.all().order_by(sort)
|
||||
|
||||
context = {
|
||||
'sort': sort,
|
||||
'objects': queryset,
|
||||
# ... other context
|
||||
}
|
||||
return render(request, 'template.html', context)
|
||||
```
|
||||
|
||||
## JavaScript API
|
||||
|
||||
### Functions
|
||||
|
||||
#### `updateSort(field)`
|
||||
Updates the sort parameter and reloads the page.
|
||||
|
||||
**Parameters:**
|
||||
- `field` (string): The field name to sort by
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
updateSort('created_at'); // Sort by created_at ascending
|
||||
```
|
||||
|
||||
#### `getCurrentSort()`
|
||||
Gets the current sort field and direction from URL.
|
||||
|
||||
**Returns:**
|
||||
- Object with `field` and `direction` properties
|
||||
- `direction` can be 'asc', 'desc', or null
|
||||
|
||||
**Example:**
|
||||
```javascript
|
||||
const sort = getCurrentSort();
|
||||
console.log(sort.field); // 'created_at'
|
||||
console.log(sort.direction); // 'asc' or 'desc'
|
||||
```
|
||||
|
||||
#### `initializeSortIndicators()`
|
||||
Automatically called on page load to show current sort state.
|
||||
|
||||
## Requirements Satisfied
|
||||
|
||||
This implementation satisfies the following requirements from the specification:
|
||||
|
||||
- **5.1**: Supports ascending and descending order for sortable columns
|
||||
- **5.2**: Toggles between ascending, descending when clicking column headers
|
||||
- **5.3**: Displays visual indicators (arrow icons) showing sort direction
|
||||
- **5.5**: Preserves sort state in URL parameters during navigation
|
||||
- **5.6**: Preserves other active filters and resets pagination when sorting
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. Open `dbapp/mainapp/static/js/sorting-test.html` in a browser
|
||||
2. Click column headers to test sorting
|
||||
3. Verify URL updates correctly
|
||||
4. Add query parameters (e.g., ?page=5&search=test) and verify they're preserved
|
||||
|
||||
### Integration Testing
|
||||
|
||||
Test in actual Django views:
|
||||
1. Navigate to any list view (sources, objitems, transponders)
|
||||
2. Click column headers to sort
|
||||
3. Verify data is sorted correctly
|
||||
4. Apply filters and verify they're preserved when sorting
|
||||
5. Navigate to page 2+, then sort - verify it resets to page 1
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- Modern browsers supporting ES6 (URLSearchParams)
|
||||
- Chrome 49+
|
||||
- Firefox 44+
|
||||
- Safari 10.1+
|
||||
- Edge 17+
|
||||
|
||||
## Notes
|
||||
|
||||
- The sorting.js file is loaded with `defer` attribute for better performance
|
||||
- All GET parameters are preserved except `page` which is reset to 1
|
||||
- The function is globally available and can be called from any template
|
||||
- Sort indicators are automatically initialized on page load
|
||||
120
dbapp/mainapp/static/js/checkbox-select-multiple.js
Normal file
120
dbapp/mainapp/static/js/checkbox-select-multiple.js
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Checkbox Select Multiple Widget
|
||||
* Provides a multi-select dropdown with checkboxes and tag display
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize all checkbox multiselect widgets
|
||||
document.querySelectorAll('.checkbox-multiselect-wrapper').forEach(function(wrapper) {
|
||||
initCheckboxMultiselect(wrapper);
|
||||
});
|
||||
});
|
||||
|
||||
function initCheckboxMultiselect(wrapper) {
|
||||
const widgetId = wrapper.dataset.widgetId;
|
||||
const inputContainer = wrapper.querySelector('.multiselect-input-container');
|
||||
const searchInput = wrapper.querySelector('.multiselect-search');
|
||||
const dropdown = wrapper.querySelector('.multiselect-dropdown');
|
||||
const tagsContainer = wrapper.querySelector('.multiselect-tags');
|
||||
const clearButton = wrapper.querySelector('.multiselect-clear');
|
||||
const checkboxes = wrapper.querySelectorAll('input[type="checkbox"]');
|
||||
|
||||
// Show dropdown when clicking on input container
|
||||
inputContainer.addEventListener('click', function(e) {
|
||||
if (e.target !== clearButton) {
|
||||
positionDropdown();
|
||||
dropdown.classList.add('show');
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Position dropdown (up or down based on available space)
|
||||
function positionDropdown() {
|
||||
const rect = inputContainer.getBoundingClientRect();
|
||||
const spaceAbove = rect.top;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const dropdownHeight = 300; // max-height from CSS
|
||||
|
||||
// If more space below and enough space, open downward
|
||||
if (spaceBelow > spaceAbove && spaceBelow >= dropdownHeight) {
|
||||
dropdown.classList.add('dropdown-below');
|
||||
} else {
|
||||
dropdown.classList.remove('dropdown-below');
|
||||
}
|
||||
}
|
||||
|
||||
// Hide dropdown when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!wrapper.contains(e.target)) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Search functionality
|
||||
searchInput.addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
const options = wrapper.querySelectorAll('.multiselect-option');
|
||||
|
||||
options.forEach(function(option) {
|
||||
const label = option.querySelector('.option-label').textContent.toLowerCase();
|
||||
if (label.includes(searchTerm)) {
|
||||
option.classList.remove('hidden');
|
||||
} else {
|
||||
option.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle checkbox changes
|
||||
checkboxes.forEach(function(checkbox) {
|
||||
checkbox.addEventListener('change', function() {
|
||||
updateTags();
|
||||
});
|
||||
});
|
||||
|
||||
// Clear all button
|
||||
clearButton.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
checkboxes.forEach(function(checkbox) {
|
||||
checkbox.checked = false;
|
||||
});
|
||||
updateTags();
|
||||
});
|
||||
|
||||
// Update tags display
|
||||
function updateTags() {
|
||||
tagsContainer.innerHTML = '';
|
||||
let hasSelections = false;
|
||||
|
||||
checkboxes.forEach(function(checkbox) {
|
||||
if (checkbox.checked) {
|
||||
hasSelections = true;
|
||||
const tag = document.createElement('div');
|
||||
tag.className = 'multiselect-tag';
|
||||
tag.innerHTML = `
|
||||
<span>${checkbox.dataset.label}</span>
|
||||
<button type="button" class="multiselect-tag-remove" data-value="${checkbox.value}">×</button>
|
||||
`;
|
||||
|
||||
// Remove tag on click
|
||||
tag.querySelector('.multiselect-tag-remove').addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
checkbox.checked = false;
|
||||
updateTags();
|
||||
});
|
||||
|
||||
tagsContainer.appendChild(tag);
|
||||
}
|
||||
});
|
||||
|
||||
// Show/hide clear button
|
||||
if (hasSelections) {
|
||||
inputContainer.classList.add('has-selections');
|
||||
} else {
|
||||
inputContainer.classList.remove('has-selections');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize tags on load
|
||||
updateTags();
|
||||
}
|
||||
91
dbapp/mainapp/static/js/sorting-test.html
Normal file
91
dbapp/mainapp/static/js/sorting-test.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sorting Test</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<h1>Sorting Functionality Test</h1>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<strong>Current URL:</strong> <span id="currentUrl"></span>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>
|
||||
<a href="javascript:void(0)"
|
||||
onclick="updateSort('id')"
|
||||
class="text-white text-decoration-none"
|
||||
data-sort-field="id">
|
||||
ID
|
||||
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="javascript:void(0)"
|
||||
onclick="updateSort('name')"
|
||||
class="text-white text-decoration-none"
|
||||
data-sort-field="name">
|
||||
Name
|
||||
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="javascript:void(0)"
|
||||
onclick="updateSort('created_at')"
|
||||
class="text-white text-decoration-none"
|
||||
data-sort-field="created_at">
|
||||
Created At
|
||||
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>Test Item 1</td>
|
||||
<td>2024-01-01</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>Test Item 2</td>
|
||||
<td>2024-01-02</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5>Test Instructions:</h5>
|
||||
<ol>
|
||||
<li>Click on any column header (ID, Name, or Created At)</li>
|
||||
<li>The URL should update with ?sort=field_name</li>
|
||||
<li>Click again to toggle to descending (?sort=-field_name)</li>
|
||||
<li>Click a third time to toggle back to ascending</li>
|
||||
<li>Add ?page=5 to the URL and click a header - page should reset to 1</li>
|
||||
<li>Add ?search=test to the URL and click a header - search should be preserved</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="sorting.js"></script>
|
||||
<script>
|
||||
// Display current URL
|
||||
function updateUrlDisplay() {
|
||||
document.getElementById('currentUrl').textContent = window.location.href;
|
||||
}
|
||||
updateUrlDisplay();
|
||||
|
||||
// Update URL display on page load
|
||||
window.addEventListener('load', updateUrlDisplay);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
106
dbapp/mainapp/static/js/sorting.js
Normal file
106
dbapp/mainapp/static/js/sorting.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Sorting functionality for table columns
|
||||
* Handles toggling between ascending, descending, and no sort
|
||||
* Preserves other GET parameters and resets pagination
|
||||
*/
|
||||
|
||||
/**
|
||||
* Updates the sort parameter in the URL and reloads the page
|
||||
* @param {string} field - The field name to sort by
|
||||
*/
|
||||
function updateSort(field) {
|
||||
// Get current URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const currentSort = urlParams.get('sort');
|
||||
|
||||
let newSort;
|
||||
|
||||
// Toggle sort direction logic:
|
||||
// 1. If not sorted by this field -> sort ascending (field)
|
||||
// 2. If sorted ascending -> sort descending (-field)
|
||||
// 3. If sorted descending -> sort ascending (field)
|
||||
if (currentSort === field) {
|
||||
// Currently ascending, switch to descending
|
||||
newSort = '-' + field;
|
||||
} else if (currentSort === '-' + field) {
|
||||
// Currently descending, switch to ascending
|
||||
newSort = field;
|
||||
} else {
|
||||
// Not sorted by this field, start with ascending
|
||||
newSort = field;
|
||||
}
|
||||
|
||||
// Update sort parameter
|
||||
urlParams.set('sort', newSort);
|
||||
|
||||
// Reset to first page when sorting changes
|
||||
urlParams.delete('page');
|
||||
|
||||
// Reload page with new parameters
|
||||
window.location.search = urlParams.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current sort field and direction
|
||||
* @returns {Object} Object with field and direction properties
|
||||
*/
|
||||
function getCurrentSort() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const sort = urlParams.get('sort');
|
||||
|
||||
if (!sort) {
|
||||
return { field: null, direction: null };
|
||||
}
|
||||
|
||||
if (sort.startsWith('-')) {
|
||||
return {
|
||||
field: sort.substring(1),
|
||||
direction: 'desc'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
field: sort,
|
||||
direction: 'asc'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes sort indicators on page load
|
||||
* Adds visual indicators to show current sort state
|
||||
*/
|
||||
function initializeSortIndicators() {
|
||||
const currentSort = getCurrentSort();
|
||||
|
||||
if (!currentSort.field) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all sort headers and update their indicators
|
||||
const sortHeaders = document.querySelectorAll('[data-sort-field]');
|
||||
sortHeaders.forEach(header => {
|
||||
const field = header.getAttribute('data-sort-field');
|
||||
|
||||
if (field === currentSort.field) {
|
||||
// Add active class or update icon
|
||||
header.classList.add('sort-active');
|
||||
|
||||
// Update icon if present
|
||||
const icon = header.querySelector('.sort-icon');
|
||||
if (icon) {
|
||||
if (currentSort.direction === 'asc') {
|
||||
icon.className = 'bi bi-arrow-up sort-icon';
|
||||
} else {
|
||||
icon.className = 'bi bi-arrow-down sort-icon';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize sort indicators when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeSortIndicators);
|
||||
} else {
|
||||
initializeSortIndicators();
|
||||
}
|
||||
@@ -9,9 +9,6 @@
|
||||
<p class="lead">Управление данными спутников</p>
|
||||
</div>
|
||||
|
||||
<!-- Alert messages -->
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
<!-- Main feature cards -->
|
||||
<div class="row g-4">
|
||||
<!-- Excel Data Upload Card -->
|
||||
@@ -82,26 +79,7 @@
|
||||
</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">Добавьте список транспондеров в базу данных.</p>
|
||||
<a href="{% url 'mainapp:add_trans' %}" class="btn btn-warning">
|
||||
Добавить транспондеры
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- VCH Load Data Card -->
|
||||
<div class="col-lg-6">
|
||||
@@ -205,6 +183,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unlink All LyngSat Sources Card -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100 shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<div class="bg-warning bg-opacity-10 rounded-circle p-2 me-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-unlink text-warning" viewBox="0 0 16 16">
|
||||
<path d="M6.354 5.5H4a3 3 0 0 0 0 6h3a3 3 0 0 0 2.83-4H9q-.13 0-.25.031A2 2 0 0 1 7 10.5H4a2 2 0 1 1 0-4h1.535c.218-.376.495-.714.82-1z"/>
|
||||
<path d="M9 5.5a3 3 0 0 0-2.83 4h1.098A2 2 0 0 1 9 6.5h3a2 2 0 1 1 0 4h-1.535a4 4 0 0 1-.82 1H12a3 3 0 1 0 0-6z"/>
|
||||
<path d="M1 1l14 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="card-title mb-0">Отвязка всех источников LyngSat</h3>
|
||||
</div>
|
||||
<p class="card-text">Отвязать все источники LyngSat от объектов. Все объекты перестанут отображаться как "ТВ" источники. Операция обратима через повторную привязку.</p>
|
||||
<a href="{% url 'mainapp:unlink_all_lyngsat' %}" class="btn btn-warning">
|
||||
Отвязать все источники
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -11,8 +11,6 @@
|
||||
<h2 class="mb-0">Загрузка данных из CSV</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
<p class="card-text">Загрузите CSV-файл для загрузки данных в базу.</p>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
@@ -21,8 +19,21 @@
|
||||
<!-- Form fields with Bootstrap styling -->
|
||||
{% include 'mainapp/components/_form_field.html' with field=form.file %}
|
||||
|
||||
<!-- Automatic checkbox -->
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
{{ form.is_automatic }}
|
||||
<label class="form-check-label" for="{{ form.is_automatic.id_for_label }}">
|
||||
{{ form.is_automatic.label }}
|
||||
</label>
|
||||
{% if form.is_automatic.help_text %}
|
||||
<div class="form-text">{{ form.is_automatic.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||
<button type="submit" class="btn btn-success">Добавить в базу</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<h2 class="mb-0">Загрузка данных из Excel</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
<p class="card-text">Загрузите Excel-файл и выберите спутник для загрузки данных в базу.</p>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
@@ -23,8 +21,21 @@
|
||||
{% include 'mainapp/components/_form_field.html' with field=form.sat_choice %}
|
||||
{% include 'mainapp/components/_form_field.html' with field=form.number_input %}
|
||||
|
||||
<!-- Automatic checkbox -->
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
{{ form.is_automatic }}
|
||||
<label class="form-check-label" for="{{ form.is_automatic.id_for_label }}">
|
||||
{{ form.is_automatic.label }}
|
||||
</label>
|
||||
{% if form.is_automatic.help_text %}
|
||||
<div class="form-text">{{ form.is_automatic.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||
<button type="submit" class="btn btn-primary">Добавить в базу</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}" defer></script>
|
||||
|
||||
<!-- Common sorting functionality -->
|
||||
<script src="{% static 'js/sorting.js' %}" defer></script>
|
||||
|
||||
<!-- Дополнительные скрипты -->
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{% comment %}
|
||||
Компонент для элемента переключения видимости столбца
|
||||
Использование:
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=0 column_label="Выбрать" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=1 column_label="Имя" checked=True %}
|
||||
{% endcomment %}
|
||||
|
||||
<li>
|
||||
<label class="dropdown-item">
|
||||
<input type="checkbox" class="column-toggle" data-column="{{ column_index }}" {% if checked %}checked{% endif %}
|
||||
onchange="toggleColumn(this)"> {{ column_label }}
|
||||
</label>
|
||||
</li>
|
||||
@@ -0,0 +1,45 @@
|
||||
{% comment %}
|
||||
Компонент для выпадающего списка видимости столбцов
|
||||
Использование:
|
||||
{% include 'mainapp/components/_column_visibility_dropdown.html' %}
|
||||
{% endcomment %}
|
||||
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle"
|
||||
id="columnVisibilityDropdown" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="bi bi-gear"></i> Колонки
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="columnVisibilityDropdown" style="z-index: 1050; max-height: 300px; overflow-y: auto;">
|
||||
<li>
|
||||
<label class="dropdown-item">
|
||||
<input type="checkbox" id="select-all-columns" unchecked
|
||||
onchange="toggleAllColumns(this)"> Выбрать всё
|
||||
</label>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=0 column_label="Выбрать" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=1 column_label="Имя" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=2 column_label="Спутник" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=3 column_label="Транспондер" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=4 column_label="Част, МГц" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=5 column_label="Полоса, МГц" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=6 column_label="Поляризация" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=7 column_label="Сим. V" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=8 column_label="Модул" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=9 column_label="ОСШ" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=10 column_label="Время ГЛ" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=11 column_label="Местоположение" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=12 column_label="Геолокация" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=13 column_label="Обновлено" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=14 column_label="Кем (обновление)" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=15 column_label="Создано" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=16 column_label="Кем (создание)" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=17 column_label="Комментарий" checked=False %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=18 column_label="Усреднённое" checked=False %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=19 column_label="Стандарт" checked=False %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=20 column_label="Тип источника" checked=True %}
|
||||
{% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Зеркала" checked=True %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
{% comment %}
|
||||
Переиспользуемый компонент панели фильтров (Offcanvas)
|
||||
Параметры:
|
||||
- filters: список HTML-кода фильтров для отображения (опционально)
|
||||
- filter_form: объект формы Django для фильтров (опционально)
|
||||
- reset_url: URL для сброса фильтров (по умолчанию: текущая страница без параметров)
|
||||
|
||||
Использование:
|
||||
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list %}
|
||||
{% include 'mainapp/components/_filter_panel.html' with filter_form=form %}
|
||||
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list reset_url='/sources/' %}
|
||||
|
||||
Примечание:
|
||||
- Можно передать либо список HTML-кода фильтров через 'filters', либо форму Django через 'filter_form'
|
||||
- Форма отправляется методом GET для сохранения параметров в URL
|
||||
- Кнопка "Сбросить" очищает все параметры фильтрации
|
||||
{% endcomment %}
|
||||
|
||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<form method="get" id="filter-form">
|
||||
{% if filter_form %}
|
||||
{# Если передана форма Django, отображаем её поля #}
|
||||
{% for field in filter_form %}
|
||||
<div class="mb-3">
|
||||
<label for="{{ field.id_for_label }}" class="form-label">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<div class="form-text">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif filters %}
|
||||
{# Если переданы готовые HTML-блоки фильтров #}
|
||||
{% for filter in filters %}
|
||||
{{ filter|safe }}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{# Сохраняем параметры сортировки и поиска при применении фильтров #}
|
||||
{% if request.GET.sort %}
|
||||
<input type="hidden" name="sort" value="{{ request.GET.sort }}">
|
||||
{% endif %}
|
||||
{% if request.GET.search %}
|
||||
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||
{% endif %}
|
||||
{% if request.GET.items_per_page %}
|
||||
<input type="hidden" name="items_per_page" value="{{ request.GET.items_per_page }}">
|
||||
{% endif %}
|
||||
|
||||
<div class="d-grid gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
Применить
|
||||
</button>
|
||||
<a href="{{ reset_url|default:'?' }}" class="btn btn-secondary btn-sm">
|
||||
Сбросить
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update filter counter badge when filters are active
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filterCounter = document.getElementById('filterCounter');
|
||||
|
||||
if (filterCounter) {
|
||||
// Count active filters (excluding pagination, sort, search, and items_per_page)
|
||||
const excludedParams = ['page', 'sort', 'search', 'items_per_page'];
|
||||
let activeFilters = 0;
|
||||
|
||||
for (const [key, value] of urlParams.entries()) {
|
||||
if (!excludedParams.includes(key) && value) {
|
||||
activeFilters++;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeFilters > 0) {
|
||||
filterCounter.textContent = activeFilters;
|
||||
filterCounter.style.display = 'inline-block';
|
||||
} else {
|
||||
filterCounter.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,71 @@
|
||||
<!-- Frequency Plan Modal -->
|
||||
<div class="modal fade" id="frequencyPlanModal" tabindex="-1" aria-labelledby="frequencyPlanModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="frequencyPlanModalLabel">Частотный план</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="modalLoadingSpinner" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modalFrequencyContent" style="display: none;">
|
||||
<p class="text-muted">Визуализация транспондеров спутника по частотам. <span style="color: #0d6efd;">■</span> Downlink (синий), <span style="color: #fd7e14;">■</span> Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации и связи с парным каналом.</p>
|
||||
|
||||
<div class="frequency-plan">
|
||||
<div class="chart-controls">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="modalResetZoom">
|
||||
<i class="bi bi-arrow-clockwise"></i> Сбросить масштаб
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="frequency-chart-container">
|
||||
<canvas id="modalFrequencyChart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<p><strong>Всего транспондеров:</strong> <span id="modalTransponderCount">0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modalNoData" style="display: none;" class="text-center text-muted py-5">
|
||||
<p>Нет данных о транспондерах для этого спутника</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.frequency-plan {
|
||||
padding: 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.frequency-chart-container {
|
||||
position: relative;
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-controls button {
|
||||
padding: 5px 15px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,833 @@
|
||||
{% load l10n %}
|
||||
<!-- Вкладка фильтров и экспорта -->
|
||||
<form method="get" id="filterForm" class="mb-4">
|
||||
<input type="hidden" name="tab" value="filters">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Фильтры</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<!-- Спутники -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.satellites.id_for_label }}" class="form-label">{{ form.satellites.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('satellites', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('satellites', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.satellites }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Полоса спутника -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.band.id_for_label }}" class="form-label">{{ form.band.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('band', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('band', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.band }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Поляризация -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.polarization.id_for_label }}" class="form-label">{{ form.polarization.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('polarization', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('polarization', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.polarization }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Модуляция -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.modulation.id_for_label }}" class="form-label">{{ form.modulation.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('modulation', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('modulation', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.modulation }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Центральная частота -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Центральная частота (МГц)</label>
|
||||
<div class="input-group">
|
||||
{{ form.frequency_min }}
|
||||
<span class="input-group-text">—</span>
|
||||
{{ form.frequency_max }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Полоса -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Полоса (МГц)</label>
|
||||
<div class="input-group">
|
||||
{{ form.freq_range_min }}
|
||||
<span class="input-group-text">—</span>
|
||||
{{ form.freq_range_max }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Тип объекта -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.object_type.id_for_label }}" class="form-label">{{ form.object_type.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('object_type', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('object_type', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.object_type }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
|
||||
<!-- Принадлежность объекта -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="{{ form.object_ownership.id_for_label }}" class="form-label">{{ form.object_ownership.label }}</label>
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('object_ownership', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="selectAllOptions('object_ownership', false)">Снять</button>
|
||||
</div>
|
||||
{{ form.object_ownership }}
|
||||
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Количество ObjItem -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">Количество привязанных точек ГЛ</label>
|
||||
<div class="input-group mb-2">
|
||||
{{ form.objitem_count_min }}
|
||||
</div>
|
||||
<div class="input-group">
|
||||
{{ form.objitem_count_max }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Планы на Кубсат -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">{{ form.has_plans.label }}</label>
|
||||
<div>
|
||||
{% for radio in form.has_plans %}
|
||||
<div class="form-check">
|
||||
{{ radio.tag }}
|
||||
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||
{{ radio.choice_label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ГСО успешно -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">{{ form.success_1.label }}</label>
|
||||
<div>
|
||||
{% for radio in form.success_1 %}
|
||||
<div class="form-check">
|
||||
{{ radio.tag }}
|
||||
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||
{{ radio.choice_label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кубсат успешно -->
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="form-label">{{ form.success_2.label }}</label>
|
||||
<div>
|
||||
{% for radio in form.success_2 %}
|
||||
<div class="form-check">
|
||||
{{ radio.tag }}
|
||||
<label class="form-check-label" for="{{ radio.id_for_label }}">
|
||||
{{ radio.choice_label }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Диапазон дат -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Диапазон дат ГЛ:</label>
|
||||
<div class="input-group">
|
||||
{{ form.date_from }}
|
||||
<span class="input-group-text">—</span>
|
||||
{{ form.date_to }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Применить фильтры</button>
|
||||
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">Сбросить</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Кнопка экспорта и статистика -->
|
||||
{% if sources_with_date_info %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||
<!-- Поиск по имени точки -->
|
||||
<div class="input-group" style="max-width: 350px;">
|
||||
<input type="text" id="searchObjitemName" class="form-control"
|
||||
placeholder="Поиск по имени точки..."
|
||||
oninput="filterTableByName()">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-success" onclick="exportToExcel()">
|
||||
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="createRequestsFromTable()">
|
||||
<i class="bi bi-plus-circle"></i> Создать заявки
|
||||
</button>
|
||||
<span class="text-muted" id="statsCounter">
|
||||
Найдено объектов: {{ sources_with_date_info|length }},
|
||||
точек: {% for source_data in sources_with_date_info %}{{ source_data.objitems_data|length }}{% if not forloop.last %}+{% endif %}{% endfor %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Таблица результатов -->
|
||||
{% if sources_with_date_info %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
|
||||
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;" id="resultsTable">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th style="min-width: 80px;">ID объекта</th>
|
||||
<th style="min-width: 120px;">Тип объекта</th>
|
||||
<th style="min-width: 150px;">Принадлежность объекта</th>
|
||||
<th class="text-center" style="min-width: 60px;" title="Всего заявок">Заявки</th>
|
||||
<th class="text-center" style="min-width: 80px;">ГСО</th>
|
||||
<th class="text-center" style="min-width: 80px;">Кубсат</th>
|
||||
<th class="text-center" style="min-width: 100px;">Статус заявки</th>
|
||||
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
|
||||
<th style="min-width: 150px;">Усреднённая координата</th>
|
||||
<th style="min-width: 120px;">Имя точки</th>
|
||||
<th style="min-width: 150px;">Спутник</th>
|
||||
<th style="min-width: 100px;">Частота (МГц)</th>
|
||||
<th style="min-width: 100px;">Полоса (МГц)</th>
|
||||
<th style="min-width: 100px;">Поляризация</th>
|
||||
<th style="min-width: 100px;">Модуляция</th>
|
||||
<th style="min-width: 150px;">Координаты ГЛ</th>
|
||||
<th style="min-width: 100px;">Дата ГЛ</th>
|
||||
<th style="min-width: 150px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for source_data in sources_with_date_info %}
|
||||
{% for objitem_data in source_data.objitems_data %}
|
||||
<tr data-source-id="{{ source_data.source.id }}"
|
||||
data-objitem-id="{{ objitem_data.objitem.id }}"
|
||||
data-objitem-name="{{ objitem_data.objitem.name|default:'' }}"
|
||||
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
|
||||
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}"
|
||||
data-lat="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.y }}{% endif %}"
|
||||
data-lon="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.x }}{% endif %}">
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="source-id-cell">{{ source_data.source.id }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="source-type-cell">{{ source_data.source.info.name|default:"-" }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="source-ownership-cell">
|
||||
{% if source_data.source.ownership %}
|
||||
{% if source_data.source.ownership.name == "ТВ" and source_data.has_lyngsat %}
|
||||
<a href="#" class="text-primary text-decoration-none"
|
||||
onclick="showLyngsatModal({{ source_data.lyngsat_id }}); return false;">
|
||||
<i class="bi bi-tv"></i> {{ source_data.source.ownership.name }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ source_data.source.ownership.name }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-requests-count-cell">
|
||||
{% if source_data.requests_count > 0 %}
|
||||
<span class="badge bg-info">{{ source_data.requests_count }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-gso-cell">
|
||||
{% if source_data.gso_success == True %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||
{% elif source_data.gso_success == False %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-kubsat-cell">
|
||||
{% if source_data.kubsat_success == True %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
|
||||
{% elif source_data.kubsat_success == False %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-status-cell">
|
||||
{% if source_data.request_status %}
|
||||
{% if source_data.request_status_raw == 'successful' or source_data.request_status_raw == 'result_received' %}
|
||||
<span class="badge bg-success">{{ source_data.request_status }}</span>
|
||||
{% elif source_data.request_status_raw == 'unsuccessful' or source_data.request_status_raw == 'no_correlation' or source_data.request_status_raw == 'no_signal' %}
|
||||
<span class="badge bg-danger">{{ source_data.request_status }}</span>
|
||||
{% elif source_data.request_status_raw == 'planned' %}
|
||||
<span class="badge bg-primary">{{ source_data.request_status }}</span>
|
||||
{% elif source_data.request_status_raw == 'downloading' or source_data.request_status_raw == 'processing' %}
|
||||
<span class="badge bg-warning text-dark">{{ source_data.request_status }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ source_data.request_status }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if forloop.first %}
|
||||
<td rowspan="{{ source_data.objitems_data|length }}" class="source-avg-coords-cell"
|
||||
data-avg-lat="{{ source_data.avg_lat|default:''|unlocalize }}"
|
||||
data-avg-lon="{{ source_data.avg_lon|default:''|unlocalize }}">
|
||||
{% if source_data.avg_lat and source_data.avg_lon %}
|
||||
{{ source_data.avg_lat|floatformat:6|unlocalize }}, {{ source_data.avg_lon|floatformat:6|unlocalize }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
<td>{{ objitem_data.objitem.name|default:"-" }}</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %}
|
||||
{{ objitem_data.objitem.parameter_obj.id_satellite.name }}
|
||||
{% if objitem_data.objitem.parameter_obj.id_satellite.norad %}
|
||||
({{ objitem_data.objitem.parameter_obj.id_satellite.norad }})
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj %}
|
||||
{{ objitem_data.objitem.parameter_obj.frequency|default:"-"|floatformat:3 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj %}
|
||||
{{ objitem_data.objitem.parameter_obj.freq_range|default:"-"|floatformat:3 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %}
|
||||
{{ objitem_data.objitem.parameter_obj.polarization.name }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %}
|
||||
{{ objitem_data.objitem.parameter_obj.modulation.name }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}
|
||||
{{ objitem_data.objitem.geo_obj.coords.y|floatformat:6|unlocalize }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6|unlocalize }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if objitem_data.geo_date %}
|
||||
{{ objitem_data.geo_date|date:"d.m.Y" }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="removeObjItem(this)" title="Удалить точку">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% if forloop.first %}
|
||||
<button type="button" class="btn btn-sm btn-warning" onclick="removeSource(this)" title="Удалить весь объект">
|
||||
<i class="bi bi-trash-fill"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif request.GET %}
|
||||
<div class="alert alert-info">
|
||||
По заданным критериям ничего не найдено.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// Функция для пересчёта усреднённых координат источника через Python API
|
||||
// Координаты рассчитываются на сервере с сортировкой по дате ГЛ
|
||||
function recalculateAverageCoords(sourceId) {
|
||||
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||
if (sourceRows.length === 0) return;
|
||||
|
||||
// Собираем ID всех оставшихся точек для этого источника
|
||||
const objitemIds = sourceRows.map(row => row.dataset.objitemId).filter(id => id);
|
||||
|
||||
if (objitemIds.length === 0) {
|
||||
// Нет точек - очищаем координаты
|
||||
updateAvgCoordsCell(sourceId, null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Вызываем Python API для пересчёта координат
|
||||
const formData = new FormData();
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfToken) {
|
||||
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||
}
|
||||
objitemIds.forEach(id => formData.append('objitem_ids', id));
|
||||
|
||||
fetch('{% url "mainapp:kubsat_recalculate_coords" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success && result.results[sourceId]) {
|
||||
const coords = result.results[sourceId];
|
||||
updateAvgCoordsCell(sourceId, coords.avg_lat, coords.avg_lon);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error recalculating coords:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Обновляет ячейку с усреднёнными координатами
|
||||
function updateAvgCoordsCell(sourceId, avgLat, avgLon) {
|
||||
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||
if (sourceRows.length === 0) return;
|
||||
|
||||
const firstRow = sourceRows[0];
|
||||
const avgCoordsCell = firstRow.querySelector('.source-avg-coords-cell');
|
||||
if (avgCoordsCell) {
|
||||
if (avgLat !== null && avgLon !== null) {
|
||||
avgCoordsCell.textContent = `${avgLat.toFixed(6)}, ${avgLon.toFixed(6)}`;
|
||||
avgCoordsCell.dataset.avgLat = avgLat;
|
||||
avgCoordsCell.dataset.avgLon = avgLon;
|
||||
} else {
|
||||
avgCoordsCell.textContent = '-';
|
||||
avgCoordsCell.dataset.avgLat = '';
|
||||
avgCoordsCell.dataset.avgLon = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeObjItem(button) {
|
||||
const row = button.closest('tr');
|
||||
const sourceId = row.dataset.sourceId;
|
||||
const isFirstInSource = row.dataset.isFirstInSource === 'true';
|
||||
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
|
||||
|
||||
// All rowspan cells that need to be handled
|
||||
const rowspanCellClasses = [
|
||||
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||
];
|
||||
|
||||
if (sourceRows.length === 1) {
|
||||
row.remove();
|
||||
} else if (isFirstInSource) {
|
||||
const nextRow = sourceRows[1];
|
||||
const cells = rowspanCellClasses.map(cls => row.querySelector(cls)).filter(c => c);
|
||||
|
||||
if (cells.length > 0) {
|
||||
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||
const newRowspan = currentRowspan - 1;
|
||||
|
||||
// Clone and update all rowspan cells
|
||||
const newCells = cells.map(cell => {
|
||||
const newCell = cell.cloneNode(true);
|
||||
newCell.setAttribute('rowspan', newRowspan);
|
||||
if (newCell.classList.contains('source-count-cell')) {
|
||||
newCell.textContent = newRowspan;
|
||||
}
|
||||
return newCell;
|
||||
});
|
||||
|
||||
// Insert cells in reverse order to maintain correct order
|
||||
newCells.reverse().forEach(cell => {
|
||||
nextRow.insertBefore(cell, nextRow.firstChild);
|
||||
});
|
||||
|
||||
const actionsCell = nextRow.querySelector('td:last-child');
|
||||
if (actionsCell) {
|
||||
const btnGroup = actionsCell.querySelector('.btn-group');
|
||||
if (btnGroup && btnGroup.children.length === 1) {
|
||||
const deleteSourceBtn = document.createElement('button');
|
||||
deleteSourceBtn.type = 'button';
|
||||
deleteSourceBtn.className = 'btn btn-sm btn-warning';
|
||||
deleteSourceBtn.onclick = function() { removeSource(this); };
|
||||
deleteSourceBtn.title = 'Удалить весь объект';
|
||||
deleteSourceBtn.innerHTML = '<i class="bi bi-trash-fill"></i>';
|
||||
btnGroup.appendChild(deleteSourceBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
nextRow.dataset.isFirstInSource = 'true';
|
||||
row.remove();
|
||||
// Пересчитываем усреднённые координаты после удаления точки
|
||||
recalculateAverageCoords(sourceId);
|
||||
} else {
|
||||
const firstRow = sourceRows[0];
|
||||
const cells = rowspanCellClasses.map(cls => firstRow.querySelector(cls)).filter(c => c);
|
||||
|
||||
if (cells.length > 0) {
|
||||
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
|
||||
const newRowspan = currentRowspan - 1;
|
||||
|
||||
cells.forEach(cell => {
|
||||
cell.setAttribute('rowspan', newRowspan);
|
||||
if (cell.classList.contains('source-count-cell')) {
|
||||
cell.textContent = newRowspan;
|
||||
}
|
||||
});
|
||||
}
|
||||
row.remove();
|
||||
// Пересчитываем усреднённые координаты после удаления точки
|
||||
recalculateAverageCoords(sourceId);
|
||||
}
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function removeSource(button) {
|
||||
const row = button.closest('tr');
|
||||
const sourceId = row.dataset.sourceId;
|
||||
const rows = document.querySelectorAll(`tr[data-source-id="${sourceId}"]`);
|
||||
rows.forEach(r => r.remove());
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function updateCounter() {
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
const counter = document.getElementById('statsCounter');
|
||||
if (counter) {
|
||||
// Подсчитываем уникальные источники и точки (только видимые)
|
||||
const uniqueSources = new Set();
|
||||
let visibleRowsCount = 0;
|
||||
rows.forEach(row => {
|
||||
if (row.style.display !== 'none') {
|
||||
uniqueSources.add(row.dataset.sourceId);
|
||||
visibleRowsCount++;
|
||||
}
|
||||
});
|
||||
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
|
||||
}
|
||||
}
|
||||
|
||||
function exportToExcel() {
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||
|
||||
if (objitemIds.length === 0) {
|
||||
alert('Нет данных для экспорта');
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{% url "mainapp:kubsat_export" %}';
|
||||
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfToken) {
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrfmiddlewaretoken';
|
||||
csrfInput.value = csrfToken.value;
|
||||
form.appendChild(csrfInput);
|
||||
}
|
||||
|
||||
objitemIds.forEach(id => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'objitem_ids';
|
||||
input.value = id;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
}
|
||||
|
||||
function selectAllOptions(selectName, selectAll) {
|
||||
const selectElement = document.querySelector(`select[name="${selectName}"]`);
|
||||
if (selectElement) {
|
||||
for (let i = 0; i < selectElement.options.length; i++) {
|
||||
selectElement.options[i].selected = selectAll;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestsFromTable() {
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
|
||||
|
||||
if (objitemIds.length === 0) {
|
||||
alert('Нет данных для создания заявок');
|
||||
return;
|
||||
}
|
||||
|
||||
// Подсчитываем уникальные источники
|
||||
const uniqueSources = new Set();
|
||||
rows.forEach(row => uniqueSources.add(row.dataset.sourceId));
|
||||
|
||||
if (!confirm(`Будет создано ${uniqueSources.size} заявок (по одной на каждый источник) со статусом "Запланировано".\n\nКоординаты будут рассчитаны как среднее по выбранным точкам.\n\nПродолжить?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
const btn = event.target.closest('button');
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Создание...';
|
||||
|
||||
const formData = new FormData();
|
||||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (csrfToken) {
|
||||
formData.append('csrfmiddlewaretoken', csrfToken.value);
|
||||
}
|
||||
|
||||
objitemIds.forEach(id => {
|
||||
formData.append('objitem_ids', id);
|
||||
});
|
||||
|
||||
fetch('{% url "mainapp:kubsat_create_requests" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken ? csrfToken.value : ''
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
|
||||
if (result.success) {
|
||||
let message = `Создано заявок: ${result.created_count} из ${result.total_sources}`;
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
message += `\n\nОшибки:\n${result.errors.join('\n')}`;
|
||||
}
|
||||
alert(message);
|
||||
// Перезагружаем страницу для обновления данных
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка: ' + result.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
console.error('Error creating requests:', error);
|
||||
alert('Ошибка создания заявок');
|
||||
});
|
||||
}
|
||||
|
||||
// Фильтрация таблицы по имени точки
|
||||
function filterTableByName() {
|
||||
const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim();
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
|
||||
if (!searchValue) {
|
||||
// Показываем все строки
|
||||
rows.forEach(row => {
|
||||
row.style.display = '';
|
||||
});
|
||||
// Восстанавливаем rowspan
|
||||
recalculateRowspans();
|
||||
updateCounter();
|
||||
return;
|
||||
}
|
||||
|
||||
// Группируем строки по source_id
|
||||
const sourceGroups = {};
|
||||
rows.forEach(row => {
|
||||
const sourceId = row.dataset.sourceId;
|
||||
if (!sourceGroups[sourceId]) {
|
||||
sourceGroups[sourceId] = [];
|
||||
}
|
||||
sourceGroups[sourceId].push(row);
|
||||
});
|
||||
|
||||
// Фильтруем по имени точки используя data-атрибут
|
||||
Object.keys(sourceGroups).forEach(sourceId => {
|
||||
const sourceRows = sourceGroups[sourceId];
|
||||
let hasVisibleRows = false;
|
||||
|
||||
sourceRows.forEach(row => {
|
||||
// Используем data-атрибут для получения имени точки
|
||||
const name = (row.dataset.objitemName || '').toLowerCase();
|
||||
|
||||
if (name.includes(searchValue)) {
|
||||
row.style.display = '';
|
||||
hasVisibleRows = true;
|
||||
} else {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan)
|
||||
if (!hasVisibleRows) {
|
||||
sourceRows.forEach(row => {
|
||||
row.style.display = 'none';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Пересчитываем rowspan для видимых строк
|
||||
recalculateRowspans();
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
// Пересчет rowspan для видимых строк
|
||||
function recalculateRowspans() {
|
||||
const rows = document.querySelectorAll('#resultsTable tbody tr');
|
||||
|
||||
// Группируем видимые строки по source_id
|
||||
const sourceGroups = {};
|
||||
rows.forEach(row => {
|
||||
if (row.style.display !== 'none') {
|
||||
const sourceId = row.dataset.sourceId;
|
||||
if (!sourceGroups[sourceId]) {
|
||||
sourceGroups[sourceId] = [];
|
||||
}
|
||||
sourceGroups[sourceId].push(row);
|
||||
}
|
||||
});
|
||||
|
||||
// All rowspan cell classes
|
||||
const rowspanCellClasses = [
|
||||
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
|
||||
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
|
||||
];
|
||||
|
||||
// Обновляем rowspan для каждой группы
|
||||
Object.keys(sourceGroups).forEach(sourceId => {
|
||||
const visibleRows = sourceGroups[sourceId];
|
||||
const newRowspan = visibleRows.length;
|
||||
|
||||
if (visibleRows.length > 0) {
|
||||
const firstRow = visibleRows[0];
|
||||
|
||||
rowspanCellClasses.forEach(cls => {
|
||||
const cell = firstRow.querySelector(cls);
|
||||
if (cell) {
|
||||
cell.setAttribute('rowspan', newRowspan);
|
||||
// Обновляем отображаемое количество точек
|
||||
if (cell.classList.contains('source-count-cell')) {
|
||||
cell.textContent = newRowspan;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Очистка поиска
|
||||
function clearSearch() {
|
||||
document.getElementById('searchObjitemName').value = '';
|
||||
filterTableByName();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateCounter();
|
||||
});
|
||||
</script>
|
||||
@@ -2,24 +2,40 @@
|
||||
Переиспользуемый компонент для отображения сообщений Django
|
||||
Использование:
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
Для отключения автоскрытия добавьте extra_tags='persistent':
|
||||
messages.success(request, "Сообщение", extra_tags='persistent')
|
||||
{% endcomment %}
|
||||
|
||||
{% if messages %}
|
||||
<div class="messages-container">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{% if message.tags == 'error' %}
|
||||
<div class="alert alert-{% if 'error' in message.tags %}danger{% elif 'success' in message.tags %}success{% elif 'warning' in message.tags %}warning{% else %}info{% endif %} alert-dismissible fade show {% if 'persistent' not in message.tags %}auto-dismiss{% endif %}" role="alert">
|
||||
{% if 'error' in message.tags %}
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{% elif message.tags == 'success' %}
|
||||
{% elif 'success' in message.tags %}
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
{% elif message.tags == 'warning' %}
|
||||
{% elif 'warning' in message.tags %}
|
||||
<i class="bi bi-exclamation-circle-fill me-2"></i>
|
||||
{% elif message.tags == 'info' %}
|
||||
{% elif 'info' in message.tags %}
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
{% endif %}
|
||||
{{ message }}
|
||||
{{ message|safe }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Автоматическое скрытие уведомлений через 5 секунд (кроме persistent)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const alerts = document.querySelectorAll('.alert.auto-dismiss');
|
||||
alerts.forEach(function(alert) {
|
||||
setTimeout(function() {
|
||||
const bsAlert = new bootstrap.Alert(alert);
|
||||
bsAlert.close();
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
@@ -6,24 +6,45 @@
|
||||
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="{% url 'mainapp:home' %}">Геолокация</a>
|
||||
<a class="navbar-brand" href="{% url 'mainapp:source_list' %}">Геолокация</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
{% if user.is_authenticated %}
|
||||
<ul class="navbar-nav me-auto">
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:source_list' %}">Главная</a>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:home' %}">Объекты</a>
|
||||
<a class="nav-link" href="{% url 'mainapp:source_list' %}">Объекты</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Точки</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:satellite_list' %}">Спутники</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:transponder_list' %}">Транспондеры</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a>
|
||||
</li>
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:signal_marks' %}">Отметки сигналов</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
|
||||
</li>
|
||||
<!-- <li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
|
||||
</li>
|
||||
</li> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'mapsapp:2dmap' %}">2D карта</a>
|
||||
<a class="nav-link" href="{% url 'mapsapp:2dmap' %}">Карта</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'admin:index' %}">Админ панель</a>
|
||||
|
||||
126
dbapp/mainapp/templates/mainapp/components/_objitems_table.html
Normal file
126
dbapp/mainapp/templates/mainapp/components/_objitems_table.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<!-- ObjItems Table Component -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">Объекты (ObjItems)</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
|
||||
<table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.9rem;">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя</th>
|
||||
<th>Спутник</th>
|
||||
<th>Частота, МГц</th>
|
||||
<th>Полоса, МГц</th>
|
||||
<th>Поляризация</th>
|
||||
<th>Модуляция</th>
|
||||
<th>Сим. v</th>
|
||||
<th>ОСШ</th>
|
||||
<th>Геолокация</th>
|
||||
<th>Дата гео</th>
|
||||
<th>Объект</th>
|
||||
<th>LyngSat</th>
|
||||
{% if show_marks == '1' %}
|
||||
<th>Отметки</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in processed_objitems %}
|
||||
<tr>
|
||||
<td><a href="{% url 'mainapp:objitem_detail' item.id %}">{{ item.id }}</a></td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.satellite }}</td>
|
||||
<td>{{ item.frequency }}</td>
|
||||
<td>{{ item.freq_range }}</td>
|
||||
<td>{{ item.polarization }}</td>
|
||||
<td>{{ item.modulation }}</td>
|
||||
<td>{{ item.bod_velocity }}</td>
|
||||
<td>{{ item.snr }}</td>
|
||||
<td>{{ item.geo_coords }}</td>
|
||||
<td>{{ item.geo_date }}</td>
|
||||
<td>
|
||||
{% if item.source_id %}
|
||||
<a href="{% url 'mainapp:source_update' item.source_id %}">{{ item.source_id }}</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.lyngsat_id %}
|
||||
<a href="{% url 'admin:lyngsatapp_lyngsat_change' item.lyngsat_id %}" target="_blank">
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if show_marks == '1' %}
|
||||
<td>
|
||||
{% if item.marks %}
|
||||
<div style="max-height: 150px; overflow-y: auto;">
|
||||
{% for mark in item.marks %}
|
||||
<div class="mb-1">
|
||||
<span class="mark-badge {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
|
||||
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
|
||||
</span>
|
||||
<br>
|
||||
<small class="text-muted">{{ mark.timestamp|date:"d.m.Y H:i" }}</small>
|
||||
<br>
|
||||
<small class="text-muted">{{ mark.created_by }}</small>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="{% if show_marks == '1' %}14{% else %}13{% endif %}" class="text-center py-4 text-muted">
|
||||
Нет данных для выбранных фильтров
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<div class="card-footer">
|
||||
<nav>
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1">Первая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}">Предыдущая</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
|
||||
</li>
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}">Следующая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}">Последняя</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="text-center mt-2">
|
||||
<small class="text-muted">Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- Selected Items Offcanvas Component -->
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="selectedItemsOffcanvas" aria-labelledby="selectedItemsOffcanvasLabel" style="width: 100vw;">
|
||||
<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>
|
||||
@@ -12,8 +12,8 @@
|
||||
<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="sendSelectedItems()">
|
||||
<i class="bi bi-send"></i> Отправить
|
||||
<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">
|
||||
Закрыть
|
||||
@@ -24,7 +24,7 @@
|
||||
<!-- 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" style="font-size: 0.85rem;">
|
||||
<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%;">
|
||||
@@ -32,6 +32,7 @@
|
||||
</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>
|
||||
@@ -41,12 +42,11 @@
|
||||
<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">
|
||||
|
||||
@@ -180,10 +180,10 @@ function showSigmaParameterModal(parameterId) {
|
||||
if (sigma.marks.length > 0) {
|
||||
html += `
|
||||
<div class="table-responsive" style="max-height: 200px; overflow-y: auto;">
|
||||
<table class="table table-sm table-striped mb-0">
|
||||
<table class="table table-sm table-striped table-bordered mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th style="width: 20%;">Отметка</th>
|
||||
<th style="width: 20%;">Наличие сигнала</th>
|
||||
<th>Дата</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
27
dbapp/mainapp/templates/mainapp/components/_sort_header.html
Normal file
27
dbapp/mainapp/templates/mainapp/components/_sort_header.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% comment %}
|
||||
Переиспользуемый компонент заголовка таблицы с сортировкой
|
||||
|
||||
Параметры:
|
||||
- field: имя поля для сортировки (обязательный)
|
||||
- label: отображаемый текст заголовка (обязательный)
|
||||
- current_sort: текущее значение сортировки из контекста (обязательный)
|
||||
|
||||
Использование:
|
||||
{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
|
||||
{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Дата создания' current_sort=sort %}
|
||||
|
||||
Примечание:
|
||||
Функция updateSort() определена в static/js/sorting.js и загружается через base.html
|
||||
{% endcomment %}
|
||||
|
||||
<a href="javascript:void(0)"
|
||||
onclick="updateSort('{{ field }}')"
|
||||
class="text-white text-decoration-none"
|
||||
data-sort-field="{{ field }}">
|
||||
{{ label }}
|
||||
{% if current_sort == field %}
|
||||
<i class="bi bi-arrow-up sort-icon"></i>
|
||||
{% elif current_sort == '-'|add:field %}
|
||||
<i class="bi bi-arrow-down sort-icon"></i>
|
||||
{% endif %}
|
||||
</a>
|
||||
@@ -0,0 +1,386 @@
|
||||
{% load static %}
|
||||
<!-- Вкладка заявок на источники -->
|
||||
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||
<style>
|
||||
#requestsTable .tabulator-header .tabulator-col {
|
||||
padding: 8px 6px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
#requestsTable .tabulator-cell {
|
||||
padding: 6px 8px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
#requestsTable .tabulator-row {
|
||||
min-height: 36px !important;
|
||||
}
|
||||
#requestsTable .tabulator-footer {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="bi bi-list-task"></i> Заявки на источники</h5>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm me-2" id="bulkDeleteBtn" onclick="bulkDeleteRequests()">
|
||||
<i class="bi bi-trash"></i> Удалить
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-success btn-sm me-2" onclick="exportRequests()">
|
||||
<i class="bi bi-file-earmark-excel"></i> Экспорт
|
||||
</button>
|
||||
<button type="button" class="btn btn-success btn-sm" onclick="openCreateRequestModal()">
|
||||
<i class="bi bi-plus-circle"></i> Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Фильтры заявок -->
|
||||
<form method="get" class="row g-2 mb-3" id="requestsFilterForm">
|
||||
<div class="col-md-2">
|
||||
<select name="status" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">Все статусы</option>
|
||||
{% for value, label in status_choices %}
|
||||
<option value="{{ value }}" {% if current_status == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="priority" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">Все приоритеты</option>
|
||||
{% for value, label in priority_choices %}
|
||||
<option value="{{ value }}" {% if current_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="gso_success" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">ГСО: все</option>
|
||||
<option value="true" {% if request.GET.gso_success == 'true' %}selected{% endif %}>ГСО: Да</option>
|
||||
<option value="false" {% if request.GET.gso_success == 'false' %}selected{% endif %}>ГСО: Нет</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="kubsat_success" class="form-select form-select-sm" onchange="this.form.submit()">
|
||||
<option value="">Кубсат: все</option>
|
||||
<option value="true" {% if request.GET.kubsat_success == 'true' %}selected{% endif %}>Кубсат: Да</option>
|
||||
<option value="false" {% if request.GET.kubsat_success == 'false' %}selected{% endif %}>Кубсат: Нет</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Клиентский поиск -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="searchRequestInput" class="form-control"
|
||||
placeholder="Поиск по спутнику, частоте...">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="clearRequestSearch()">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Таблица заявок (Tabulator с встроенной пагинацией) -->
|
||||
<div id="requestsTable"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||
<script>
|
||||
// Данные заявок из Django (через JSON)
|
||||
const requestsData = JSON.parse('{{ requests_json|escapejs }}');
|
||||
|
||||
// Форматтер для статуса
|
||||
function statusFormatter(cell) {
|
||||
const status = cell.getValue();
|
||||
const display = cell.getData().status_display;
|
||||
let badgeClass = 'bg-secondary';
|
||||
|
||||
if (status === 'successful' || status === 'result_received') {
|
||||
badgeClass = 'bg-success';
|
||||
} else if (status === 'unsuccessful' || status === 'no_correlation' || status === 'no_signal') {
|
||||
badgeClass = 'bg-danger';
|
||||
} else if (status === 'planned') {
|
||||
badgeClass = 'bg-primary';
|
||||
} else if (status === 'downloading' || status === 'processing') {
|
||||
badgeClass = 'bg-warning text-dark';
|
||||
}
|
||||
|
||||
return `<span class="badge ${badgeClass}">${display}</span>`;
|
||||
}
|
||||
|
||||
// Форматтер для булевых значений (ГСО/Кубсат)
|
||||
function boolFormatter(cell) {
|
||||
const val = cell.getValue();
|
||||
if (val === true) {
|
||||
return '<span class="badge bg-success">Да</span>';
|
||||
} else if (val === false) {
|
||||
return '<span class="badge bg-danger">Нет</span>';
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
// Форматтер для координат (4 знака после запятой)
|
||||
function coordsFormatter(cell) {
|
||||
const data = cell.getData();
|
||||
const field = cell.getField();
|
||||
let lat, lon;
|
||||
|
||||
if (field === 'coords_lat') {
|
||||
lat = data.coords_lat;
|
||||
lon = data.coords_lon;
|
||||
} else if (field === 'coords_source_lat') {
|
||||
lat = data.coords_source_lat;
|
||||
lon = data.coords_source_lon;
|
||||
} else if (field === 'coords_object_lat') {
|
||||
lat = data.coords_object_lat;
|
||||
lon = data.coords_object_lon;
|
||||
}
|
||||
|
||||
if (lat !== null && lon !== null) {
|
||||
return `${lat.toFixed(4)}, ${lon.toFixed(4)}`;
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
// Форматтер для числовых значений
|
||||
function numberFormatter(cell, decimals) {
|
||||
const val = cell.getValue();
|
||||
if (val !== null && val !== undefined) {
|
||||
return val.toFixed(decimals);
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
// Форматтер для источника
|
||||
function sourceFormatter(cell) {
|
||||
const sourceId = cell.getValue();
|
||||
if (sourceId) {
|
||||
return `<a href="/source/${sourceId}/edit/" target="_blank">#${sourceId}</a>`;
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
// Форматтер для приоритета
|
||||
function priorityFormatter(cell) {
|
||||
const priority = cell.getValue();
|
||||
const display = cell.getData().priority_display;
|
||||
let badgeClass = 'bg-secondary';
|
||||
|
||||
if (priority === 'high') {
|
||||
badgeClass = 'bg-danger';
|
||||
} else if (priority === 'medium') {
|
||||
badgeClass = 'bg-warning text-dark';
|
||||
} else if (priority === 'low') {
|
||||
badgeClass = 'bg-info';
|
||||
}
|
||||
|
||||
return `<span class="badge ${badgeClass}">${display}</span>`;
|
||||
}
|
||||
|
||||
// Форматтер для комментария
|
||||
function commentFormatter(cell) {
|
||||
const val = cell.getValue();
|
||||
if (!val) return '-';
|
||||
|
||||
// Обрезаем длинный текст и добавляем tooltip
|
||||
const maxLength = 50;
|
||||
if (val.length > maxLength) {
|
||||
const truncated = val.substring(0, maxLength) + '...';
|
||||
return `<span title="${val.replace(/"/g, '"')}">${truncated}</span>`;
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
// Форматтер для действий
|
||||
function actionsFormatter(cell) {
|
||||
const id = cell.getData().id;
|
||||
return `
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button type="button" class="btn btn-outline-info btn-sm" onclick="showHistory(${id})" title="История">
|
||||
<i class="bi bi-clock-history"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-warning btn-sm" onclick="openEditRequestModal(${id})" title="Редактировать">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" onclick="deleteRequest(${id})" title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Инициализация Tabulator
|
||||
const requestsTable = new Tabulator("#requestsTable", {
|
||||
data: requestsData,
|
||||
layout: "fitColumns",
|
||||
height: "65vh",
|
||||
placeholder: "Нет заявок",
|
||||
selectable: true,
|
||||
selectableRangeMode: "click",
|
||||
pagination: true,
|
||||
paginationSize: true,
|
||||
paginationSizeSelector: [50, 200, 500],
|
||||
paginationCounter: "rows",
|
||||
columns: [
|
||||
{
|
||||
formatter: "rowSelection",
|
||||
titleFormatter: "rowSelection",
|
||||
hozAlign: "center",
|
||||
headerSort: false,
|
||||
width: 50,
|
||||
cellClick: function(e, cell) {
|
||||
cell.getRow().toggleSelect();
|
||||
}
|
||||
},
|
||||
{title: "ID", field: "id", width: 50, hozAlign: "center"},
|
||||
{title: "Ист.", field: "source_id", width: 55, formatter: sourceFormatter},
|
||||
{title: "Спутник", field: "satellite_name", width: 100},
|
||||
{title: "Статус", field: "status", width: 105, formatter: statusFormatter},
|
||||
{title: "Приоритет", field: "priority", width: 105, formatter: priorityFormatter},
|
||||
{title: "Заявка", field: "request_date_display", width: 105,
|
||||
sorter: function(a, b, aRow, bRow) {
|
||||
const dateA = aRow.getData().request_date;
|
||||
const dateB = bRow.getData().request_date;
|
||||
if (!dateA && !dateB) return 0;
|
||||
if (!dateA) return 1;
|
||||
if (!dateB) return -1;
|
||||
return new Date(dateA) - new Date(dateB);
|
||||
}
|
||||
},
|
||||
{title: "Карточка", field: "card_date_display", width: 120,
|
||||
sorter: function(a, b, aRow, bRow) {
|
||||
const dateA = aRow.getData().card_date;
|
||||
const dateB = bRow.getData().card_date;
|
||||
if (!dateA && !dateB) return 0;
|
||||
if (!dateA) return 1;
|
||||
if (!dateB) return -1;
|
||||
return new Date(dateA) - new Date(dateB);
|
||||
}
|
||||
},
|
||||
{title: "Планирование", field: "planned_at_display", width: 150,
|
||||
sorter: function(a, b, aRow, bRow) {
|
||||
const dateA = aRow.getData().planned_at;
|
||||
const dateB = bRow.getData().planned_at;
|
||||
if (!dateA && !dateB) return 0;
|
||||
if (!dateA) return 1;
|
||||
if (!dateB) return -1;
|
||||
return new Date(dateA) - new Date(dateB);
|
||||
}
|
||||
},
|
||||
{title: "Down", field: "downlink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
|
||||
{title: "Up", field: "uplink", width: 65, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 2); }},
|
||||
{title: "Пер.", field: "transfer", width: 50, hozAlign: "right", formatter: function(cell) { return numberFormatter(cell, 0); }},
|
||||
{title: "Коорд. ГСО", field: "coords_lat", width: 130, formatter: coordsFormatter},
|
||||
{title: "Район", field: "region", width: 100, formatter: function(cell) {
|
||||
const val = cell.getValue();
|
||||
return val ? val.substring(0, 12) + (val.length > 12 ? '...' : '') : '-';
|
||||
}},
|
||||
{title: "ГСО", field: "gso_success", width: 50, hozAlign: "center", formatter: boolFormatter},
|
||||
{title: "Куб", field: "kubsat_success", width: 50, hozAlign: "center", formatter: boolFormatter},
|
||||
{title: "Коорд. ист.", field: "coords_source_lat", width: 140, formatter: coordsFormatter},
|
||||
{title: "Коорд. об.", field: "coords_object_lat", width: 140, formatter: coordsFormatter},
|
||||
{title: "Комментарий", field: "comment", width: 180, formatter: commentFormatter},
|
||||
{title: "Действия", field: "id", width: 105, formatter: actionsFormatter, headerSort: false},
|
||||
],
|
||||
rowSelectionChanged: function(data, rows) {
|
||||
updateSelectedCount();
|
||||
},
|
||||
dataFiltered: function(filters, rows) {
|
||||
updateRequestsCounter();
|
||||
},
|
||||
});
|
||||
|
||||
// Поиск по таблице
|
||||
document.getElementById('searchRequestInput').addEventListener('input', function() {
|
||||
const searchValue = this.value.toLowerCase().trim();
|
||||
if (searchValue) {
|
||||
requestsTable.setFilter(function(data) {
|
||||
// Поиск по спутнику
|
||||
const satelliteMatch = data.satellite_name && data.satellite_name.toLowerCase().includes(searchValue);
|
||||
|
||||
// Поиск по частотам (downlink, uplink, transfer)
|
||||
const downlinkMatch = data.downlink && data.downlink.toString().includes(searchValue);
|
||||
const uplinkMatch = data.uplink && data.uplink.toString().includes(searchValue);
|
||||
const transferMatch = data.transfer && data.transfer.toString().includes(searchValue);
|
||||
|
||||
// Поиск по району
|
||||
const regionMatch = data.region && data.region.toLowerCase().includes(searchValue);
|
||||
|
||||
return satelliteMatch || downlinkMatch || uplinkMatch || transferMatch || regionMatch;
|
||||
});
|
||||
} else {
|
||||
requestsTable.clearFilter();
|
||||
}
|
||||
updateRequestsCounter();
|
||||
});
|
||||
|
||||
// Обновление счётчика заявок (пустая функция для совместимости)
|
||||
function updateRequestsCounter() {
|
||||
// Функция оставлена для совместимости, но ничего не делает
|
||||
}
|
||||
|
||||
// Очистка поиска
|
||||
function clearRequestSearch() {
|
||||
document.getElementById('searchRequestInput').value = '';
|
||||
requestsTable.clearFilter();
|
||||
updateRequestsCounter();
|
||||
}
|
||||
|
||||
// Обновление счётчика выбранных (пустая функция для совместимости)
|
||||
function updateSelectedCount() {
|
||||
// Функция оставлена для совместимости, но ничего не делает
|
||||
}
|
||||
|
||||
// Массовое удаление заявок
|
||||
async function bulkDeleteRequests() {
|
||||
const selectedRows = requestsTable.getSelectedRows();
|
||||
const ids = selectedRows.map(row => row.getData().id);
|
||||
|
||||
if (ids.length === 0) {
|
||||
alert('Не выбраны заявки для удаления');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Вы уверены, что хотите удалить ${ids.length} заявок?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('{% url "mainapp:source_request_bulk_delete" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: JSON.stringify({ ids: ids })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Ошибка: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Экспорт заявок в Excel
|
||||
function exportRequests() {
|
||||
// Получаем текущие параметры фильтрации
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const exportUrl = '{% url "mainapp:source_request_export" %}?' + urlParams.toString();
|
||||
window.location.href = exportUrl;
|
||||
}
|
||||
|
||||
// Инициализация счётчика при загрузке
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateRequestsCounter();
|
||||
});
|
||||
</script>
|
||||
122
dbapp/mainapp/templates/mainapp/components/_sources_table.html
Normal file
122
dbapp/mainapp/templates/mainapp/components/_sources_table.html
Normal file
@@ -0,0 +1,122 @@
|
||||
<!-- Sources Table Component -->
|
||||
<style>
|
||||
.mark-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.mark-present {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mark-absent {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">Объекты (Sources)</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">
|
||||
<thead class="table-dark sticky-top">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Спутники</th>
|
||||
<th>Кол-во объектов</th>
|
||||
<th>Усреднённые координаты</th>
|
||||
<th>Координаты Кубсата</th>
|
||||
<th>Координаты оперативников</th>
|
||||
<th>Справочные координаты</th>
|
||||
<th>Дата создания</th>
|
||||
{% if show_marks == '1' %}
|
||||
<th>Отметки</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for source in processed_sources %}
|
||||
<tr>
|
||||
<td><a href="{% url 'mainapp:source_update' source.id %}">{{ source.id }}</a></td>
|
||||
<td>{{ source.satellites }}</td>
|
||||
<td>{{ source.objitem_count }}</td>
|
||||
<td>{{ source.coords_average }}</td>
|
||||
<td>{{ source.coords_kupsat }}</td>
|
||||
<td>{{ source.coords_valid }}</td>
|
||||
<td>{{ source.coords_reference }}</td>
|
||||
<td>{{ source.created_at|date:"d.m.Y H:i" }}</td>
|
||||
{% if show_marks == '1' %}
|
||||
<td>
|
||||
{% if source.marks %}
|
||||
<div style="max-height: 150px; overflow-y: auto;">
|
||||
{% for mark in source.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' %}9{% else %}8{% 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>
|
||||
146
dbapp/mainapp/templates/mainapp/components/_toolbar.html
Normal file
146
dbapp/mainapp/templates/mainapp/components/_toolbar.html
Normal file
@@ -0,0 +1,146 @@
|
||||
{% comment %}
|
||||
Переиспользуемый компонент панели инструментов
|
||||
Параметры:
|
||||
- show_search: показывать ли поиск (по умолчанию: True)
|
||||
- show_filters: показывать ли кнопку фильтров (по умолчанию: True)
|
||||
- show_actions: показывать ли кнопки действий (по умолчанию: True)
|
||||
- search_placeholder: текст placeholder для поиска (по умолчанию: "Поиск...")
|
||||
- search_query: текущее значение поиска
|
||||
- items_per_page: текущее количество элементов на странице
|
||||
- available_items_per_page: список доступных значений для выбора
|
||||
- action_buttons: HTML-код кнопок действий (опционально)
|
||||
- page_obj: объект пагинации Django
|
||||
- show_pagination_info: показывать ли информацию о количестве элементов (по умолчанию: True)
|
||||
- extra_buttons: дополнительные кнопки между фильтрами и пагинацией (опционально)
|
||||
|
||||
Использование:
|
||||
{% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=True %}
|
||||
{% include 'mainapp/components/_toolbar.html' with show_search=False show_actions=False %}
|
||||
{% endcomment %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||
{% if show_search|default:True %}
|
||||
<!-- Search bar -->
|
||||
<div style="min-width: 200px; flex-grow: 1; max-width: 400px;">
|
||||
<div class="input-group">
|
||||
<input type="text" id="toolbar-search" class="form-control"
|
||||
placeholder="{{ search_placeholder|default:'Поиск...' }}"
|
||||
value="{{ search_query|default:'' }}">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="performSearch()">
|
||||
Найти
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Items per page select -->
|
||||
<div>
|
||||
<label for="items-per-page" class="form-label mb-0">Показать:</label>
|
||||
<select name="items_per_page" id="items-per-page"
|
||||
class="form-select form-select-sm d-inline-block" style="width: auto;"
|
||||
onchange="updateItemsPerPage()">
|
||||
{% for option in available_items_per_page %}
|
||||
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>
|
||||
{{ option }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if show_actions|default:True %}
|
||||
<!-- Action buttons -->
|
||||
<div class="d-flex gap-2">
|
||||
{% if action_buttons %}
|
||||
{{ action_buttons|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_filters|default:True %}
|
||||
<!-- Filter Toggle Button -->
|
||||
<div>
|
||||
<button class="btn btn-outline-primary btn-sm" type="button"
|
||||
data-bs-toggle="offcanvas" data-bs-target="#offcanvasFilters">
|
||||
<i class="bi bi-funnel"></i> Фильтры
|
||||
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if extra_buttons %}
|
||||
<!-- Extra buttons (e.g., polygon filter) -->
|
||||
<div class="d-flex gap-2">
|
||||
{{ extra_buttons|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="ms-auto">
|
||||
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=show_pagination_info|default:True %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Search functionality
|
||||
function performSearch() {
|
||||
const searchInput = document.getElementById('toolbar-search');
|
||||
const searchValue = searchInput.value.trim();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (searchValue) {
|
||||
urlParams.set('search', searchValue);
|
||||
} else {
|
||||
urlParams.delete('search');
|
||||
}
|
||||
|
||||
// Reset to first page when searching
|
||||
urlParams.delete('page');
|
||||
|
||||
window.location.search = urlParams.toString();
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
const searchInput = document.getElementById('toolbar-search');
|
||||
searchInput.value = '';
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.delete('search');
|
||||
urlParams.delete('page');
|
||||
|
||||
window.location.search = urlParams.toString();
|
||||
}
|
||||
|
||||
// Allow Enter key to trigger search
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('toolbar-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Items per page functionality
|
||||
function updateItemsPerPage() {
|
||||
const select = document.getElementById('items-per-page');
|
||||
const itemsPerPage = select.value;
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
urlParams.set('items_per_page', itemsPerPage);
|
||||
|
||||
// Reset to first page when changing items per page
|
||||
urlParams.delete('page');
|
||||
|
||||
window.location.search = urlParams.toString();
|
||||
}
|
||||
</script>
|
||||
312
dbapp/mainapp/templates/mainapp/data_entry.html
Normal file
312
dbapp/mainapp/templates/mainapp/data_entry.html
Normal file
@@ -0,0 +1,312 @@
|
||||
{% extends "mainapp/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Ввод данных{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||
<style>
|
||||
.data-entry-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.form-section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.table-section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
#data-table {
|
||||
margin-top: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#data-table .tabulator-header {
|
||||
font-size: 12px;
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
#data-table .tabulator-header .tabulator-col {
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
height: auto;
|
||||
min-height: 40px;
|
||||
}
|
||||
#data-table .tabulator-header .tabulator-col-content {
|
||||
white-space: normal;
|
||||
word-wrap: break-word;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
#data-table .tabulator-cell {
|
||||
font-size: 12px;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
.btn-group-custom {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.input-field {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="data-entry-container">
|
||||
<h2>Ввод данных точек спутников</h2>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="satellite-select" class="form-label">Спутник</label>
|
||||
<select id="satellite-select" class="form-select">
|
||||
<option value="">Выберите спутник</option>
|
||||
{% for satellite in satellites %}
|
||||
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="data-input" class="form-label">Данные</label>
|
||||
<input type="text" id="data-input" class="form-control input-field"
|
||||
placeholder="Вставьте строку данных и нажмите Enter">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-section">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5>Таблица данных <span id="row-count" class="badge bg-primary">0</span></h5>
|
||||
</div>
|
||||
<div class="btn-group-custom">
|
||||
<button id="export-xlsx" class="btn btn-success">
|
||||
<i class="bi bi-file-earmark-excel"></i> Сохранить в Excel
|
||||
</button>
|
||||
<button id="clear-table" class="btn btn-danger ms-2">
|
||||
<i class="bi bi-trash"></i> Очистить таблицу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="data-table"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||
<script src="{% static 'sheetjs/xlsx.full.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize Tabulator
|
||||
const table = new Tabulator("#data-table", {
|
||||
layout: "fitDataStretch",
|
||||
height: "500px",
|
||||
placeholder: "Нет данных. Введите данные в поле выше и нажмите Enter.",
|
||||
headerWordWrap: true,
|
||||
columns: [
|
||||
{title: "Объект наблюдения", field: "object_name", minWidth: 180, widthGrow: 2, editor: "input"},
|
||||
{title: "Частота, МГц", field: "frequency", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||
{title: "Полоса, МГц", field: "freq_range", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||
{title: "Символьная скорость, БОД", field: "bod_velocity", minWidth: 120, widthGrow: 1.5, editor: "input"},
|
||||
{title: "Модуляция", field: "modulation", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||
{title: "ОСШ", field: "snr", minWidth: 70, widthGrow: 0.8, editor: "input"},
|
||||
{title: "Дата", field: "date", minWidth: 100, widthGrow: 1, editor: "input"},
|
||||
{title: "Время", field: "time", minWidth: 90, widthGrow: 1, editor: "input"},
|
||||
{title: "Зеркала", field: "mirrors", minWidth: 130, widthGrow: 1.5, editor: "input"},
|
||||
{title: "Местоопределение", field: "location", minWidth: 130, widthGrow: 1.5, editor: "input"},
|
||||
{title: "Координаты", field: "coordinates", minWidth: 150, widthGrow: 2, editor: "input"},
|
||||
],
|
||||
data: [],
|
||||
});
|
||||
|
||||
// Update row count
|
||||
function updateRowCount() {
|
||||
const count = table.getDataCount();
|
||||
document.getElementById('row-count').textContent = count;
|
||||
}
|
||||
|
||||
// Listen to table events
|
||||
table.on("rowAdded", updateRowCount);
|
||||
table.on("dataChanged", updateRowCount);
|
||||
|
||||
// Parse input string
|
||||
function parseInputString(inputStr) {
|
||||
const parts = inputStr.split(';');
|
||||
|
||||
if (parts.length < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse date and time (first part)
|
||||
const dateTimePart = parts[0].trim();
|
||||
const dateTimeMatch = dateTimePart.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{2}:\d{2}:\d{2})/);
|
||||
|
||||
if (!dateTimeMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = dateTimeMatch[1];
|
||||
const time = dateTimeMatch[2];
|
||||
|
||||
// Parse object name (second part)
|
||||
const objectName = parts[1].trim();
|
||||
|
||||
// Parse location (fourth part - "Позиция")
|
||||
// const location = parts[3].trim() || '-';
|
||||
const location = '-';
|
||||
|
||||
// Parse coordinates (fifth part)
|
||||
const coordsPart = parts[4].trim();
|
||||
const coordsMatch = coordsPart.match(/([-\d,]+)\s+([-\d,]+)/);
|
||||
|
||||
let coordinates = '-';
|
||||
if (coordsMatch) {
|
||||
const lat = coordsMatch[1].replace(',', '.');
|
||||
const lon = coordsMatch[2].replace(',', '.');
|
||||
coordinates = `${lat}, ${lon}`;
|
||||
}
|
||||
|
||||
return {
|
||||
date: date,
|
||||
time: time,
|
||||
object_name: objectName,
|
||||
location: location,
|
||||
coordinates: coordinates,
|
||||
};
|
||||
}
|
||||
|
||||
// Search for ObjItem data
|
||||
async function searchObjItemData(objectName, satelliteId, latitude, longitude) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
name: objectName,
|
||||
});
|
||||
|
||||
if (satelliteId) {
|
||||
params.append('satellite_id', satelliteId);
|
||||
}
|
||||
|
||||
if (latitude && longitude) {
|
||||
params.append('latitude', latitude);
|
||||
params.append('longitude', longitude);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/search-objitem/?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error searching ObjItem:', error);
|
||||
return { found: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Handle input
|
||||
const dataInput = document.getElementById('data-input');
|
||||
const satelliteSelect = document.getElementById('satellite-select');
|
||||
|
||||
dataInput.addEventListener('keypress', async function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
|
||||
const inputValue = this.value.trim();
|
||||
|
||||
if (!inputValue) {
|
||||
alert('Введите данные');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable input while processing
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
// Parse input
|
||||
const parsedData = parseInputString(inputValue);
|
||||
|
||||
if (!parsedData) {
|
||||
alert('Неверный формат данных. Проверьте формат строки.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Search for ObjItem data
|
||||
const satelliteId = satelliteSelect.value;
|
||||
|
||||
// Extract latitude and longitude from coordinates
|
||||
let latitude = null;
|
||||
let longitude = null;
|
||||
if (parsedData.coordinates && parsedData.coordinates !== '-') {
|
||||
const coordParts = parsedData.coordinates.split(',').map(c => c.trim());
|
||||
if (coordParts.length === 2) {
|
||||
latitude = coordParts[0];
|
||||
longitude = coordParts[1];
|
||||
}
|
||||
}
|
||||
|
||||
const objItemData = await searchObjItemData(
|
||||
parsedData.object_name,
|
||||
satelliteId,
|
||||
latitude,
|
||||
longitude
|
||||
);
|
||||
|
||||
// Show warning if object not found
|
||||
if (!objItemData.found) {
|
||||
console.warn('Объект не найден в базе данных:', parsedData.object_name);
|
||||
}
|
||||
|
||||
// Prepare row data
|
||||
const rowData = {
|
||||
object_name: parsedData.object_name || '-',
|
||||
date: parsedData.date || '-',
|
||||
time: parsedData.time || '-',
|
||||
location: parsedData.location || '-',
|
||||
coordinates: parsedData.coordinates || '-',
|
||||
frequency: objItemData.found && objItemData.frequency !== null ? objItemData.frequency : '-',
|
||||
freq_range: objItemData.found && objItemData.freq_range !== null ? objItemData.freq_range : '-',
|
||||
bod_velocity: objItemData.found && objItemData.bod_velocity !== null ? objItemData.bod_velocity : '-',
|
||||
modulation: objItemData.found && objItemData.modulation !== null ? objItemData.modulation : '-',
|
||||
snr: objItemData.found && objItemData.snr !== null ? objItemData.snr : '-',
|
||||
mirrors: objItemData.found && objItemData.mirrors !== null ? objItemData.mirrors : '-',
|
||||
};
|
||||
|
||||
// Add row to table
|
||||
table.addRow(rowData);
|
||||
|
||||
// Clear input
|
||||
this.value = '';
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обработке данных:', error);
|
||||
alert('Произошла ошибка при обработке данных. Проверьте консоль для деталей.');
|
||||
} finally {
|
||||
// Re-enable input
|
||||
this.disabled = false;
|
||||
this.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export to Excel
|
||||
document.getElementById('export-xlsx').addEventListener('click', function() {
|
||||
table.download("xlsx", "data_export.xlsx", {sheetName: "Данные"});
|
||||
});
|
||||
|
||||
// Clear table
|
||||
document.getElementById('clear-table').addEventListener('click', function() {
|
||||
if (confirm('Вы уверены, что хотите очистить таблицу?')) {
|
||||
table.clearData();
|
||||
updateRowCount();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize row count
|
||||
updateRowCount();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -17,9 +17,6 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Alert messages -->
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Внимание!</strong> Процесс заполнения данных может занять продолжительное время,
|
||||
так как выполняются запросы к внешнему сайту Lyngsat. Пожалуйста, дождитесь завершения операции.
|
||||
|
||||
@@ -1,422 +1,483 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Список объектов{% endblock %}
|
||||
{% block title %}Главная страница{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.filter-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.filter-group-title {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.conditional-filters {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.table-container.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-generate {
|
||||
font-size: 1.1rem;
|
||||
padding: 12px 40px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filter-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
margin: 2px;
|
||||
}
|
||||
|
||||
.active-filters {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.mark-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.mark-present {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.mark-absent {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h2>Список объектов</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="container-fluid px-4 py-3">
|
||||
<h2 class="mb-4">Главная страница - Динамический отчёт</h2>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<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 style="min-width: 300px; flex-grow: 1;">
|
||||
<label for="toolbar-search" class="form-label mb-0">Поиск:</label>
|
||||
<input type="text" id="toolbar-search" class="form-control" placeholder="Поиск по имени, местоположению..." value="{{ search_query|default:'' }}">
|
||||
</div>
|
||||
<div class="ms-auto">
|
||||
<button type="button" class="btn btn-outline-primary" onclick="performSearch()">Найти</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">Очистить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Filters Sidebar - Made narrower -->
|
||||
<div class="col-md-2">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Фильтры</h5>
|
||||
<!-- Фильтры -->
|
||||
<div class="filter-section">
|
||||
<form method="get" id="filter-form">
|
||||
<!-- Satellite Selection - Multi-select -->
|
||||
<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 class="row">
|
||||
<!-- Основной выбор: Объекти или Объекты -->
|
||||
<div class="col-12">
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-title">1. Тип отображения</div>
|
||||
<div class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="display_mode" id="mode_sources" value="sources"
|
||||
{% if display_mode == 'sources' %}checked{% endif %} onchange="updateConditionalFilters()">
|
||||
<label class="btn btn-outline-primary" for="mode_sources">Объекти (Source)</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="display_mode" id="mode_objitems" value="objitems"
|
||||
{% if display_mode == 'objitems' %}checked{% endif %} onchange="updateConditionalFilters()">
|
||||
<label class="btn btn-outline-primary" for="mode_objitems">Объекты (ObjItem)</label>
|
||||
</div>
|
||||
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="4">
|
||||
{% for satellite in satellites %}
|
||||
<option value="{{ satellite.id }}"
|
||||
{% if satellite.id in selected_satellites %}selected{% endif %}>
|
||||
{{ satellite.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Общие фильтры -->
|
||||
<div class="col-md-6">
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-title">2. Общие фильтры</div>
|
||||
|
||||
<!-- Спутники -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Спутники:</label>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('satellite_id', true)">Все</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('satellite_id', false)">Снять</button>
|
||||
</div>
|
||||
<select name="satellite_id" class="form-select form-select-sm" multiple size="5">
|
||||
{% for sat in satellites %}
|
||||
<option value="{{ sat.id }}" {% if sat.id in selected_satellites %}selected{% endif %}>
|
||||
{{ sat.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Frequency Filter -->
|
||||
<div class="mb-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Условные фильтры (зависят от типа отображения) -->
|
||||
<div class="col-md-6">
|
||||
<!-- Фильтры для Объектов -->
|
||||
<div class="filter-group" id="sources-filters" style="display: none;">
|
||||
<div class="filter-group-title">3. Фильтры для Объектов</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Усреднённые координаты:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_coords_average" id="avg_all" value="" {% if not has_coords_average %}checked{% endif %}>
|
||||
<label class="form-check-label" for="avg_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_coords_average" id="avg_yes" value="1" {% if has_coords_average == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="avg_yes">Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_coords_average" id="avg_no" value="0" {% if has_coords_average == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="avg_no">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Координаты Кубсата:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_kupsat" id="kup_all" value="" {% if not has_kupsat %}checked{% endif %}>
|
||||
<label class="form-check-label" for="kup_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_kupsat" id="kup_yes" value="1" {% if has_kupsat == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="kup_yes">Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_kupsat" id="kup_no" value="0" {% if has_kupsat == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="kup_no">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Координаты оперативников:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_valid" id="val_all" value="" {% if not has_valid %}checked{% endif %}>
|
||||
<label class="form-check-label" for="val_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_valid" id="val_yes" value="1" {% if has_valid == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="val_yes">Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_valid" id="val_no" value="0" {% if has_valid == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="val_no">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Справочные координаты:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_reference" id="ref_all" value="" {% if not has_reference %}checked{% endif %}>
|
||||
<label class="form-check-label" for="ref_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_reference" id="ref_yes" value="1" {% if has_reference == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="ref_yes">Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_reference" id="ref_no" value="0" {% if has_reference == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="ref_no">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Наличие сигнала для объектов -->
|
||||
<div class="conditional-filters">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Отображать наличие сигнала:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="show_marks" id="marks_src" value="1"
|
||||
{% if show_marks == '1' %}checked{% endif %} onchange="toggleMarksFilters()">
|
||||
<label class="form-check-label" for="marks_src">Да</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="marks-additional-filters-src" style="{% if show_marks != '1' %}display:none;{% endif %}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Период отметок:</label>
|
||||
<input type="datetime-local" name="marks_date_from" class="form-control form-control-sm mb-1" value="{{ marks_date_from }}">
|
||||
<input type="datetime-local" name="marks_date_to" class="form-control form-control-sm" value="{{ marks_date_to }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Статус отметок:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="marks_status" id="marks_all_src" value="" {% if not marks_status %}checked{% endif %}>
|
||||
<label class="form-check-label" for="marks_all_src">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="marks_status" id="marks_present_src" value="present" {% if marks_status == 'present' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="marks_present_src">✓ Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="marks_status" id="marks_absent_src" value="absent" {% if marks_status == 'absent' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="marks_absent_src">✗ Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры для Объектов -->
|
||||
<div class="filter-group" id="objitems-filters" style="display: none;">
|
||||
<div class="filter-group-title">3. Фильтры для Объектов</div>
|
||||
|
||||
<!-- Дата геолокации -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Дата геолокации:</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm mb-1" value="{{ date_from }}">
|
||||
<input type="date" name="date_to" class="form-control form-control-sm" value="{{ date_to }}">
|
||||
</div>
|
||||
|
||||
<!-- Частота -->
|
||||
<div class="mb-3">
|
||||
<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|default:'' }}">
|
||||
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm" placeholder="До" value="{{ freq_max|default:'' }}">
|
||||
<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>
|
||||
|
||||
<!-- Range Filter -->
|
||||
<div class="mb-2">
|
||||
<!-- Полоса -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Полоса, МГц:</label>
|
||||
<input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ range_min|default:'' }}">
|
||||
<input type="number" step="0.001" name="range_max" class="form-control form-control-sm" placeholder="До" value="{{ range_max|default:'' }}">
|
||||
<input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ range_min }}">
|
||||
<input type="number" step="0.001" name="range_max" class="form-control form-control-sm" placeholder="До" value="{{ range_max }}">
|
||||
</div>
|
||||
|
||||
<!-- SNR Filter -->
|
||||
<div class="mb-2">
|
||||
<label class="form-label">ОСШ:</label>
|
||||
<input type="number" step="0.001" name="snr_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ snr_min|default:'' }}">
|
||||
<input type="number" step="0.001" name="snr_max" class="form-control form-control-sm" placeholder="До" value="{{ snr_max|default:'' }}">
|
||||
</div>
|
||||
|
||||
<!-- Symbol Rate Filter -->
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Сим. v, БОД:</label>
|
||||
<input type="number" step="0.001" name="bod_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ bod_min|default:'' }}">
|
||||
<input type="number" step="0.001" name="bod_max" class="form-control form-control-sm" placeholder="До" value="{{ bod_max|default:'' }}">
|
||||
</div>
|
||||
|
||||
<!-- Removed old search input as it's now in the toolbar -->
|
||||
|
||||
<!-- Modulation Filter -->
|
||||
<div class="mb-2">
|
||||
<!-- Модуляция -->
|
||||
<div class="mb-3">
|
||||
<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', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('modulation', false)">Снять</button>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('modulation', true)">Все</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('modulation', false)">Снять</button>
|
||||
</div>
|
||||
<select name="modulation" class="form-select form-select-sm mb-2" multiple size="4">
|
||||
<select name="modulation" class="form-select form-select-sm" multiple size="4">
|
||||
{% for mod in modulations %}
|
||||
<option value="{{ mod.id }}"
|
||||
{% if mod.id in selected_modulations %}selected{% endif %}>
|
||||
<option value="{{ mod.id }}" {% if mod.id in selected_modulations %}selected{% endif %}>
|
||||
{{ mod.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Polarization Filter -->
|
||||
<div class="mb-2">
|
||||
<!-- Поляризация -->
|
||||
<div class="mb-3">
|
||||
<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', true)">Выбрать</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('polarization', false)">Снять</button>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('polarization', true)">Все</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll('polarization', false)">Снять</button>
|
||||
</div>
|
||||
<select name="polarization" class="form-select form-select-sm mb-2" multiple size="4">
|
||||
<select name="polarization" class="form-select form-select-sm" multiple size="4">
|
||||
{% for pol in polarizations %}
|
||||
<option value="{{ pol.id }}"
|
||||
{% if pol.id in selected_polarizations %}selected{% endif %}>
|
||||
<option value="{{ pol.id }}" {% if pol.id in selected_polarizations %}selected{% endif %}>
|
||||
{{ pol.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Kubsat Coordinates Filter -->
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Координаты Кубсата:</label>
|
||||
<div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_1" value="1"
|
||||
{% if has_kupsat == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_kupsat_1">Есть</label>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Координаты геолокации:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_geo" id="geo_all" value="" {% if not has_geo %}checked{% endif %}>
|
||||
<label class="form-check-label" for="geo_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_geo" id="geo_yes" value="1" {% if has_geo == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="geo_yes">Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_geo" id="geo_no" value="0" {% if has_geo == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="geo_no">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Связь с LyngSat:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_lyngsat" id="lyng_all" value="" {% if not has_lyngsat %}checked{% endif %}>
|
||||
<label class="form-check-label" for="lyng_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_lyngsat" id="lyng_yes" value="1" {% if has_lyngsat == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="lyng_yes">Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_lyngsat" id="lyng_no" value="0" {% if has_lyngsat == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="lyng_no">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Наличие сигнала -->
|
||||
<div class="conditional-filters">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Отображать наличие сигнала:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="show_marks" id="marks_obj" value="1"
|
||||
{% if show_marks == '1' %}checked{% endif %} onchange="toggleMarksFilters()">
|
||||
<label class="form-check-label" for="marks_obj">Да</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="marks-additional-filters" style="{% if show_marks != '1' %}display:none;{% endif %}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Период отметок:</label>
|
||||
<input type="datetime-local" name="marks_date_from" class="form-control form-control-sm mb-1" value="{{ marks_date_from }}">
|
||||
<input type="datetime-local" name="marks_date_to" class="form-control form-control-sm" value="{{ marks_date_to }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Статус отметок:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="marks_status" id="marks_all" value="" {% if not marks_status %}checked{% endif %}>
|
||||
<label class="form-check-label" for="marks_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="marks_status" id="marks_present" value="present" {% if marks_status == 'present' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="marks_present">✓ Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="marks_status" id="marks_absent" value="absent" {% if marks_status == 'absent' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="marks_absent">✗ Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="has_kupsat" id="has_kupsat_0" value="0"
|
||||
{% if has_kupsat == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_kupsat_0">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Valid Coordinates Filter -->
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Координаты опер. отдела:</label>
|
||||
<div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="has_valid" id="has_valid_1" value="1"
|
||||
{% if has_valid == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_valid_1">Есть</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="has_valid" id="has_valid_0" value="0"
|
||||
{% if has_valid == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_valid_0">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Per Page -->
|
||||
<div class="mb-2">
|
||||
<label for="items-per-page" class="form-label">Элементов:</label>
|
||||
<select name="items_per_page" id="items-per-page" class="form-select form-select-sm" onchange="document.getElementById('filter-form').submit();">
|
||||
<!-- Настройки отображения -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="filter-group">
|
||||
<div class="filter-group-title">4. Настройки отображения</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Элементов на странице:</label>
|
||||
<select name="items_per_page" class="form-select form-select-sm">
|
||||
{% for option in available_items_per_page %}
|
||||
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>
|
||||
{{ option }}
|
||||
</option>
|
||||
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>{{ option }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply Filters and Reset Buttons -->
|
||||
<div class="d-grid gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
|
||||
<a href="?" class="btn btn-secondary btn-sm">Сбросить</a>
|
||||
<!-- Кнопки -->
|
||||
<div class="row">
|
||||
<div class="col-12 text-center">
|
||||
<button type="submit" class="btn btn-primary btn-generate">
|
||||
<i class="bi bi-table"></i> Сформировать таблицу
|
||||
</button>
|
||||
<a href="?" class="btn btn-secondary ms-2">Сбросить фильтры</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Table -->
|
||||
<div class="col-md-10">
|
||||
<div class="card h-100">
|
||||
<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" 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" class="form-check-input">
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in processed_objects %}
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input item-checkbox" value="{{ item.id }}">
|
||||
</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.satellite_name }}</td>
|
||||
<td>{{ item.frequency }}</td>
|
||||
<td>{{ item.freq_range }}</td>
|
||||
<td>{{ item.polarization }}</td>
|
||||
<td>{{ item.bod_velocity }}</td>
|
||||
<td>{{ item.modulation }}</td>
|
||||
<td>{{ item.snr }}</td>
|
||||
<td>{{ item.geo_coords }}</td>
|
||||
<td>{{ item.kupsat_coords }}</td>
|
||||
<td>{{ item.valid_coords }}</td>
|
||||
<td>{{ item.distance_geo_kup }}</td>
|
||||
<td>{{ item.distance_geo_valid }}</td>
|
||||
<td>{{ item.distance_kup_valid }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="15" class="text-center py-4">
|
||||
{% if selected_satellite_id %}
|
||||
Нет данных для выбранных фильтров
|
||||
{% else %}
|
||||
Пожалуйста, выберите спутник для отображения данных
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<nav aria-label="Page navigation" class="px-3 pb-3">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% 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 %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ num }}</span>
|
||||
</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>
|
||||
</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 }}">Следующая</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>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination Info -->
|
||||
<!-- Активные фильтры -->
|
||||
{% if page_obj %}
|
||||
<div class="px-3 pb-3 d-flex justify-content-between align-items-center">
|
||||
<div>Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</div>
|
||||
<div class="active-filters">
|
||||
<strong>Активные фильтры:</strong>
|
||||
{% if display_mode == 'sources' %}
|
||||
<span class="filter-badge">Объекти</span>
|
||||
{% else %}
|
||||
<span class="filter-badge">Объекты</span>
|
||||
{% endif %}
|
||||
{% if selected_satellites %}
|
||||
<span class="filter-badge">Спутники: {{ selected_satellites|length }}</span>
|
||||
{% endif %}
|
||||
{% if date_from or date_to %}
|
||||
<span class="filter-badge">Дата геолокации</span>
|
||||
{% endif %}
|
||||
{% if show_marks == '1' %}
|
||||
<span class="filter-badge">С наличие сигналами</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Таблица (показывается только после генерации) -->
|
||||
<div class="table-container {% if page_obj %}show{% endif %}">
|
||||
{% if display_mode == 'sources' %}
|
||||
{% include 'mainapp/components/_sources_table.html' %}
|
||||
{% else %}
|
||||
{% include 'mainapp/components/_objitems_table.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
<!-- JavaScript for checkbox functionality and filters -->
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function selectAll(name, select) {
|
||||
const element = document.querySelector(`select[name="${name}"]`);
|
||||
if (element) {
|
||||
for (let option of element.options) {
|
||||
option.selected = select;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateConditionalFilters() {
|
||||
const mode = document.querySelector('input[name="display_mode"]:checked').value;
|
||||
const sourcesFilters = document.getElementById('sources-filters');
|
||||
const objitemsFilters = document.getElementById('objitems-filters');
|
||||
|
||||
if (mode === 'sources') {
|
||||
sourcesFilters.style.display = 'block';
|
||||
objitemsFilters.style.display = 'none';
|
||||
} else {
|
||||
sourcesFilters.style.display = 'none';
|
||||
objitemsFilters.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMarksFilters() {
|
||||
const mode = document.querySelector('input[name="display_mode"]:checked')?.value;
|
||||
|
||||
if (mode === 'sources') {
|
||||
const checkbox = document.getElementById('marks_src');
|
||||
const filters = document.getElementById('marks-additional-filters-src');
|
||||
if (checkbox && filters) {
|
||||
filters.style.display = checkbox.checked ? 'block' : 'none';
|
||||
}
|
||||
} else if (mode === 'objitems') {
|
||||
const checkbox = document.getElementById('marks_obj');
|
||||
const filters = document.getElementById('marks-additional-filters');
|
||||
if (checkbox && filters) {
|
||||
filters.style.display = checkbox.checked ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Select/Deselect all checkboxes
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
|
||||
|
||||
if (selectAllCheckbox && itemCheckboxes.length > 0) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
itemCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = selectAllCheckbox.checked;
|
||||
});
|
||||
});
|
||||
|
||||
// Update select all checkbox state based on individual selections
|
||||
itemCheckboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
const allChecked = Array.from(itemCheckboxes).every(cb => cb.checked);
|
||||
selectAllCheckbox.checked = allChecked;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle multiple selection for modulations and polarizations
|
||||
const modulationSelect = document.querySelector('select[name="modulation"]');
|
||||
const polarizationSelect = document.querySelector('select[name="polarization"]');
|
||||
|
||||
// Prevent deselecting all options when Ctrl+click is used
|
||||
if (modulationSelect) {
|
||||
modulationSelect.addEventListener('change', function(e) {
|
||||
document.getElementById('filter-form').submit();
|
||||
});
|
||||
}
|
||||
|
||||
if (polarizationSelect) {
|
||||
polarizationSelect.addEventListener('change', function(e) {
|
||||
document.getElementById('filter-form').submit();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle kubsat and valid coords checkboxes (mutually exclusive)
|
||||
// Add a function to handle radio-like behavior for these checkboxes
|
||||
function setupRadioLikeCheckboxes(name) {
|
||||
const checkboxes = document.querySelectorAll(`input[name="${name}"]`);
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
// If this checkbox is checked, uncheck the other
|
||||
if (this.checked) {
|
||||
checkboxes.forEach(other => {
|
||||
if (other !== this) {
|
||||
other.checked = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If both are unchecked, no action needed
|
||||
}
|
||||
document.getElementById('filter-form').submit();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setupRadioLikeCheckboxes('has_kupsat');
|
||||
setupRadioLikeCheckboxes('has_valid');
|
||||
|
||||
// Function to select/deselect all options in a select element
|
||||
window.selectAllOptions = function(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;
|
||||
}
|
||||
document.getElementById('filter-form').submit();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to update the page when satellite selection changes
|
||||
function updateSatelliteSelection() {
|
||||
document.getElementById('filter-form').submit();
|
||||
}
|
||||
|
||||
// Get all current filter values and return as URL parameters
|
||||
function getAllFilterParams() {
|
||||
const form = document.getElementById('filter-form');
|
||||
const searchValue = document.getElementById('toolbar-search').value;
|
||||
|
||||
// Create URLSearchParams object from the form
|
||||
const params = new URLSearchParams(new FormData(form));
|
||||
|
||||
// Add search value from toolbar if present
|
||||
if (searchValue.trim() !== '') {
|
||||
params.set('search', searchValue);
|
||||
} else {
|
||||
// Remove search parameter if empty
|
||||
params.delete('search');
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
// Function to perform search
|
||||
window.performSearch = function() {
|
||||
const filterParams = getAllFilterParams();
|
||||
window.location.search = filterParams;
|
||||
};
|
||||
|
||||
// Function to clear search
|
||||
window.clearSearch = function() {
|
||||
// Clear only the search input in the toolbar
|
||||
document.getElementById('toolbar-search').value = '';
|
||||
// Submit the form to update the results
|
||||
const filterParams = getAllFilterParams();
|
||||
window.location.search = filterParams;
|
||||
};
|
||||
|
||||
// Handle Enter key in toolbar search
|
||||
const toolbarSearch = document.getElementById('toolbar-search');
|
||||
if (toolbarSearch) {
|
||||
toolbarSearch.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listener to satellite select for immediate update
|
||||
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
|
||||
if (satelliteSelect) {
|
||||
satelliteSelect.addEventListener('change', function() {
|
||||
updateSatelliteSelection();
|
||||
});
|
||||
}
|
||||
updateConditionalFilters();
|
||||
toggleMarksFilters();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
450
dbapp/mainapp/templates/mainapp/home_old.html
Normal file
450
dbapp/mainapp/templates/mainapp/home_old.html
Normal file
@@ -0,0 +1,450 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
|
||||
{% block title %}Главная страница{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h2>Главная страница</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Filters Sidebar -->
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Фильтры</h5>
|
||||
<form method="get" id="filter-form">
|
||||
<!-- Display Mode -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Отображение:</label>
|
||||
<select name="display_mode" class="form-select form-select-sm" onchange="document.getElementById('filter-form').submit();">
|
||||
<option value="sources" {% if display_mode == 'sources' %}selected{% endif %}>Список источников</option>
|
||||
<option value="objitems" {% if display_mode == 'objitems' %}selected{% endif %}>Список объектов</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Satellite Selection -->
|
||||
<div class="mb-3">
|
||||
<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" multiple size="5">
|
||||
{% for satellite in satellites %}
|
||||
<option value="{{ satellite.id }}"
|
||||
{% if satellite.id in selected_satellites %}selected{% endif %}>
|
||||
{{ satellite.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Дата геолокации:</label>
|
||||
<input type="date" name="date_from" class="form-control form-control-sm mb-1" placeholder="От" value="{{ date_from }}">
|
||||
<input type="date" name="date_to" class="form-control form-control-sm" placeholder="До" value="{{ date_to }}">
|
||||
</div>
|
||||
|
||||
<!-- Frequency Filter -->
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
|
||||
<!-- Range Filter -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Полоса, МГц:</label>
|
||||
<input type="number" step="0.001" name="range_min" class="form-control form-control-sm mb-1" placeholder="От" value="{{ range_min }}">
|
||||
<input type="number" step="0.001" name="range_max" class="form-control form-control-sm" placeholder="До" value="{{ range_max }}">
|
||||
</div>
|
||||
|
||||
<!-- Modulation Filter -->
|
||||
<div class="mb-3">
|
||||
<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', true)">Все</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('modulation', false)">Снять</button>
|
||||
</div>
|
||||
<select name="modulation" class="form-select form-select-sm" multiple size="4">
|
||||
{% for mod in modulations %}
|
||||
<option value="{{ mod.id }}"
|
||||
{% if mod.id in selected_modulations %}selected{% endif %}>
|
||||
{{ mod.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Polarization Filter -->
|
||||
<div class="mb-3">
|
||||
<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', true)">Все</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAllOptions('polarization', false)">Снять</button>
|
||||
</div>
|
||||
<select name="polarization" class="form-select form-select-sm" multiple size="4">
|
||||
{% for pol in polarizations %}
|
||||
<option value="{{ pol.id }}"
|
||||
{% if pol.id in selected_polarizations %}selected{% endif %}>
|
||||
{{ pol.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Marks Filters -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Отображать отметки:</label>
|
||||
<div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="show_marks" id="show_marks_0" value="0"
|
||||
{% if show_marks == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="show_marks_0">Нет</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="show_marks" id="show_marks_1" value="1"
|
||||
{% if show_marks == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="show_marks_1">Да</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marks Date Range (shown only if show_marks is enabled) -->
|
||||
<div class="mb-3" id="marks-date-filter" style="{% if show_marks != '1' %}display:none;{% endif %}">
|
||||
<label class="form-label">Период отметок:</label>
|
||||
<input type="date" name="marks_date_from" class="form-control form-control-sm mb-1" placeholder="От" value="{{ marks_date_from }}">
|
||||
<input type="date" name="marks_date_to" class="form-control form-control-sm" placeholder="До" value="{{ marks_date_to }}">
|
||||
</div>
|
||||
|
||||
<!-- Marks Status Filter -->
|
||||
<div class="mb-3" id="marks-status-filter" style="{% if show_marks != '1' %}display:none;{% endif %}">
|
||||
<label class="form-label">Статус отметок:</label>
|
||||
<div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="marks_status" id="marks_status_all" value=""
|
||||
{% if not marks_status %}checked{% endif %}>
|
||||
<label class="form-check-label" for="marks_status_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="marks_status" id="marks_status_present" value="present"
|
||||
{% if marks_status == 'present' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="marks_status_present">✓ Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="marks_status" id="marks_status_absent" value="absent"
|
||||
{% if marks_status == 'absent' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="marks_status_absent">✗ Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Coordinates Filters -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Координаты геолокации:</label>
|
||||
<div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_geo" id="has_geo_all" value=""
|
||||
{% if not has_geo %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_geo_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_geo" id="has_geo_1" value="1"
|
||||
{% if has_geo == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_geo_1">Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_geo" id="has_geo_0" value="0"
|
||||
{% if has_geo == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_geo_0">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Координаты Кубсата:</label>
|
||||
<div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_kupsat" id="has_kupsat_all" value=""
|
||||
{% if not has_kupsat %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_kupsat_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_kupsat" id="has_kupsat_1" value="1"
|
||||
{% if has_kupsat == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_kupsat_1">Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_kupsat" id="has_kupsat_0" value="0"
|
||||
{% if has_kupsat == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_kupsat_0">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Координаты опер. отдела:</label>
|
||||
<div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_valid" id="has_valid_all" value=""
|
||||
{% if not has_valid %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_valid_all">Все</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_valid" id="has_valid_1" value="1"
|
||||
{% if has_valid == '1' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_valid_1">Есть</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="has_valid" id="has_valid_0" value="0"
|
||||
{% if has_valid == '0' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="has_valid_0">Нет</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Per Page -->
|
||||
<div class="mb-3">
|
||||
<label for="items-per-page" class="form-label">Элементов на странице:</label>
|
||||
<select name="items_per_page" id="items-per-page" class="form-select form-select-sm">
|
||||
{% for option in available_items_per_page %}
|
||||
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>
|
||||
{{ option }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Apply Filters and Reset Buttons -->
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
|
||||
<a href="?" class="btn btn-secondary btn-sm">Сбросить</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="col-md-9">
|
||||
<div class="card h-100">
|
||||
<div class="card-body p-0">
|
||||
{% if display_mode == 'sources' %}
|
||||
<!-- Sources Table -->
|
||||
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
|
||||
<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">ID</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>
|
||||
{% for source in processed_sources %}
|
||||
<tr>
|
||||
<td><a href="{% url 'mainapp:source_update' source.id %}">{{ source.id }}</a></td>
|
||||
<td>{{ source.satellites }}</td>
|
||||
<td>{{ source.objitem_count }}</td>
|
||||
<td>{{ source.coords_average }}</td>
|
||||
<td>{{ source.coords_kupsat }}</td>
|
||||
<td>{{ source.coords_valid }}</td>
|
||||
<td>{{ source.created_at|date:"Y-m-d H:i" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-4">
|
||||
Нет данных для выбранных фильтров
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- ObjItems Table -->
|
||||
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
|
||||
<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">ID</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>
|
||||
{% if show_marks == '1' %}
|
||||
<th scope="col">Отметки</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>{{ item.kupsat_coords }}</td>
|
||||
<td>{{ item.valid_coords }}</td>
|
||||
<td>
|
||||
{% if item.source_id %}
|
||||
<a href="{% url 'mainapp:source_update' item.source_id %}">{{ item.source_id }}</a>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if show_marks == '1' %}
|
||||
<td>
|
||||
{% if item.marks %}
|
||||
<div class="marks-list">
|
||||
{% for mark in item.marks %}
|
||||
<div class="mark-item mb-1">
|
||||
<span class="badge {% if mark.mark %}bg-success{% else %}bg-danger{% endif %}">
|
||||
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
|
||||
</span>
|
||||
<small class="text-muted d-block">
|
||||
{{ mark.timestamp|date:"d.m.Y H:i" }}
|
||||
</small>
|
||||
<small class="text-muted d-block">
|
||||
{{ 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' %}15{% else %}14{% endif %}" class="text-center py-4">
|
||||
Нет данных для выбранных фильтров
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<nav aria-label="Page navigation" class="px-3 pb-3">
|
||||
<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 %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ num }}</span>
|
||||
</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>
|
||||
</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 }}">Следующая</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>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination Info -->
|
||||
{% if page_obj %}
|
||||
<div class="px-3 pb-3 text-center">
|
||||
<small class="text-muted">Показано {{ page_obj.start_index }}-{{ page_obj.end_index }} из {{ page_obj.paginator.count }} записей</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.marks-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.mark-item {
|
||||
padding: 4px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
.mark-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Function to select/deselect all options in a select element
|
||||
window.selectAllOptions = function(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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle marks filters visibility
|
||||
const showMarksRadios = document.querySelectorAll('input[name="show_marks"]');
|
||||
const marksDateFilter = document.getElementById('marks-date-filter');
|
||||
const marksStatusFilter = document.getElementById('marks-status-filter');
|
||||
|
||||
showMarksRadios.forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
if (this.value === '1') {
|
||||
marksDateFilter.style.display = 'block';
|
||||
marksStatusFilter.style.display = 'block';
|
||||
} else {
|
||||
marksDateFilter.style.display = 'none';
|
||||
marksStatusFilter.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
784
dbapp/mainapp/templates/mainapp/kubsat.html
Normal file
784
dbapp/mainapp/templates/mainapp/kubsat.html
Normal file
@@ -0,0 +1,784 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Кубсат{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h2>Кубсат</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Форма фильтров -->
|
||||
<form method="get" id="filterForm" class="mb-4">
|
||||
<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>
|
||||
|
||||
<!-- Успех 1 (фиктивный) -->
|
||||
<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>
|
||||
|
||||
<!-- Успех 2 (фиктивный) -->
|
||||
<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>
|
||||
<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: 75vh; 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: 100px;">Кол-во точек</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 %}">
|
||||
|
||||
<!-- ID Source (только для первой строки источника) -->
|
||||
{% 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-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</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 }}, {{ objitem_data.objitem.geo_obj.coords.x }}
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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}"]`));
|
||||
|
||||
if (sourceRows.length === 1) {
|
||||
// Последняя строка источника - просто удаляем
|
||||
row.remove();
|
||||
} else if (isFirstInSource) {
|
||||
// Удаляем первую строку - нужно перенести ячейки с rowspan на следующую строку
|
||||
const nextRow = sourceRows[1];
|
||||
|
||||
// Находим ячейки с rowspan (ID Source, Тип объекта, Количество точек)
|
||||
const sourceIdCell = row.querySelector('.source-id-cell');
|
||||
const sourceTypeCell = row.querySelector('.source-type-cell');
|
||||
const sourceCountCell = row.querySelector('.source-count-cell');
|
||||
|
||||
if (sourceIdCell && sourceTypeCell && sourceCountCell) {
|
||||
const currentRowspan = parseInt(sourceIdCell.getAttribute('rowspan'));
|
||||
const newRowspan = currentRowspan - 1;
|
||||
|
||||
// Создаем новые ячейки для следующей строки
|
||||
const newSourceIdCell = sourceIdCell.cloneNode(true);
|
||||
const newSourceTypeCell = sourceTypeCell.cloneNode(true);
|
||||
const newSourceCountCell = sourceCountCell.cloneNode(true);
|
||||
|
||||
newSourceIdCell.setAttribute('rowspan', newRowspan);
|
||||
newSourceTypeCell.setAttribute('rowspan', newRowspan);
|
||||
newSourceCountCell.setAttribute('rowspan', newRowspan);
|
||||
|
||||
// Обновляем счетчик точек
|
||||
newSourceCountCell.textContent = newRowspan;
|
||||
|
||||
// Вставляем ячейки в начало следующей строки
|
||||
nextRow.insertBefore(newSourceCountCell, nextRow.firstChild);
|
||||
nextRow.insertBefore(newSourceTypeCell, nextRow.firstChild);
|
||||
nextRow.insertBefore(newSourceIdCell, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем data-is-first-in-source для следующей строки
|
||||
nextRow.dataset.isFirstInSource = 'true';
|
||||
|
||||
// Удаляем текущую строку
|
||||
row.remove();
|
||||
} else {
|
||||
// Удаляем не первую строку - уменьшаем rowspan в первой строке
|
||||
const firstRow = sourceRows[0];
|
||||
const sourceIdCell = firstRow.querySelector('.source-id-cell');
|
||||
const sourceTypeCell = firstRow.querySelector('.source-type-cell');
|
||||
const sourceCountCell = firstRow.querySelector('.source-count-cell');
|
||||
|
||||
if (sourceIdCell && sourceTypeCell && sourceCountCell) {
|
||||
const currentRowspan = parseInt(sourceIdCell.getAttribute('rowspan'));
|
||||
const newRowspan = currentRowspan - 1;
|
||||
|
||||
sourceIdCell.setAttribute('rowspan', newRowspan);
|
||||
sourceTypeCell.setAttribute('rowspan', newRowspan);
|
||||
sourceCountCell.setAttribute('rowspan', newRowspan);
|
||||
|
||||
// Обновляем счетчик точек
|
||||
sourceCountCell.textContent = newRowspan;
|
||||
}
|
||||
|
||||
// Удаляем текущую строку
|
||||
row.remove();
|
||||
}
|
||||
|
||||
// Обновляем общий счетчик
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function removeSource(button) {
|
||||
// Удаляем все строки источника из таблицы (не из базы данных)
|
||||
const row = button.closest('tr');
|
||||
const sourceId = row.dataset.sourceId;
|
||||
|
||||
// Находим все строки с этим source_id и удаляем их
|
||||
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() {
|
||||
// Собираем ID оставшихся в таблице точек (ObjItem)
|
||||
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" %}';
|
||||
|
||||
// Добавляем CSRF токен
|
||||
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);
|
||||
}
|
||||
|
||||
// Добавляем ID точек
|
||||
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);
|
||||
}
|
||||
|
||||
// Функция для выбора/снятия всех опций в select
|
||||
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 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);
|
||||
}
|
||||
});
|
||||
|
||||
// Обновляем rowspan для каждой группы
|
||||
Object.keys(sourceGroups).forEach(sourceId => {
|
||||
const visibleRows = sourceGroups[sourceId];
|
||||
const newRowspan = visibleRows.length;
|
||||
|
||||
if (visibleRows.length > 0) {
|
||||
const firstRow = visibleRows[0];
|
||||
const sourceIdCell = firstRow.querySelector('.source-id-cell');
|
||||
const sourceTypeCell = firstRow.querySelector('.source-type-cell');
|
||||
const sourceOwnershipCell = firstRow.querySelector('.source-ownership-cell');
|
||||
const sourceCountCell = firstRow.querySelector('.source-count-cell');
|
||||
|
||||
if (sourceIdCell) sourceIdCell.setAttribute('rowspan', newRowspan);
|
||||
if (sourceTypeCell) sourceTypeCell.setAttribute('rowspan', newRowspan);
|
||||
if (sourceOwnershipCell) sourceOwnershipCell.setAttribute('rowspan', newRowspan);
|
||||
if (sourceCountCell) {
|
||||
sourceCountCell.setAttribute('rowspan', newRowspan);
|
||||
// Обновляем отображаемое количество точек
|
||||
sourceCountCell.textContent = newRowspan;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Очистка поиска
|
||||
function clearSearch() {
|
||||
document.getElementById('searchObjitemName').value = '';
|
||||
filterTableByName();
|
||||
}
|
||||
|
||||
// Обновляем счетчик при загрузке страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateCounter();
|
||||
});
|
||||
|
||||
// Функция для показа модального окна LyngSat
|
||||
function showLyngsatModal(lyngsatId) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('lyngsatModal'));
|
||||
modal.show();
|
||||
|
||||
const modalBody = document.getElementById('lyngsatModalBody');
|
||||
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||
|
||||
fetch('/api/lyngsat/' + lyngsatId + '/')
|
||||
.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.satellite + '</strong></td></tr>' +
|
||||
'<tr><td class="text-muted">Частота:</td><td><strong>' + data.frequency + ' МГц</strong></td></tr>' +
|
||||
'<tr><td class="text-muted">Поляризация:</td><td><span class="badge bg-info">' + data.polarization + '</span></td></tr>' +
|
||||
'<tr><td class="text-muted">Канал:</td><td>' + data.channel_info + '</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">' + data.modulation + '</span></td></tr>' +
|
||||
'<tr><td class="text-muted">Стандарт:</td><td><span class="badge bg-secondary">' + data.standard + '</span></td></tr>' +
|
||||
'<tr><td class="text-muted">Сим. скорость:</td><td><strong>' + data.sym_velocity + ' БОД</strong></td></tr>' +
|
||||
'<tr><td class="text-muted">FEC:</td><td>' + data.fec + '</td></tr>' +
|
||||
'</tbody></table></div></div></div>' +
|
||||
'<div class="col-12"><div class="card">' +
|
||||
'<div class="card-header bg-light"><strong><i class="bi bi-clock-history"></i> Дополнительная информация</strong></div>' +
|
||||
'<div class="card-body"><div class="row">' +
|
||||
'<div class="col-md-6"><p class="mb-2"><span class="text-muted">Последнее обновление:</span><br><strong>' + data.last_update + '</strong></p></div>' +
|
||||
'<div class="col-md-6">' + (data.url ? '<p class="mb-2"><span class="text-muted">Ссылка на объект:</span><br>' +
|
||||
'<a href="' + data.url + '" target="_blank" class="btn btn-sm btn-outline-primary">' +
|
||||
'<i class="bi bi-link-45deg"></i> Открыть на LyngSat</a></p>' : '') +
|
||||
'</div></div></div></div></div></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>
|
||||
|
||||
<style>
|
||||
.table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Стили для кнопок действий */
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Sticky header */
|
||||
.sticky-top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- LyngSat Data Modal -->
|
||||
<div class="modal fade" id="lyngsatModal" tabindex="-1" aria-labelledby="lyngsatModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="lyngsatModalLabel">
|
||||
<i class="bi bi-tv"></i> Данные объекта LyngSat
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
|
||||
aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="lyngsatModalBody">
|
||||
<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>
|
||||
{% endblock %}
|
||||
639
dbapp/mainapp/templates/mainapp/kubsat_tabs.html
Normal file
639
dbapp/mainapp/templates/mainapp/kubsat_tabs.html
Normal file
@@ -0,0 +1,639 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Кубсат{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h2>Кубсат</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Вкладки -->
|
||||
<ul class="nav nav-tabs mb-3" id="kubsatTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="requests-tab" data-bs-toggle="tab" data-bs-target="#requests"
|
||||
type="button" role="tab" aria-controls="requests" aria-selected="true">
|
||||
<i class="bi bi-list-task"></i> Заявки
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="filters-tab" data-bs-toggle="tab" data-bs-target="#filters"
|
||||
type="button" role="tab" aria-controls="filters" aria-selected="false">
|
||||
<i class="bi bi-funnel"></i> Фильтры и экспорт
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="kubsatTabsContent">
|
||||
<!-- Вкладка заявок -->
|
||||
<div class="tab-pane fade show active" id="requests" role="tabpanel" aria-labelledby="requests-tab">
|
||||
{% include 'mainapp/components/_source_requests_tab.html' %}
|
||||
</div>
|
||||
|
||||
<!-- Вкладка фильтров -->
|
||||
<div class="tab-pane fade" id="filters" role="tabpanel" aria-labelledby="filters-tab">
|
||||
{% include 'mainapp/components/_kubsat_filters_tab.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно создания/редактирования заявки -->
|
||||
<div class="modal fade" id="requestModal" tabindex="-1" aria-labelledby="requestModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="requestModalLabel">
|
||||
<i class="bi bi-plus-circle"></i> <span id="requestModalTitle">Создать заявку</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="requestForm">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" id="requestId" name="request_id" value="">
|
||||
|
||||
<!-- Источник и статус -->
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestSource" class="form-label">Источник (ID)</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">#</span>
|
||||
<input type="number" class="form-control" id="requestSourceId" name="source"
|
||||
placeholder="ID источника" min="1" onchange="loadSourceData()">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="loadSourceData()">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sourceCheckResult" class="form-text"></div>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestSatellite" class="form-label">Спутник</label>
|
||||
<select class="form-select" id="requestSatellite" name="satellite">
|
||||
<option value="">-</option>
|
||||
{% for sat in satellites %}
|
||||
<option value="{{ sat.id }}">{{ sat.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestStatus" class="form-label">Статус</label>
|
||||
<select class="form-select" id="requestStatus" name="status">
|
||||
<option value="planned">Запланировано</option>
|
||||
<option value="conducted">Проведён</option>
|
||||
<option value="successful">Успешно</option>
|
||||
<option value="no_correlation">Нет корреляции</option>
|
||||
<option value="no_signal">Нет сигнала в спектре</option>
|
||||
<option value="unsuccessful">Неуспешно</option>
|
||||
<option value="downloading">Скачивание</option>
|
||||
<option value="processing">Обработка</option>
|
||||
<option value="result_received">Результат получен</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestPriority" class="form-label">Приоритет</label>
|
||||
<select class="form-select" id="requestPriority" name="priority">
|
||||
<option value="low">Низкий</option>
|
||||
<option value="medium" selected>Средний</option>
|
||||
<option value="high">Высокий</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Частоты и перенос -->
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestDownlink" class="form-label">Downlink (МГц)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="requestDownlink" name="downlink"
|
||||
placeholder="Частота downlink">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestUplink" class="form-label">Uplink (МГц)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="requestUplink" name="uplink"
|
||||
placeholder="Частота uplink">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestTransfer" class="form-label">Перенос (МГц)</label>
|
||||
<input type="number" step="0.01" class="form-control" id="requestTransfer" name="transfer"
|
||||
placeholder="Перенос">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestRegion" class="form-label">Район</label>
|
||||
<input type="text" class="form-control" id="requestRegion" name="region"
|
||||
placeholder="Район/местоположение">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Данные источника (только для чтения) -->
|
||||
<div class="card bg-light mb-3" id="sourceDataCard" style="display: none;">
|
||||
<div class="card-header py-2">
|
||||
<small class="text-muted"><i class="bi bi-info-circle"></i> Данные источника</small>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-2">
|
||||
<label class="form-label small text-muted mb-0">Имя точки</label>
|
||||
<input type="text" class="form-control form-control-sm" id="requestObjitemName" readonly>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<label class="form-label small text-muted mb-0">Модуляция</label>
|
||||
<input type="text" class="form-control form-control-sm" id="requestModulation" readonly>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<label class="form-label small text-muted mb-0">Символьная скорость</label>
|
||||
<input type="text" class="form-control form-control-sm" id="requestSymbolRate" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Координаты ГСО -->
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestCoordsLat" class="form-label">Широта ГСО</label>
|
||||
<input type="number" step="0.000001" class="form-control" id="requestCoordsLat" name="coords_lat"
|
||||
placeholder="Например: 55.751244">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestCoordsLon" class="form-label">Долгота ГСО</label>
|
||||
<input type="number" step="0.000001" class="form-control" id="requestCoordsLon" name="coords_lon"
|
||||
placeholder="Например: 37.618423">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestCoordsSourceLat" class="form-label">Широта источника</label>
|
||||
<input type="number" step="0.000001" class="form-control" id="requestCoordsSourceLat" name="coords_source_lat"
|
||||
placeholder="Например: 55.751244">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestCoordsSourceLon" class="form-label">Долгота источника</label>
|
||||
<input type="number" step="0.000001" class="form-control" id="requestCoordsSourceLon" name="coords_source_lon"
|
||||
placeholder="Например: 37.618423">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Координаты объекта -->
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestCoordsObjectLat" class="form-label">Широта объекта</label>
|
||||
<input type="number" step="0.000001" class="form-control" id="requestCoordsObjectLat" name="coords_object_lat"
|
||||
placeholder="Например: 55.751244">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="requestCoordsObjectLon" class="form-label">Долгота объекта</label>
|
||||
<input type="number" step="0.000001" class="form-control" id="requestCoordsObjectLon" name="coords_object_lon"
|
||||
placeholder="Например: 37.618423">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Даты -->
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="requestPlannedAt" class="form-label">Дата и время планирования</label>
|
||||
<input type="datetime-local" class="form-control" id="requestPlannedAt" name="planned_at">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="requestDate" class="form-label">Дата заявки</label>
|
||||
<input type="date" class="form-control" id="requestDate" name="request_date">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="requestCardDate" class="form-label">Дата формирования карточки</label>
|
||||
<input type="date" class="form-control" id="requestCardDate" name="card_date">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Результаты -->
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="requestGsoSuccess" class="form-label">ГСО успешно?</label>
|
||||
<select class="form-select" id="requestGsoSuccess" name="gso_success">
|
||||
<option value="">-</option>
|
||||
<option value="true">Да</option>
|
||||
<option value="false">Нет</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="requestKubsatSuccess" class="form-label">Кубсат успешно?</label>
|
||||
<select class="form-select" id="requestKubsatSuccess" name="kubsat_success">
|
||||
<option value="">-</option>
|
||||
<option value="true">Да</option>
|
||||
<option value="false">Нет</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Комментарий -->
|
||||
<div class="mb-3">
|
||||
<label for="requestComment" class="form-label">Комментарий</label>
|
||||
<textarea class="form-control" id="requestComment" name="comment" rows="2"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveRequest()">
|
||||
<i class="bi bi-check-lg"></i> Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно истории статусов -->
|
||||
<div class="modal fade" id="historyModal" tabindex="-1" aria-labelledby="historyModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-info text-white">
|
||||
<h5 class="modal-title" id="historyModalLabel">
|
||||
<i class="bi bi-clock-history"></i> История изменений статуса
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="historyModalBody">
|
||||
<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>
|
||||
// Загрузка данных источника по ID
|
||||
function loadSourceData() {
|
||||
const sourceId = document.getElementById('requestSourceId').value;
|
||||
const resultDiv = document.getElementById('sourceCheckResult');
|
||||
const sourceDataCard = document.getElementById('sourceDataCard');
|
||||
|
||||
if (!sourceId) {
|
||||
resultDiv.innerHTML = '<span class="text-warning">Введите ID источника</span>';
|
||||
sourceDataCard.style.display = 'none';
|
||||
clearSourceData();
|
||||
return;
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = '<span class="text-muted">Загрузка...</span>';
|
||||
|
||||
fetch(`{% url 'mainapp:source_data_api' source_id=0 %}`.replace('0', sourceId))
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.found) {
|
||||
resultDiv.innerHTML = `<span class="text-success"><i class="bi bi-check-circle"></i> Источник #${sourceId} найден</span>`;
|
||||
|
||||
// Заполняем данные источника (только для чтения)
|
||||
document.getElementById('requestObjitemName').value = data.objitem_name || '-';
|
||||
document.getElementById('requestModulation').value = data.modulation || '-';
|
||||
document.getElementById('requestSymbolRate').value = data.symbol_rate || '-';
|
||||
|
||||
// Заполняем координаты ГСО (редактируемые)
|
||||
// if (data.coords_lat !== null) {
|
||||
// document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||
// }
|
||||
// if (data.coords_lon !== null) {
|
||||
// document.getElementById('requestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||
// }
|
||||
|
||||
// Заполняем данные из транспондера
|
||||
if (data.downlink) {
|
||||
document.getElementById('requestDownlink').value = data.downlink;
|
||||
}
|
||||
if (data.uplink) {
|
||||
document.getElementById('requestUplink').value = data.uplink;
|
||||
}
|
||||
if (data.transfer) {
|
||||
document.getElementById('requestTransfer').value = data.transfer;
|
||||
}
|
||||
if (data.satellite_id) {
|
||||
document.getElementById('requestSatellite').value = data.satellite_id;
|
||||
}
|
||||
|
||||
sourceDataCard.style.display = 'block';
|
||||
} else {
|
||||
resultDiv.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle"></i> Источник #${sourceId} не найден</span>`;
|
||||
sourceDataCard.style.display = 'none';
|
||||
clearSourceData();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
resultDiv.innerHTML = `<span class="text-danger"><i class="bi bi-x-circle"></i> Источник #${sourceId} не найден</span>`;
|
||||
sourceDataCard.style.display = 'none';
|
||||
clearSourceData();
|
||||
});
|
||||
}
|
||||
|
||||
// Очистка данных источника
|
||||
function clearSourceData() {
|
||||
document.getElementById('requestObjitemName').value = '';
|
||||
document.getElementById('requestModulation').value = '';
|
||||
document.getElementById('requestSymbolRate').value = '';
|
||||
document.getElementById('requestCoordsLat').value = '';
|
||||
document.getElementById('requestCoordsLon').value = '';
|
||||
document.getElementById('requestCoordsSourceLat').value = '';
|
||||
document.getElementById('requestCoordsSourceLon').value = '';
|
||||
document.getElementById('requestCoordsObjectLat').value = '';
|
||||
document.getElementById('requestCoordsObjectLon').value = '';
|
||||
document.getElementById('requestDownlink').value = '';
|
||||
document.getElementById('requestUplink').value = '';
|
||||
document.getElementById('requestTransfer').value = '';
|
||||
document.getElementById('requestRegion').value = '';
|
||||
document.getElementById('requestSatellite').value = '';
|
||||
document.getElementById('requestCardDate').value = '';
|
||||
}
|
||||
|
||||
// Открытие модального окна создания заявки
|
||||
function openCreateRequestModal(sourceId = null) {
|
||||
document.getElementById('requestModalTitle').textContent = 'Создать заявку';
|
||||
document.getElementById('requestForm').reset();
|
||||
document.getElementById('requestId').value = '';
|
||||
document.getElementById('sourceCheckResult').innerHTML = '';
|
||||
document.getElementById('sourceDataCard').style.display = 'none';
|
||||
clearSourceData();
|
||||
|
||||
if (sourceId) {
|
||||
document.getElementById('requestSourceId').value = sourceId;
|
||||
loadSourceData();
|
||||
}
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('requestModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Открытие модального окна редактирования заявки
|
||||
function openEditRequestModal(requestId) {
|
||||
document.getElementById('requestModalTitle').textContent = 'Редактировать заявку';
|
||||
document.getElementById('sourceCheckResult').innerHTML = '';
|
||||
|
||||
fetch(`/api/source-request/${requestId}/`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('requestId').value = data.id;
|
||||
document.getElementById('requestSourceId').value = data.source_id || '';
|
||||
document.getElementById('requestSatellite').value = data.satellite_id || '';
|
||||
document.getElementById('requestStatus').value = data.status;
|
||||
document.getElementById('requestPriority').value = data.priority;
|
||||
document.getElementById('requestPlannedAt').value = data.planned_at || '';
|
||||
document.getElementById('requestDate').value = data.request_date || '';
|
||||
document.getElementById('requestCardDate').value = data.card_date || '';
|
||||
document.getElementById('requestGsoSuccess').value = data.gso_success === null ? '' : data.gso_success.toString();
|
||||
document.getElementById('requestKubsatSuccess').value = data.kubsat_success === null ? '' : data.kubsat_success.toString();
|
||||
document.getElementById('requestComment').value = data.comment || '';
|
||||
|
||||
// Заполняем данные источника
|
||||
document.getElementById('requestObjitemName').value = data.objitem_name || '-';
|
||||
document.getElementById('requestModulation').value = data.modulation || '-';
|
||||
document.getElementById('requestSymbolRate').value = data.symbol_rate || '-';
|
||||
|
||||
// Заполняем частоты
|
||||
document.getElementById('requestDownlink').value = data.downlink || '';
|
||||
document.getElementById('requestUplink').value = data.uplink || '';
|
||||
document.getElementById('requestTransfer').value = data.transfer || '';
|
||||
document.getElementById('requestRegion').value = data.region || '';
|
||||
|
||||
// Заполняем координаты ГСО
|
||||
if (data.coords_lat !== null) {
|
||||
document.getElementById('requestCoordsLat').value = data.coords_lat.toFixed(6);
|
||||
} else {
|
||||
document.getElementById('requestCoordsLat').value = '';
|
||||
}
|
||||
if (data.coords_lon !== null) {
|
||||
document.getElementById('requestCoordsLon').value = data.coords_lon.toFixed(6);
|
||||
} else {
|
||||
document.getElementById('requestCoordsLon').value = '';
|
||||
}
|
||||
|
||||
// Заполняем координаты источника
|
||||
if (data.coords_source_lat !== null) {
|
||||
document.getElementById('requestCoordsSourceLat').value = data.coords_source_lat.toFixed(6);
|
||||
} else {
|
||||
document.getElementById('requestCoordsSourceLat').value = '';
|
||||
}
|
||||
if (data.coords_source_lon !== null) {
|
||||
document.getElementById('requestCoordsSourceLon').value = data.coords_source_lon.toFixed(6);
|
||||
} else {
|
||||
document.getElementById('requestCoordsSourceLon').value = '';
|
||||
}
|
||||
|
||||
// Заполняем координаты объекта
|
||||
if (data.coords_object_lat !== null) {
|
||||
document.getElementById('requestCoordsObjectLat').value = data.coords_object_lat.toFixed(6);
|
||||
} else {
|
||||
document.getElementById('requestCoordsObjectLat').value = '';
|
||||
}
|
||||
if (data.coords_object_lon !== null) {
|
||||
document.getElementById('requestCoordsObjectLon').value = data.coords_object_lon.toFixed(6);
|
||||
} else {
|
||||
document.getElementById('requestCoordsObjectLon').value = '';
|
||||
}
|
||||
|
||||
document.getElementById('sourceDataCard').style.display = data.source_id ? 'block' : 'none';
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('requestModal'));
|
||||
modal.show();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading request:', error);
|
||||
alert('Ошибка загрузки данных заявки');
|
||||
});
|
||||
}
|
||||
|
||||
// Сохранение заявки
|
||||
function saveRequest() {
|
||||
const form = document.getElementById('requestForm');
|
||||
const formData = new FormData(form);
|
||||
const requestId = document.getElementById('requestId').value;
|
||||
|
||||
const url = requestId
|
||||
? `/source-requests/${requestId}/edit/`
|
||||
: '{% url "mainapp:source_request_create" %}';
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': formData.get('csrfmiddlewaretoken'),
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
body: new URLSearchParams(formData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
// Properly close modal and remove backdrop
|
||||
const modalEl = document.getElementById('requestModal');
|
||||
const modalInstance = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
}
|
||||
// Remove any remaining backdrops
|
||||
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||
document.body.classList.remove('modal-open');
|
||||
document.body.style.removeProperty('overflow');
|
||||
document.body.style.removeProperty('padding-right');
|
||||
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка: ' + JSON.stringify(result.errors));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error saving request:', error);
|
||||
alert('Ошибка сохранения заявки');
|
||||
});
|
||||
}
|
||||
|
||||
// Удаление заявки
|
||||
function deleteRequest(requestId) {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту заявку?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/source-requests/${requestId}/delete/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Ошибка: ' + result.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting request:', error);
|
||||
alert('Ошибка удаления заявки');
|
||||
});
|
||||
}
|
||||
|
||||
// Показать историю статусов
|
||||
function showHistory(requestId) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('historyModal'));
|
||||
modal.show();
|
||||
|
||||
const modalBody = document.getElementById('historyModalBody');
|
||||
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||
|
||||
fetch(`/api/source-request/${requestId}/`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.history && data.history.length > 0) {
|
||||
let html = '<table class="table table-sm table-striped"><thead><tr><th>Старый статус</th><th>Новый статус</th><th>Дата изменения</th><th>Пользователь</th></tr></thead><tbody>';
|
||||
data.history.forEach(h => {
|
||||
html += `<tr><td>${h.old_status}</td><td>${h.new_status}</td><td>${h.changed_at}</td><td>${h.changed_by}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
modalBody.innerHTML = html;
|
||||
} else {
|
||||
modalBody.innerHTML = '<div class="alert alert-info">История изменений пуста</div>';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
modalBody.innerHTML = '<div class="alert alert-danger">Ошибка загрузки истории</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// Функция для показа модального окна LyngSat
|
||||
function showLyngsatModal(lyngsatId) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('lyngsatModal'));
|
||||
modal.show();
|
||||
|
||||
const modalBody = document.getElementById('lyngsatModalBody');
|
||||
modalBody.innerHTML = '<div class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">Загрузка...</span></div></div>';
|
||||
|
||||
fetch('/api/lyngsat/' + lyngsatId + '/')
|
||||
.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.satellite + '</strong></td></tr>' +
|
||||
'<tr><td class="text-muted">Частота:</td><td><strong>' + data.frequency + ' МГц</strong></td></tr>' +
|
||||
'<tr><td class="text-muted">Поляризация:</td><td><span class="badge bg-info">' + data.polarization + '</span></td></tr>' +
|
||||
'<tr><td class="text-muted">Канал:</td><td>' + data.channel_info + '</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">' + data.modulation + '</span></td></tr>' +
|
||||
'<tr><td class="text-muted">Стандарт:</td><td><span class="badge bg-secondary">' + data.standard + '</span></td></tr>' +
|
||||
'<tr><td class="text-muted">Сим. скорость:</td><td><strong>' + data.sym_velocity + ' БОД</strong></td></tr>' +
|
||||
'<tr><td class="text-muted">FEC:</td><td>' + data.fec + '</td></tr>' +
|
||||
'</tbody></table></div></div></div>' +
|
||||
'<div class="col-12"><div class="card">' +
|
||||
'<div class="card-header bg-light"><strong><i class="bi bi-clock-history"></i> Дополнительная информация</strong></div>' +
|
||||
'<div class="card-body"><div class="row">' +
|
||||
'<div class="col-md-6"><p class="mb-2"><span class="text-muted">Последнее обновление:</span><br><strong>' + data.last_update + '</strong></p></div>' +
|
||||
'<div class="col-md-6">' + (data.url ? '<p class="mb-2"><span class="text-muted">Ссылка на объект:</span><br>' +
|
||||
'<a href="' + data.url + '" target="_blank" class="btn btn-sm btn-outline-primary">' +
|
||||
'<i class="bi bi-link-45deg"></i> Открыть на LyngSat</a></p>' : '') +
|
||||
'</div></div></div></div></div></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>';
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Restore active tab from URL parameter
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const activeTab = urlParams.get('tab');
|
||||
if (activeTab === 'filters') {
|
||||
const filtersTab = document.getElementById('filters-tab');
|
||||
const requestsTab = document.getElementById('requests-tab');
|
||||
const filtersPane = document.getElementById('filters');
|
||||
const requestsPane = document.getElementById('requests');
|
||||
|
||||
if (filtersTab && requestsTab) {
|
||||
requestsTab.classList.remove('active');
|
||||
requestsTab.setAttribute('aria-selected', 'false');
|
||||
filtersTab.classList.add('active');
|
||||
filtersTab.setAttribute('aria-selected', 'true');
|
||||
|
||||
requestsPane.classList.remove('show', 'active');
|
||||
filtersPane.classList.add('show', 'active');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- LyngSat Data Modal -->
|
||||
<div class="modal fade" id="lyngsatModal" tabindex="-1" aria-labelledby="lyngsatModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="lyngsatModalLabel">
|
||||
<i class="bi bi-tv"></i> Данные объекта LyngSat
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
|
||||
aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="lyngsatModalBody">
|
||||
<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>
|
||||
{% endblock %}
|
||||
@@ -13,13 +13,10 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Alert messages -->
|
||||
{% include 'mainapp/components/_messages.html' %}
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>Информация:</strong> Эта функция автоматически привязывает источники из базы LyngSat к объектам
|
||||
на основе совпадения частоты (с округлением) и поляризации. Объекты с привязанными источниками LyngSat
|
||||
на основе совпадения частоты и поляризации. Объекты с привязанными источниками LyngSat
|
||||
будут отмечены как "ТВ" в списке объектов.
|
||||
</div>
|
||||
|
||||
@@ -67,23 +64,6 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help section -->
|
||||
<div class="card mt-4 shadow-sm">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-question-circle"></i> Как это работает?
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="mb-0">
|
||||
<li>Система округляет частоту каждого объекта до целого числа</li>
|
||||
<li>Ищет источники LyngSat с той же поляризацией и близкой частотой (в пределах допуска)</li>
|
||||
<li>При нахождении совпадения создается связь между объектом и источником LyngSat</li>
|
||||
<li>Объекты с привязанными источниками отображаются как "ТВ" в списке</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,15 +11,6 @@
|
||||
<h2 class="mb-0">Привязка ВЧ загрузки</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert {{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Информация о привязке:</strong>
|
||||
<p class="mb-2">Погрешность центральной частоты определяется автоматически в зависимости от полосы:</p>
|
||||
@@ -67,8 +58,8 @@
|
||||
</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>
|
||||
{% comment %} <a href="{% url 'mainapp:home' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %}
|
||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary me-md-2">Назад</a>
|
||||
{% comment %} <a href="{% url 'mainapp:source_list' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %}
|
||||
<button type="submit" class="btn btn-info">Выполнить привязку</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
1475
dbapp/mainapp/templates/mainapp/multi_sources_playback_map.html
Normal file
1475
dbapp/mainapp/templates/mainapp/multi_sources_playback_map.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@
|
||||
<p>Вы уверены, что хотите удалить этот объект? Это действие нельзя будет отменить.</p>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-danger">Удалить</button>
|
||||
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary ms-2">Отмена</a>
|
||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary ms-2">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -182,6 +182,98 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Транспондер -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-header">
|
||||
<h4>Транспондер</h4>
|
||||
</div>
|
||||
|
||||
{% if object.transponder %}
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Название:</label>
|
||||
<div class="readonly-field">{{ object.transponder.name|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Спутник:</label>
|
||||
<div class="readonly-field">{{ object.transponder.sat_id.name|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Downlink (МГц):</label>
|
||||
<div class="readonly-field">{{ object.transponder.downlink|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Uplink (МГц):</label>
|
||||
<div class="readonly-field">{{ object.transponder.uplink|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Полоса (МГц):</label>
|
||||
<div class="readonly-field">{{ object.transponder.frequency_range|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Перенос (МГц):</label>
|
||||
<div class="readonly-field">{{ object.transponder.transfer|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Поляризация:</label>
|
||||
<div class="readonly-field">{{ object.transponder.polarization.name|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">ОСШ (дБ):</label>
|
||||
<div class="readonly-field">{{ object.transponder.snr|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Зона покрытия:</label>
|
||||
<div class="readonly-field">{{ object.transponder.zone_name|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Дата создания:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.transponder.created_at %}{{ object.transponder.created_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Создан пользователем:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.transponder.created_by %}{{ object.transponder.created_by }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-3">
|
||||
<p>Нет данных о транспондере</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Блок с картой -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-header">
|
||||
@@ -220,52 +312,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Координаты Кубсата -->
|
||||
<div class="coord-group">
|
||||
<div class="coord-group-header">Координаты Кубсата</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Долгота:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.x|floatformat:6 }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Широта:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.y|floatformat:6 }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Координаты оперативников -->
|
||||
<div class="coord-group">
|
||||
<div class="coord-group-header">Координаты оперативников</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Долгота:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.x|floatformat:6 }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Широта:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.y|floatformat:6 }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
@@ -289,6 +335,7 @@
|
||||
{% if object.geo_obj.timestamp %}{{ object.geo_obj.timestamp|date:"d.m.Y H:i" }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-check-label">Усредненное значение:</label>
|
||||
@@ -299,37 +346,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-4">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Расстояние гео-кубсат, км:</label>
|
||||
<label class="form-label">Зеркала:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.distance_coords_kup is not None %}
|
||||
{{ object.geo_obj.distance_coords_kup|floatformat:2 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Расстояние гео-опер, км:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.distance_coords_valid is not None %}
|
||||
{{ object.geo_obj.distance_coords_valid|floatformat:2 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Расстояние кубсат-опер, км:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.distance_kup_valid is not None %}
|
||||
{{ object.geo_obj.distance_kup_valid|floatformat:2 }}
|
||||
{% if object.geo_obj.mirrors.all %}
|
||||
{% for mirror in object.geo_obj.mirrors.all %}
|
||||
{{ mirror.name }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
@@ -341,7 +366,6 @@
|
||||
<p>Нет данных о геолокации</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-4">
|
||||
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||
@@ -368,17 +392,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Определяем цвета для маркеров
|
||||
const colors = {
|
||||
geo: 'blue',
|
||||
kupsat: 'red',
|
||||
valid: 'green'
|
||||
};
|
||||
|
||||
// Функция для создания иконки маркера
|
||||
function createMarkerIcon(color) {
|
||||
function createMarkerIcon() {
|
||||
return L.icon({
|
||||
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + color + '.png',
|
||||
iconUrl: '{% static "leaflet-markers/img/marker-icon-blue.png" %}',
|
||||
shadowUrl: `{% static 'leaflet-markers/img/marker-shadow.png' %}`,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
@@ -387,18 +404,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// Маркеры
|
||||
const markers = {};
|
||||
function createMarker(position, color, name) {
|
||||
const marker = L.marker(position, {
|
||||
draggable: false,
|
||||
icon: createMarkerIcon(color),
|
||||
title: name
|
||||
}).addTo(map);
|
||||
marker.bindPopup(name);
|
||||
return marker;
|
||||
}
|
||||
|
||||
// Получаем координаты из данных объекта
|
||||
{% if object.geo_obj and object.geo_obj.coords %}
|
||||
const geoLat = {{ object.geo_obj.coords.y|unlocalize }};
|
||||
@@ -408,66 +413,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const geoLng = 37.62;
|
||||
{% endif %}
|
||||
|
||||
{% if object.geo_obj and object.geo_obj.coords_kupsat %}
|
||||
const kupsatLat = {{ object.geo_obj.coords_kupsat.y|unlocalize }};
|
||||
const kupsatLng = {{ object.geo_obj.coords_kupsat.x|unlocalize }};
|
||||
{% else %}
|
||||
const kupsatLat = 55.75;
|
||||
const kupsatLng = 37.61;
|
||||
{% endif %}
|
||||
// Создаем маркер геолокации
|
||||
const marker = L.marker([geoLat, geoLng], {
|
||||
draggable: false,
|
||||
icon: createMarkerIcon(),
|
||||
title: 'Геолокация'
|
||||
}).addTo(map);
|
||||
marker.bindPopup('Геолокация');
|
||||
|
||||
{% if object.geo_obj and object.geo_obj.coords_valid %}
|
||||
const validLat = {{ object.geo_obj.coords_valid.y|unlocalize }};
|
||||
const validLng = {{ object.geo_obj.coords_valid.x|unlocalize }};
|
||||
{% else %}
|
||||
const validLat = 55.75;
|
||||
const validLng = 37.63;
|
||||
{% endif %}
|
||||
|
||||
// Создаем маркеры
|
||||
markers.geo = createMarker(
|
||||
[geoLat, geoLng],
|
||||
colors.geo,
|
||||
'Геолокация'
|
||||
);
|
||||
|
||||
markers.kupsat = createMarker(
|
||||
[kupsatLat, kupsatLng],
|
||||
colors.kupsat,
|
||||
'Кубсат'
|
||||
);
|
||||
|
||||
markers.valid = createMarker(
|
||||
[validLat, validLng],
|
||||
colors.valid,
|
||||
'Оперативник'
|
||||
);
|
||||
|
||||
// Центрируем карту на первом маркере
|
||||
if (map.hasLayer(markers.geo)) {
|
||||
map.setView(markers.geo.getLatLng(), 10);
|
||||
}
|
||||
|
||||
// Легенда
|
||||
const legend = L.control({ position: 'bottomright' });
|
||||
|
||||
legend.onAdd = function() {
|
||||
const div = L.DomUtil.create('div', 'info legend');
|
||||
div.style.fontSize = '14px';
|
||||
div.style.backgroundColor = 'white';
|
||||
div.style.padding = '10px';
|
||||
div.style.borderRadius = '4px';
|
||||
div.style.boxShadow = '0 0 10px rgba(0,0,0,0.2)';
|
||||
div.innerHTML = `
|
||||
<h5>Легенда</h5>
|
||||
<div><span style="color: blue; font-weight: bold;">•</span> Геолокация</div>
|
||||
<div><span style="color: red; font-weight: bold;">•</span> Кубсат</div>
|
||||
<div><span style="color: green; font-weight: bold;">•</span> Оперативники</div>
|
||||
`;
|
||||
return div;
|
||||
};
|
||||
|
||||
legend.addTo(map);
|
||||
// Центрируем карту на маркере
|
||||
map.setView(marker.getLatLng(), 10);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,30 +3,96 @@
|
||||
{% load static leaflet_tags %}
|
||||
{% load l10n %}
|
||||
|
||||
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %}
|
||||
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать новый объект{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/checkbox-select-multiple.css' %}">
|
||||
<style>
|
||||
.form-section { margin-bottom: 2rem; border: 1px solid #dee2e6; border-radius: 0.25rem; padding: 1rem; }
|
||||
.form-section-header { border-bottom: 1px solid #dee2e6; padding-bottom: 0.5rem; margin-bottom: 1rem; }
|
||||
.btn-action { margin-right: 0.5rem; }
|
||||
.dynamic-form { border: 1px dashed #ced4da; padding: 1rem; margin-top: 1rem; border-radius: 0.25rem; }
|
||||
.dynamic-form-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.readonly-field { background-color: #f8f9fa; padding: 0.375rem 0.75rem; border: 1px solid #ced4da; border-radius: 0.25rem; }
|
||||
.coord-group { border: 1px solid #dee2e6; padding: 0.75rem; border-radius: 0.25rem; margin-bottom: 1rem; }
|
||||
.coord-group-header { font-weight: bold; margin-bottom: 0.5rem; }
|
||||
.form-check-input { margin-top: 0.25rem; }
|
||||
.datetime-group { display: flex; gap: 1rem; }
|
||||
.datetime-group > div { flex: 1; }
|
||||
#map { height: 500px; width: 100%; margin-bottom: 1rem; }
|
||||
.map-container { margin-bottom: 1rem; }
|
||||
.coord-sync-group { border: 1px solid #dee2e6; padding: 0.75rem; border-radius: 0.25rem; }
|
||||
.form-section {
|
||||
margin-bottom: 2rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.form-section-header {
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.dynamic-form {
|
||||
border: 1px dashed #ced4da;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.dynamic-form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.readonly-field {
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.coord-group {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.coord-group-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-check-input {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.datetime-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.datetime-group>div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 500px;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.coord-sync-group {
|
||||
border: 1px solid #dee2e6;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.map-control-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #ced4da;
|
||||
@@ -34,25 +100,43 @@
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map-control-btn.active {
|
||||
background-color: #e9ecef;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
.map-control-btn.edit {
|
||||
background-color: #fff3cd;
|
||||
border-color: #ffeeba;
|
||||
}
|
||||
|
||||
.map-control-btn.save {
|
||||
background-color: #d1ecf1;
|
||||
border-color: #bee5eb;
|
||||
}
|
||||
|
||||
.map-control-btn.cancel {
|
||||
background-color: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon {
|
||||
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
|
||||
/* Select2 custom styling */
|
||||
.select2-container--default .select2-selection--multiple {
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 0.25rem;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.select2-container--default.select2-container--focus .select2-selection--multiple {
|
||||
border-color: #86b7fe;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -60,15 +144,17 @@
|
||||
<div class="container-fluid px-3">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12 d-flex justify-content-between align-items-center">
|
||||
<h2>{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}</h2>
|
||||
<h2>{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать новый объект{% endif %}</h2>
|
||||
<div>
|
||||
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
|
||||
<button type="submit" form="objitem-form" class="btn btn-primary btn-action">Сохранить</button>
|
||||
{% if object %}
|
||||
<a href="{% url 'mainapp:objitem_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-action">Удалить</a>
|
||||
<a href="{% url 'mainapp:objitem_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
|
||||
class="btn btn-danger btn-action">Удалить</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<a href="{% url 'mainapp:objitem_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-secondary btn-action">Назад</a>
|
||||
<a href="{% url 'mainapp:objitem_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
|
||||
class="btn btn-secondary btn-action">Назад</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,6 +248,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if object %}
|
||||
<!-- Транспондер -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-header">
|
||||
<h4>Транспондер</h4>
|
||||
</div>
|
||||
|
||||
{% if object.transponder %}
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Название:</label>
|
||||
<div class="readonly-field">{{ object.transponder.name|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Спутник:</label>
|
||||
<div class="readonly-field">{{ object.transponder.sat_id.name|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Downlink (МГц):</label>
|
||||
<div class="readonly-field">{{ object.transponder.downlink|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Uplink (МГц):</label>
|
||||
<div class="readonly-field">{{ object.transponder.uplink|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Полоса (МГц):</label>
|
||||
<div class="readonly-field">{{ object.transponder.frequency_range|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Перенос (МГц):</label>
|
||||
<div class="readonly-field">{{ object.transponder.transfer|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Поляризация:</label>
|
||||
<div class="readonly-field">{{ object.transponder.polarization.name|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">ОСШ (дБ):</label>
|
||||
<div class="readonly-field">{{ object.transponder.snr|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Зона покрытия:</label>
|
||||
<div class="readonly-field">{{ object.transponder.zone_name|default:"-" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Дата создания:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.transponder.created_at %}{{ object.transponder.created_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Создан пользователем:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.transponder.created_by %}{{ object.transponder.created_by }}{% else %}-{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-3">
|
||||
<p class="text-muted">Нет данных о транспондере</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Блок с картой -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-header">
|
||||
@@ -186,68 +366,22 @@
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="id_geo_latitude" class="form-label">Широта:</label>
|
||||
<input type="number" step="0.000001" class="form-control"
|
||||
id="id_geo_latitude" name="geo_latitude"
|
||||
<input type="number" step="0.000001" class="form-control" id="id_geo_latitude"
|
||||
name="geo_latitude"
|
||||
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.y|unlocalize }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="id_geo_longitude" class="form-label">Долгота:</label>
|
||||
<input type="number" step="0.000001" class="form-control"
|
||||
id="id_geo_longitude" name="geo_longitude"
|
||||
<input type="number" step="0.000001" class="form-control" id="id_geo_longitude"
|
||||
name="geo_longitude"
|
||||
value="{% if object.geo_obj and object.geo_obj.coords %}{{ object.geo_obj.coords.x|unlocalize }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Координаты Кубсата -->
|
||||
<div class="coord-group">
|
||||
<div class="coord-group-header">Координаты Кубсата</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="id_kupsat_longitude" class="form-label">Долгота:</label>
|
||||
<input type="number" step="0.000001" class="form-control"
|
||||
id="id_kupsat_longitude" name="kupsat_longitude"
|
||||
value="{% if object.geo_obj and object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.x|unlocalize }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="id_kupsat_latitude" class="form-label">Широта:</label>
|
||||
<input type="number" step="0.000001" class="form-control"
|
||||
id="id_kupsat_latitude" name="kupsat_latitude"
|
||||
value="{% if object.geo_obj and object.geo_obj.coords_kupsat %}{{ object.geo_obj.coords_kupsat.y|unlocalize }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Координаты оперативников -->
|
||||
<div class="coord-group">
|
||||
<div class="coord-group-header">Координаты оперативников</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="id_valid_longitude" class="form-label">Долгота:</label>
|
||||
<input type="number" step="0.000001" class="form-control"
|
||||
id="id_valid_longitude" name="valid_longitude"
|
||||
value="{% if object.geo_obj and object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.x|unlocalize }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="id_valid_latitude" class="form-label">Широта:</label>
|
||||
<input type="number" step="0.000001" class="form-control"
|
||||
id="id_valid_latitude" name="valid_latitude"
|
||||
value="{% if object.geo_obj and object.geo_obj.coords_valid %}{{ object.geo_obj.coords_valid.y|unlocalize }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{% include 'mainapp/components/_form_field.html' with field=geo_form.location %}
|
||||
@@ -264,14 +398,12 @@
|
||||
<div class="datetime-group">
|
||||
<div>
|
||||
<label for="id_timestamp_date" class="form-label">Дата:</label>
|
||||
<input type="date" class="form-control"
|
||||
id="id_timestamp_date" name="timestamp_date"
|
||||
<input type="date" class="form-control" id="id_timestamp_date" name="timestamp_date"
|
||||
value="{% if object.geo_obj and object.geo_obj.timestamp %}{{ object.geo_obj.timestamp|date:'Y-m-d' }}{% endif %}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="id_timestamp_time" class="form-label">Время:</label>
|
||||
<input type="time" class="form-control"
|
||||
id="id_timestamp_time" name="timestamp_time"
|
||||
<input type="time" class="form-control" id="id_timestamp_time" name="timestamp_time"
|
||||
value="{% if object.geo_obj and object.geo_obj.timestamp %}{{ object.geo_obj.timestamp|time:'H:i' }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,47 +414,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if object.geo_obj %}
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Расстояние гео-кубсат, км:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.distance_coords_kup is not None %}
|
||||
{{ object.geo_obj.distance_coords_kup|floatformat:2 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'mainapp/components/_form_field.html' with field=geo_form.mirrors %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Расстояние гео-опер, км:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.distance_coords_valid is not None %}
|
||||
{{ object.geo_obj.distance_coords_valid|floatformat:2 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Расстояние кубсат-опер, км:</label>
|
||||
<div class="readonly-field">
|
||||
{% if object.geo_obj.distance_kup_valid is not None %}
|
||||
{{ object.geo_obj.distance_kup_valid|floatformat:2 }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -334,6 +431,8 @@
|
||||
{% leaflet_css %}
|
||||
<script src="{% static 'leaflet-markers/js/leaflet-color-markers.js' %}"></script>
|
||||
|
||||
<!-- Подключаем кастомный виджет для мультивыбора -->
|
||||
<script src="{% static 'js/checkbox-select-multiple.js' %}"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
@@ -344,17 +443,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Определяем цвета для маркеров
|
||||
const colors = {
|
||||
geo: 'blue',
|
||||
kupsat: 'red',
|
||||
valid: 'green'
|
||||
};
|
||||
|
||||
// Функция для создания иконки маркера
|
||||
function createMarkerIcon(color) {
|
||||
function createMarkerIcon() {
|
||||
return L.icon({
|
||||
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + color + '.png',
|
||||
iconUrl: '{% static "leaflet-markers/img/marker-icon-blue.png" %}',
|
||||
shadowUrl: `{% static 'leaflet-markers/img/marker-shadow.png' %}`,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
@@ -365,20 +457,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const editableLayerGroup = new L.FeatureGroup();
|
||||
map.addLayer(editableLayerGroup);
|
||||
|
||||
// Маркеры
|
||||
const markers = {};
|
||||
function createMarker(latFieldId, lngFieldId, position, color, name) {
|
||||
const marker = L.marker(position, {
|
||||
// Маркер геолокации
|
||||
const marker = L.marker([55.75, 37.62], {
|
||||
draggable: false,
|
||||
icon: createMarkerIcon(color),
|
||||
title: name
|
||||
icon: createMarkerIcon(),
|
||||
title: 'Геолокация'
|
||||
}).addTo(editableLayerGroup);
|
||||
marker.bindPopup(name);
|
||||
marker.bindPopup('Геолокация');
|
||||
|
||||
// Синхронизация при изменении формы
|
||||
function syncFromForm() {
|
||||
const lat = parseFloat(document.getElementById(latFieldId).value);
|
||||
const lng = parseFloat(document.getElementById(lngFieldId).value);
|
||||
const lat = parseFloat(document.getElementById('id_geo_latitude').value);
|
||||
const lng = parseFloat(document.getElementById('id_geo_longitude').value);
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
marker.setLatLng([lat, lng]);
|
||||
}
|
||||
@@ -387,8 +477,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Синхронизация при перетаскивании (только если активировано)
|
||||
marker.on('dragend', function (event) {
|
||||
const latLng = event.target.getLatLng();
|
||||
document.getElementById(latFieldId).value = latLng.lat.toFixed(6);
|
||||
document.getElementById(lngFieldId).value = latLng.lng.toFixed(6);
|
||||
document.getElementById('id_geo_latitude').value = latLng.lat.toFixed(6);
|
||||
document.getElementById('id_geo_longitude').value = latLng.lng.toFixed(6);
|
||||
});
|
||||
|
||||
// Добавляем методы для управления
|
||||
@@ -404,53 +494,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
marker.syncFromForm = syncFromForm;
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
// Создаем маркеры
|
||||
markers.geo = createMarker(
|
||||
'id_geo_latitude',
|
||||
'id_geo_longitude',
|
||||
[55.75, 37.62],
|
||||
colors.geo,
|
||||
'Геолокация'
|
||||
);
|
||||
|
||||
markers.kupsat = createMarker(
|
||||
'id_kupsat_latitude',
|
||||
'id_kupsat_longitude',
|
||||
[55.75, 37.61],
|
||||
colors.kupsat,
|
||||
'Кубсат'
|
||||
);
|
||||
|
||||
markers.valid = createMarker(
|
||||
'id_valid_latitude',
|
||||
'id_valid_longitude',
|
||||
[55.75, 37.63],
|
||||
colors.valid,
|
||||
'Оперативник'
|
||||
);
|
||||
|
||||
// Устанавливаем начальные координаты из полей формы
|
||||
function initMarkersFromForm() {
|
||||
const geoLat = parseFloat(document.getElementById('id_geo_latitude').value) || 55.75;
|
||||
const geoLng = parseFloat(document.getElementById('id_geo_longitude').value) || 37.62;
|
||||
markers.geo.setLatLng([geoLat, geoLng]);
|
||||
marker.setLatLng([geoLat, geoLng]);
|
||||
|
||||
const kupsatLat = parseFloat(document.getElementById('id_kupsat_latitude').value) || 55.75;
|
||||
const kupsatLng = parseFloat(document.getElementById('id_kupsat_longitude').value) || 37.61;
|
||||
markers.kupsat.setLatLng([kupsatLat, kupsatLng]);
|
||||
|
||||
const validLat = parseFloat(document.getElementById('id_valid_latitude').value) || 55.75;
|
||||
const validLng = parseFloat(document.getElementById('id_valid_longitude').value) || 37.63;
|
||||
markers.valid.setLatLng([validLat, validLng]);
|
||||
|
||||
// Центрируем карту на первом маркере
|
||||
map.setView(markers.geo.getLatLng(), 10);
|
||||
// Центрируем карту на маркере
|
||||
map.setView(marker.getLatLng(), 10);
|
||||
}
|
||||
|
||||
// Настройка формы для синхронизации с маркерами
|
||||
// Настройка формы для синхронизации с маркером
|
||||
function setupFormChange(latFieldId, lngFieldId, marker) {
|
||||
const latField = document.getElementById(latFieldId);
|
||||
const lngField = document.getElementById(lngFieldId);
|
||||
@@ -470,10 +524,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Инициализация
|
||||
initMarkersFromForm();
|
||||
// Настройка формы для синхронизации с маркерами
|
||||
setupFormChange('id_geo_latitude', 'id_geo_longitude', markers.geo);
|
||||
setupFormChange('id_kupsat_latitude', 'id_kupsat_longitude', markers.kupsat);
|
||||
setupFormChange('id_valid_latitude', 'id_valid_longitude', markers.valid);
|
||||
// Настройка формы для синхронизации с маркером
|
||||
setupFormChange('id_geo_latitude', 'id_geo_longitude', marker);
|
||||
// --- УПРАВЛЕНИЕ РЕДАКТИРОВАНИЕМ ---
|
||||
// Кнопки редактирования
|
||||
const editControlsDiv = L.DomUtil.create('div', 'map-controls');
|
||||
@@ -497,11 +549,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
let isEditing = false;
|
||||
|
||||
// Сохраняем начальные координаты для отмены
|
||||
const initialPositions = {
|
||||
geo: markers.geo.getLatLng(),
|
||||
kupsat: markers.kupsat.getLatLng(),
|
||||
valid: markers.valid.getLatLng()
|
||||
};
|
||||
const initialPosition = marker.getLatLng();
|
||||
|
||||
// Включение редактирования
|
||||
document.getElementById('edit-btn').addEventListener('click', function () {
|
||||
@@ -512,15 +560,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('save-btn').disabled = false;
|
||||
document.getElementById('cancel-btn').disabled = false;
|
||||
|
||||
// Включаем drag для всех маркеров
|
||||
Object.values(markers).forEach(marker => {
|
||||
// Включаем drag для маркера
|
||||
marker.enableEditing();
|
||||
});
|
||||
|
||||
// Показываем подсказку
|
||||
L.popup()
|
||||
.setLatLng(map.getCenter())
|
||||
.setContent('Перетаскивайте маркеры. Нажмите "Сохранить" или "Отмена".')
|
||||
.setContent('Перетаскивайте маркер. Нажмите "Сохранить" или "Отмена".')
|
||||
.openOn(map);
|
||||
});
|
||||
|
||||
@@ -534,14 +580,11 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('cancel-btn').disabled = true;
|
||||
|
||||
// Отключаем редактирование
|
||||
Object.values(markers).forEach(marker => {
|
||||
marker.disableEditing();
|
||||
});
|
||||
|
||||
// Обновляем начальные позиции
|
||||
initialPositions.geo = markers.geo.getLatLng();
|
||||
initialPositions.kupsat = markers.kupsat.getLatLng();
|
||||
initialPositions.valid = markers.valid.getLatLng();
|
||||
// Обновляем начальную позицию
|
||||
initialPosition.lat = marker.getLatLng().lat;
|
||||
initialPosition.lng = marker.getLatLng().lng;
|
||||
|
||||
// Убираем попап подсказки
|
||||
map.closePopup();
|
||||
@@ -556,25 +599,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('save-btn').disabled = true;
|
||||
document.getElementById('cancel-btn').disabled = true;
|
||||
|
||||
// Возвращаем маркеры на исходные позиции
|
||||
markers.geo.setLatLng(initialPositions.geo);
|
||||
markers.kupsat.setLatLng(initialPositions.kupsat);
|
||||
markers.valid.setLatLng(initialPositions.valid);
|
||||
// Возвращаем маркер на исходную позицию
|
||||
marker.setLatLng(initialPosition);
|
||||
|
||||
// Отключаем редактирование
|
||||
Object.values(markers).forEach(marker => {
|
||||
marker.disableEditing();
|
||||
});
|
||||
|
||||
// Синхронизируем форму с исходными значениями
|
||||
document.getElementById('id_geo_latitude').value = initialPositions.geo.lat.toFixed(6);
|
||||
document.getElementById('id_geo_longitude').value = initialPositions.geo.lng.toFixed(6);
|
||||
|
||||
document.getElementById('id_kupsat_latitude').value = initialPositions.kupsat.lat.toFixed(6);
|
||||
document.getElementById('id_kupsat_longitude').value = initialPositions.kupsat.lng.toFixed(6);
|
||||
|
||||
document.getElementById('id_valid_latitude').value = initialPositions.valid.lat.toFixed(6);
|
||||
document.getElementById('id_valid_longitude').value = initialPositions.valid.lng.toFixed(6);
|
||||
// Синхронизируем форму с исходным значением
|
||||
document.getElementById('id_geo_latitude').value = initialPosition.lat.toFixed(6);
|
||||
document.getElementById('id_geo_longitude').value = initialPosition.lng.toFixed(6);
|
||||
map.closePopup();
|
||||
});
|
||||
|
||||
@@ -591,8 +624,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
div.innerHTML = `
|
||||
<h5>Легенда</h5>
|
||||
<div><span style="color: blue; font-weight: bold;">•</span> Геолокация</div>
|
||||
<div><span style="color: red; font-weight: bold;">•</span> Кубсат</div>
|
||||
<div><span style="color: green; font-weight: bold;">•</span> Оперативники</div>
|
||||
`;
|
||||
return div;
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,74 @@
|
||||
{% extends "mapsapp/map2d_base.html" %}
|
||||
{% extends "mainapp/base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}Карта выбранных объектов{% endblock title %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!-- Leaflet CSS -->
|
||||
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
|
||||
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
#map {
|
||||
position: fixed;
|
||||
top: 56px; /* Высота navbar */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="map"></div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Leaflet JavaScript -->
|
||||
<script src="{% static 'leaflet/leaflet.js' %}"></script>
|
||||
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
|
||||
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
|
||||
|
||||
<script>
|
||||
// Цвета для стандартных маркеров (из leaflet-color-markers)
|
||||
// Инициализация карты
|
||||
let map = L.map('map').setView([55.75, 37.62], 5);
|
||||
L.control.scale({
|
||||
imperial: false,
|
||||
metric: true
|
||||
}).addTo(map);
|
||||
map.attributionControl.setPrefix(false);
|
||||
|
||||
const street = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
});
|
||||
street.addTo(map);
|
||||
|
||||
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||||
attribution: 'Tiles © Esri'
|
||||
});
|
||||
|
||||
const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: 'Local Tiles'
|
||||
});
|
||||
|
||||
const baseLayers = {
|
||||
"Улицы": street,
|
||||
"Спутник": satellite,
|
||||
"Локально": street_local
|
||||
};
|
||||
|
||||
L.control.layers(baseLayers).addTo(map);
|
||||
map.setMaxZoom(18);
|
||||
map.setMinZoom(0);
|
||||
L.control.measure({ primaryLengthUnit: 'kilometers' }).addTo(map);
|
||||
|
||||
// Цвета для маркеров
|
||||
var markerColors = ['red', 'green', 'orange', 'violet', 'grey', 'black', 'blue'];
|
||||
var getColorIcon = function(color) {
|
||||
return L.icon({
|
||||
@@ -19,47 +83,52 @@
|
||||
|
||||
var overlays = [];
|
||||
|
||||
// Создаём слои для каждого объекта
|
||||
{% for group in groups %}
|
||||
var groupIndex = {{ forloop.counter0 }};
|
||||
var groupName = '{{ group.name|escapejs }}';
|
||||
var colorName = markerColors[groupIndex % markerColors.length];
|
||||
var groupIcon = getColorIcon(colorName);
|
||||
|
||||
var groupLayer = L.layerGroup();
|
||||
|
||||
var subgroup = [];
|
||||
{% for point_data in group.points %}
|
||||
var pointName = "{{ group.name|escapejs }}";
|
||||
|
||||
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
|
||||
icon: groupIcon
|
||||
}).bindPopup(pointName + '<br>' + "{{ point_data.frequency|escapejs }}");
|
||||
|
||||
groupLayer.addLayer(marker);
|
||||
|
||||
subgroup.push({
|
||||
label: "{{ forloop.counter }} - {{ point_data.frequency }}",
|
||||
label: "{{ forloop.counter }} - {{ point_data.frequency|escapejs }}",
|
||||
layer: marker
|
||||
});
|
||||
{% endfor %}
|
||||
|
||||
overlays.push({
|
||||
label: '{{ group.name|escapejs }}',
|
||||
label: groupName,
|
||||
selectAllCheckbox: true,
|
||||
children: subgroup,
|
||||
layer: groupLayer
|
||||
});
|
||||
{% endfor %}
|
||||
|
||||
// Create the layer control with a custom container that includes a select all checkbox
|
||||
var layerControl = L.control.layers.tree(baseLayers, overlays, {
|
||||
// Корневая группа
|
||||
const rootGroup = {
|
||||
label: "Все точки",
|
||||
selectAllCheckbox: true,
|
||||
children: overlays,
|
||||
layer: L.layerGroup()
|
||||
};
|
||||
|
||||
// Создаём tree control
|
||||
const layerControl = L.control.layers.tree(baseLayers, [rootGroup], {
|
||||
collapsed: false,
|
||||
autoZIndex: true
|
||||
});
|
||||
|
||||
// Add the layer control to the map
|
||||
layerControl.addTo(map);
|
||||
|
||||
// Calculate map bounds to fit all markers
|
||||
// Подгоняем карту под все маркеры
|
||||
{% if groups %}
|
||||
var groupBounds = L.featureGroup([]);
|
||||
{% for group in groups %}
|
||||
@@ -67,40 +136,7 @@
|
||||
groupBounds.addLayer(L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}]));
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
map.fitBounds(groupBounds.getBounds().pad(0.1)); // Add some padding
|
||||
{% else %}
|
||||
map.setView([55.75, 37.62], 5); // Default view if no markers
|
||||
map.fitBounds(groupBounds.getBounds().pad(0.1));
|
||||
{% endif %}
|
||||
|
||||
// Add a "Select All" checkbox functionality for all overlays
|
||||
setTimeout(function() {
|
||||
// Create a custom "select all" checkbox
|
||||
var selectAllContainer = document.createElement('div');
|
||||
selectAllContainer.className = 'leaflet-control-layers-select-all';
|
||||
selectAllContainer.style.padding = '5px';
|
||||
selectAllContainer.style.borderBottom = '1px solid #ccc';
|
||||
selectAllContainer.style.marginBottom = '5px';
|
||||
selectAllContainer.innerHTML = '<label><input type="checkbox" id="select-all-overlays" checked> Показать все точки</label>';
|
||||
|
||||
// Insert the checkbox at the top of the layer control
|
||||
var layerControlContainer = document.querySelector('.leaflet-control-layers-list');
|
||||
if (layerControlContainer) {
|
||||
layerControlContainer.insertBefore(selectAllContainer, layerControlContainer.firstChild);
|
||||
}
|
||||
|
||||
// Add event listener to the "select all" checkbox
|
||||
document.getElementById('select-all-overlays').addEventListener('change', function() {
|
||||
var isChecked = this.checked;
|
||||
|
||||
// Iterate through all overlays and toggle visibility
|
||||
for (var i = 0; i < overlays.length; i++) {
|
||||
if (isChecked) {
|
||||
map.addLayer(overlays[i].layer);
|
||||
} else {
|
||||
map.removeLayer(overlays[i].layer);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 500); // Slight delay to ensure the tree control has been rendered
|
||||
</script>
|
||||
{% endblock extra_js %}
|
||||
810
dbapp/mainapp/templates/mainapp/points_averaging.html
Normal file
810
dbapp/mainapp/templates/mainapp/points_averaging.html
Normal file
@@ -0,0 +1,810 @@
|
||||
{% extends "mainapp/base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Усреднение точек{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
|
||||
<style>
|
||||
.averaging-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.form-section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.table-section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
#sources-table {
|
||||
margin-top: 15px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#sources-table .tabulator-header {
|
||||
font-size: 12px;
|
||||
}
|
||||
#sources-table .tabulator-cell {
|
||||
font-size: 12px;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
.btn-group-custom {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.8);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
.loading-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
.modal-xl {
|
||||
max-width: 95%;
|
||||
}
|
||||
.group-card {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
.group-header {
|
||||
background: #f8f9fa;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.group-header.has-outliers {
|
||||
background: #fff3cd;
|
||||
}
|
||||
.group-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
.group-info-item {
|
||||
font-size: 13px;
|
||||
}
|
||||
.group-info-item strong {
|
||||
color: #495057;
|
||||
}
|
||||
.group-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.group-body {
|
||||
padding: 10px;
|
||||
}
|
||||
.points-table {
|
||||
font-size: 11px;
|
||||
width: 100%;
|
||||
}
|
||||
.points-table th, .points-table td {
|
||||
padding: 5px 6px;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.points-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.points-table tr.outlier {
|
||||
background-color: #ffcccc !important;
|
||||
}
|
||||
.points-table tr.valid {
|
||||
background-color: #d4edda !important;
|
||||
}
|
||||
.source-has-outliers {
|
||||
background-color: #fff3cd !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="loading-overlay" id="loading-overlay">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="averaging-container">
|
||||
<h2>Усреднение точек по объектам</h2>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="satellite-select" class="form-label">Спутник</label>
|
||||
<select id="satellite-select" class="form-select">
|
||||
<option value="">Выберите спутник</option>
|
||||
{% for satellite in satellites %}
|
||||
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="date-from" class="form-label">Дата с</label>
|
||||
<input type="date" id="date-from" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label for="date-to" class="form-label">Дата по</label>
|
||||
<input type="date" id="date-to" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-3 mb-3 d-flex align-items-end">
|
||||
<button id="btn-process" class="btn btn-primary w-100">
|
||||
<i class="bi bi-play-fill"></i> Загрузить данные
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-section">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5>Объекты <span id="source-count" class="badge bg-primary">0</span></h5>
|
||||
</div>
|
||||
<div class="btn-group-custom">
|
||||
<button id="export-xlsx" class="btn btn-success" disabled>
|
||||
<i class="bi bi-file-earmark-excel"></i> Сохранить в Excel
|
||||
</button>
|
||||
<button id="export-json" class="btn btn-info ms-2" disabled>
|
||||
<i class="bi bi-filetype-json"></i> Сохранить в JSON
|
||||
</button>
|
||||
<button id="clear-all" class="btn btn-danger ms-2">
|
||||
<i class="bi bi-trash"></i> Очистить всё
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sources-table"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for source details -->
|
||||
<div class="modal fade" id="sourceDetailsModal" tabindex="-1" aria-labelledby="sourceDetailsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="sourceDetailsModalLabel">Детали объекта</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-body-content">
|
||||
<!-- Groups will be rendered here -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
|
||||
<script src="{% static 'sheetjs/xlsx.full.min.js' %}"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let allSourcesData = [];
|
||||
let currentSourceIdx = null;
|
||||
let sourcesTable = null;
|
||||
|
||||
function showLoading() {
|
||||
document.getElementById('loading-overlay').classList.add('active');
|
||||
}
|
||||
function hideLoading() {
|
||||
document.getElementById('loading-overlay').classList.remove('active');
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
|
||||
function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCounts() {
|
||||
document.getElementById('source-count').textContent = allSourcesData.length;
|
||||
const hasData = allSourcesData.length > 0;
|
||||
document.getElementById('export-xlsx').disabled = !hasData;
|
||||
document.getElementById('export-json').disabled = !hasData;
|
||||
}
|
||||
|
||||
// Prepare table data from sources
|
||||
function getTableData() {
|
||||
const data = [];
|
||||
allSourcesData.forEach((source, sourceIdx) => {
|
||||
const totalPoints = source.groups.reduce((sum, g) => sum + g.valid_points_count, 0);
|
||||
const hasOutliers = source.groups.some(g => g.has_outliers);
|
||||
|
||||
// Get first group's params as representative
|
||||
const firstGroup = source.groups[0] || {};
|
||||
|
||||
data.push({
|
||||
_sourceIdx: sourceIdx,
|
||||
source_name: source.source_name,
|
||||
source_id: source.source_id,
|
||||
groups_count: source.groups.length,
|
||||
total_points: totalPoints,
|
||||
has_outliers: hasOutliers,
|
||||
frequency: firstGroup.frequency || '-',
|
||||
modulation: firstGroup.modulation || '-',
|
||||
mirrors: firstGroup.mirrors || '-',
|
||||
});
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// Initialize or update sources table
|
||||
function updateSourcesTable() {
|
||||
const data = getTableData();
|
||||
|
||||
if (!sourcesTable) {
|
||||
sourcesTable = new Tabulator("#sources-table", {
|
||||
layout: "fitDataStretch",
|
||||
height: "500px",
|
||||
placeholder: "Нет данных. Выберите спутник и диапазон дат, затем нажмите 'Загрузить данные'.",
|
||||
initialSort: [
|
||||
{column: "frequency", dir: "asc"}
|
||||
],
|
||||
columns: [
|
||||
{title: "Объект", field: "source_name", minWidth: 180, widthGrow: 2},
|
||||
{title: "Групп", field: "groups_count", minWidth: 70, hozAlign: "center"},
|
||||
{title: "Точек", field: "total_points", minWidth: 70, hozAlign: "center"},
|
||||
{title: "Частота", field: "frequency", minWidth: 100, sorter: "number"},
|
||||
{title: "Модуляция", field: "modulation", minWidth: 90},
|
||||
{title: "Зеркала", field: "mirrors", minWidth: 130},
|
||||
{
|
||||
title: "Действия",
|
||||
field: "actions",
|
||||
minWidth: 150,
|
||||
hozAlign: "center",
|
||||
formatter: function(cell) {
|
||||
const data = cell.getRow().getData();
|
||||
const outlierBadge = data.has_outliers ? '<span class="badge bg-warning me-1">!</span>' : '';
|
||||
return `${outlierBadge}
|
||||
<button class="btn btn-sm btn-primary btn-view-source" title="Открыть детали">
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger btn-delete-source ms-1" title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>`;
|
||||
},
|
||||
cellClick: function(e, cell) {
|
||||
const data = cell.getRow().getData();
|
||||
if (e.target.closest('.btn-view-source')) {
|
||||
openSourceModal(data._sourceIdx);
|
||||
} else if (e.target.closest('.btn-delete-source')) {
|
||||
deleteSource(data._sourceIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
data: data,
|
||||
rowFormatter: function(row) {
|
||||
if (row.getData().has_outliers) {
|
||||
row.getElement().classList.add('source-has-outliers');
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
sourcesTable.setData(data);
|
||||
}
|
||||
|
||||
updateCounts();
|
||||
}
|
||||
|
||||
// Delete source
|
||||
function deleteSource(sourceIdx) {
|
||||
//if (!confirm('Удалить этот объект со всеми группами?')) return;
|
||||
allSourcesData.splice(sourceIdx, 1);
|
||||
updateSourcesTable();
|
||||
}
|
||||
|
||||
// Open source modal
|
||||
function openSourceModal(sourceIdx) {
|
||||
currentSourceIdx = sourceIdx;
|
||||
const source = allSourcesData[sourceIdx];
|
||||
if (!source) return;
|
||||
|
||||
document.getElementById('sourceDetailsModalLabel').textContent = `Объект: ${source.source_name}`;
|
||||
renderModalContent();
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('sourceDetailsModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Render modal content
|
||||
function renderModalContent() {
|
||||
const source = allSourcesData[currentSourceIdx];
|
||||
if (!source) return;
|
||||
|
||||
let html = '';
|
||||
source.groups.forEach((group, groupIdx) => {
|
||||
html += renderGroupCard(group, groupIdx);
|
||||
});
|
||||
|
||||
if (source.groups.length === 0) {
|
||||
html = '<div class="alert alert-info">Нет групп для отображения</div>';
|
||||
}
|
||||
|
||||
document.getElementById('modal-body-content').innerHTML = html;
|
||||
addModalEventListeners();
|
||||
}
|
||||
|
||||
// Render group card
|
||||
function renderGroupCard(group, groupIdx) {
|
||||
const headerClass = group.has_outliers ? 'has-outliers' : '';
|
||||
|
||||
let pointsHtml = '';
|
||||
group.points.forEach((point, pointIdx) => {
|
||||
const rowClass = point.is_outlier ? 'outlier' : 'valid';
|
||||
pointsHtml += `
|
||||
<tr class="${rowClass}">
|
||||
<td>${point.id}</td>
|
||||
<td>${point.name}</td>
|
||||
<td>${point.frequency}</td>
|
||||
<td>${point.freq_range}</td>
|
||||
<td>${point.bod_velocity}</td>
|
||||
<td>${point.modulation}</td>
|
||||
<td>${point.snr}</td>
|
||||
<td>${point.timestamp}</td>
|
||||
<td>${point.mirrors}</td>
|
||||
<td>${point.coordinates}</td>
|
||||
<td>${point.distance_from_avg}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger btn-delete-point"
|
||||
data-group-idx="${groupIdx}"
|
||||
data-point-idx="${pointIdx}"
|
||||
title="Удалить точку">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="group-card" data-group-idx="${groupIdx}">
|
||||
<div class="group-header ${headerClass}">
|
||||
<div class="group-info">
|
||||
<span class="group-info-item"><strong>Интервал:</strong> ${group.interval_label}</span>
|
||||
<span class="group-info-item"><strong>Усреднённые координаты:</strong> ${group.avg_coordinates} <span class="badge bg-secondary">${group.avg_type || 'ГК'}</span></span>
|
||||
<span class="group-info-item"><strong>Медианное время:</strong> ${group.avg_time}</span>
|
||||
<span class="group-info-item"><strong>Точек:</strong> ${group.valid_points_count}/${group.total_points}</span>
|
||||
${group.has_outliers ? `<span class="badge bg-warning">Выбросов: ${group.outliers_count}</span>` : ''}
|
||||
</div>
|
||||
<div class="group-actions">
|
||||
<button class="btn btn-sm btn-primary btn-average-group" data-group-idx="${groupIdx}" title="Пересчитать усреднение">
|
||||
<i class="bi bi-calculator"></i> Усреднить
|
||||
</button>
|
||||
${group.has_outliers ? `
|
||||
<button class="btn btn-sm btn-warning btn-average-all" data-group-idx="${groupIdx}" title="Усреднить все точки">
|
||||
<i class="bi bi-arrow-repeat"></i> Все точки
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn btn-sm btn-danger btn-delete-group" data-group-idx="${groupIdx}" title="Удалить группу">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-body">
|
||||
<table class="points-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Имя</th>
|
||||
<th>Частота</th>
|
||||
<th>Полоса</th>
|
||||
<th>Симв. скорость</th>
|
||||
<th>Модуляция</th>
|
||||
<th>ОСШ</th>
|
||||
<th>Дата/Время</th>
|
||||
<th>Зеркала</th>
|
||||
<th>Координаты</th>
|
||||
<th>Расст., км</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${pointsHtml}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Add event listeners for modal
|
||||
function addModalEventListeners() {
|
||||
document.querySelectorAll('.btn-delete-group').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const groupIdx = parseInt(this.dataset.groupIdx);
|
||||
deleteGroup(groupIdx);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-delete-point').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const groupIdx = parseInt(this.dataset.groupIdx);
|
||||
const pointIdx = parseInt(this.dataset.pointIdx);
|
||||
deletePoint(groupIdx, pointIdx);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-average-group').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const groupIdx = parseInt(this.dataset.groupIdx);
|
||||
recalculateGroup(groupIdx, false);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.btn-average-all').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const groupIdx = parseInt(this.dataset.groupIdx);
|
||||
recalculateGroup(groupIdx, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Delete group
|
||||
function deleteGroup(groupIdx) {
|
||||
if (!confirm('Удалить эту группу точек?')) return;
|
||||
|
||||
const source = allSourcesData[currentSourceIdx];
|
||||
source.groups.splice(groupIdx, 1);
|
||||
|
||||
if (source.groups.length === 0) {
|
||||
allSourcesData.splice(currentSourceIdx, 1);
|
||||
bootstrap.Modal.getInstance(document.getElementById('sourceDetailsModal')).hide();
|
||||
updateSourcesTable();
|
||||
} else {
|
||||
renderModalContent();
|
||||
updateSourcesTable();
|
||||
}
|
||||
}
|
||||
|
||||
// Delete point
|
||||
function deletePoint(groupIdx, pointIdx) {
|
||||
const source = allSourcesData[currentSourceIdx];
|
||||
const group = source.groups[groupIdx];
|
||||
|
||||
if (group.points.length <= 1) {
|
||||
alert('Нельзя удалить последнюю точку. Удалите группу целиком.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Удалить эту точку и пересчитать усреднение?')) return;
|
||||
|
||||
group.points.splice(pointIdx, 1);
|
||||
group.total_points = group.points.length;
|
||||
recalculateGroup(groupIdx, true);
|
||||
}
|
||||
|
||||
// Recalculate group
|
||||
async function recalculateGroup(groupIdx, includeAll) {
|
||||
const source = allSourcesData[currentSourceIdx];
|
||||
const group = source.groups[groupIdx];
|
||||
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/points-averaging/recalculate/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': getCookie('csrftoken')
|
||||
},
|
||||
body: JSON.stringify({
|
||||
points: group.points,
|
||||
include_all: includeAll
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
alert(data.error || 'Ошибка при пересчёте');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update group data
|
||||
Object.assign(group, {
|
||||
avg_coordinates: data.avg_coordinates,
|
||||
avg_coord_tuple: data.avg_coord_tuple,
|
||||
avg_type: data.avg_type,
|
||||
total_points: data.total_points,
|
||||
valid_points_count: data.valid_points_count,
|
||||
outliers_count: data.outliers_count,
|
||||
has_outliers: data.has_outliers,
|
||||
mirrors: data.mirrors || group.mirrors,
|
||||
avg_time: data.avg_time || group.avg_time,
|
||||
points: data.points,
|
||||
});
|
||||
|
||||
renderModalContent();
|
||||
updateSourcesTable();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Произошла ошибка при пересчёте');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Process button click
|
||||
document.getElementById('btn-process').addEventListener('click', async function() {
|
||||
const satelliteId = document.getElementById('satellite-select').value;
|
||||
const dateFrom = document.getElementById('date-from').value;
|
||||
const dateTo = document.getElementById('date-to').value;
|
||||
|
||||
if (!satelliteId) { alert('Выберите спутник'); return; }
|
||||
if (!dateFrom || !dateTo) { alert('Укажите диапазон дат'); return; }
|
||||
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ satellite_id: satelliteId, date_from: dateFrom, date_to: dateTo });
|
||||
const response = await fetch(`/api/points-averaging/?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) { alert(data.error || 'Ошибка при обработке данных'); return; }
|
||||
|
||||
data.sources.forEach(source => {
|
||||
const existingIdx = allSourcesData.findIndex(s => s.source_id === source.source_id);
|
||||
if (existingIdx >= 0) {
|
||||
source.groups.forEach(newGroup => {
|
||||
const existingGroupIdx = allSourcesData[existingIdx].groups.findIndex(g => g.interval_key === newGroup.interval_key);
|
||||
if (existingGroupIdx < 0) {
|
||||
allSourcesData[existingIdx].groups.push(newGroup);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
allSourcesData.push(source);
|
||||
}
|
||||
});
|
||||
|
||||
updateSourcesTable();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Произошла ошибка при обработке данных');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
});
|
||||
|
||||
// Clear all
|
||||
document.getElementById('clear-all').addEventListener('click', function() {
|
||||
if (!confirm('Очистить все данные?')) return;
|
||||
allSourcesData = [];
|
||||
updateSourcesTable();
|
||||
});
|
||||
|
||||
// Export to Excel
|
||||
document.getElementById('export-xlsx').addEventListener('click', function() {
|
||||
if (allSourcesData.length === 0) { alert('Нет данных для экспорта'); return; }
|
||||
|
||||
const summaryData = [];
|
||||
allSourcesData.forEach(source => {
|
||||
source.groups.forEach(group => {
|
||||
summaryData.push({
|
||||
'Объект': source.source_name,
|
||||
'Частота, МГц': group.frequency,
|
||||
'Полоса, МГц': group.freq_range,
|
||||
'Символьная скорость, БОД': group.bod_velocity,
|
||||
'Модуляция': group.modulation,
|
||||
'ОСШ': group.snr,
|
||||
'Зеркала': group.mirrors,
|
||||
'Усреднённые координаты': group.avg_coordinates,
|
||||
// 'Тип усреднения': group.avg_type || 'ГК',
|
||||
'Время': group.avg_time || '-',
|
||||
'Кол-во точек': group.valid_points_count
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by frequency
|
||||
summaryData.sort((a, b) => {
|
||||
const freqA = parseFloat(a['Частота, МГц']) || 0;
|
||||
const freqB = parseFloat(b['Частота, МГц']) || 0;
|
||||
return freqA - freqB;
|
||||
});
|
||||
|
||||
const allPointsData = [];
|
||||
allSourcesData.forEach(source => {
|
||||
source.groups.forEach(group => {
|
||||
group.points.forEach(point => {
|
||||
allPointsData.push({
|
||||
'Объект': source.source_name,
|
||||
'ID точки': point.id,
|
||||
'Имя точки': point.name,
|
||||
'Частота, МГц': point.frequency,
|
||||
'Полоса, МГц': point.freq_range,
|
||||
'Символьная скорость, БОД': point.bod_velocity,
|
||||
'Модуляция': point.modulation,
|
||||
'ОСШ': point.snr,
|
||||
'Дата/Время': point.timestamp,
|
||||
'Зеркала': point.mirrors,
|
||||
'Местоположение': point.location,
|
||||
'Координаты точки': point.coordinates,
|
||||
'Усреднённые координаты': group.avg_coordinates,
|
||||
'Расстояние от среднего, км': point.distance_from_avg,
|
||||
'Статус': point.is_outlier ? 'Выброс' : 'OK'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Sort by frequency
|
||||
allPointsData.sort((a, b) => {
|
||||
const freqA = parseFloat(a['Частота, МГц']) || 0;
|
||||
const freqB = parseFloat(b['Частота, МГц']) || 0;
|
||||
return freqA - freqB;
|
||||
});
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(summaryData), "Усреднение");
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(allPointsData), "Все точки");
|
||||
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
XLSX.writeFile(wb, `averaging_${dateStr}.xlsx`);
|
||||
});
|
||||
|
||||
|
||||
// Export to JSON
|
||||
document.getElementById('export-json').addEventListener('click', function() {
|
||||
if (allSourcesData.length === 0) { alert('Нет данных для экспорта'); return; }
|
||||
|
||||
const CREATOR_ID = '6fd12c90-7f17-43d9-a03e-ee14e880f757';
|
||||
|
||||
const pathObject = {
|
||||
"tacticObjectType": "path",
|
||||
"captionPosition": "right",
|
||||
"points": [
|
||||
{"id": "b92b9cbb-dd27-49aa-bcb6-e89a147bc02c", "latitude": 57, "longitude": -13, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
|
||||
{"id": "8e3666d4-4990-4cb9-9594-63ad06333489", "latitude": 57, "longitude": 64, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
|
||||
{"id": "5f137485-d2fc-443d-8507-c936f02f3569", "latitude": 11, "longitude": 64, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
|
||||
{"id": "0fb90df7-8eb0-49fa-9d00-336389171bf5", "latitude": 11, "longitude": -13, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"},
|
||||
{"id": "3ef12637-585e-40a4-b0ee-8f1786c89ce6", "latitude": 57, "longitude": -13, "altitude": 0, "customActions": [], "tags": {"creator": CREATOR_ID}, "tacticObjectType": "point"}
|
||||
],
|
||||
"isCycle": false,
|
||||
"id": "2f604051-4984-4c2f-8c4c-c0cb64008f5f",
|
||||
"draggable": false, "selectable": false, "editable": false,
|
||||
"caption": "Ограничение для работы с поверхностями",
|
||||
"line": {"color": "rgb(148,0,211)", "thickness": 1, "dash": "solid", "border": null},
|
||||
"customActions": [],
|
||||
"tags": {"creator": CREATOR_ID}
|
||||
};
|
||||
|
||||
const result = [pathObject];
|
||||
|
||||
const jsonSourceColors = [
|
||||
"rgb(0,128,0)", "rgb(0,0,255)", "rgb(255,0,0)", "rgb(255,165,0)", "rgb(128,0,128)",
|
||||
"rgb(0,128,128)", "rgb(255,20,147)", "rgb(139,69,19)", "rgb(0,100,0)", "rgb(70,130,180)"
|
||||
];
|
||||
|
||||
allSourcesData.forEach((source, sourceIdx) => {
|
||||
const sourceColor = jsonSourceColors[sourceIdx % jsonSourceColors.length];
|
||||
|
||||
source.groups.forEach(group => {
|
||||
const avgCoord = group.avg_coord_tuple;
|
||||
const avgLat = avgCoord[1];
|
||||
const avgLon = avgCoord[0];
|
||||
const avgCaption = `${source.source_name} (усредн) - ${group.avg_time || '-'}`;
|
||||
const avgSourceId = generateUUID();
|
||||
|
||||
result.push({
|
||||
"tacticObjectType": "source",
|
||||
"captionPosition": "right",
|
||||
"id": avgSourceId,
|
||||
"icon": {"type": "triangle", "color": sourceColor},
|
||||
"caption": avgCaption,
|
||||
"name": avgCaption,
|
||||
"customActions": [],
|
||||
"trackBehavior": {},
|
||||
"bearingStyle": {"color": sourceColor, "thickness": 2, "dash": "solid", "border": null},
|
||||
"bearingBehavior": {},
|
||||
"tags": {"creator": CREATOR_ID}
|
||||
});
|
||||
|
||||
result.push({
|
||||
"tacticObjectType": "position",
|
||||
"id": generateUUID(),
|
||||
"parentId": avgSourceId,
|
||||
"timeStamp": Date.now() / 1000,
|
||||
"latitude": avgLat,
|
||||
"altitude": 0,
|
||||
"longitude": avgLon,
|
||||
"caption": "",
|
||||
"tooltip": "",
|
||||
"customActions": [],
|
||||
"tags": {"layers": [], "creator": CREATOR_ID}
|
||||
});
|
||||
|
||||
// group.points.forEach(point => {
|
||||
// if (point.is_outlier) return;
|
||||
|
||||
// const pointCoord = point.coord_tuple;
|
||||
// const pointCaption = `${point.name || '-'} - ${point.timestamp || '-'}`;
|
||||
// const pointSourceId = generateUUID();
|
||||
|
||||
// result.push({
|
||||
// "tacticObjectType": "source",
|
||||
// "captionPosition": "right",
|
||||
// "id": pointSourceId,
|
||||
// "icon": {"type": "circle", "color": sourceColor},
|
||||
// "caption": pointCaption,
|
||||
// "name": pointCaption,
|
||||
// "customActions": [],
|
||||
// "trackBehavior": {},
|
||||
// "bearingStyle": {"color": sourceColor, "thickness": 2, "dash": "solid", "border": null},
|
||||
// "bearingBehavior": {},
|
||||
// "tags": {"creator": CREATOR_ID}
|
||||
// });
|
||||
|
||||
// result.push({
|
||||
// "tacticObjectType": "position",
|
||||
// "id": generateUUID(),
|
||||
// "parentId": pointSourceId,
|
||||
// "timeStamp": point.timestamp_unix || (Date.now() / 1000),
|
||||
// "latitude": pointCoord[1],
|
||||
// "altitude": 0,
|
||||
// "longitude": pointCoord[0],
|
||||
// "caption": "",
|
||||
// "tooltip": "",
|
||||
// "customActions": [],
|
||||
// "tags": {"layers": [], "creator": CREATOR_ID}
|
||||
// });
|
||||
// });
|
||||
});
|
||||
});
|
||||
|
||||
const jsonString = JSON.stringify(result, null, 2);
|
||||
const blob = new Blob(['\uFEFF' + jsonString], {type: 'application/json;charset=utf-8'});
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `averaging_${dateStr}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
|
||||
// Initialize
|
||||
updateSourcesTable();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary">Назад</a>
|
||||
<a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary">Назад</a>
|
||||
<button type="submit" class="btn btn-success">Выполнить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
|
||||
{% block title %}Подтверждение удаления спутников{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-exclamation-triangle"></i> Подтверждение удаления
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<strong>Внимание!</strong> Вы собираетесь удалить <strong>{{ total_satellites }}</strong> спутник(ов).
|
||||
Это действие необратимо!
|
||||
</div>
|
||||
|
||||
<h5>Сводная информация:</h5>
|
||||
<ul>
|
||||
<li>Всего спутников к удалению: <strong>{{ total_satellites }}</strong></li>
|
||||
<li>Связанных транспондеров: <strong>{{ total_transponders }}</strong></li>
|
||||
</ul>
|
||||
|
||||
<h5 class="mt-4">Список спутников:</h5>
|
||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>NORAD ID</th>
|
||||
<th>Транспондеры</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for satellite in satellites_info %}
|
||||
<tr>
|
||||
<td>{{ satellite.id }}</td>
|
||||
<td>{{ satellite.name }}</td>
|
||||
<td>{{ satellite.norad }}</td>
|
||||
<td>{{ satellite.transponder_count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger mt-4">
|
||||
<strong>Предупреждение:</strong> При удалении спутников будут также удалены все связанные с ними данные.
|
||||
</div>
|
||||
|
||||
<form method="post" id="deleteForm">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="ids" value="{{ ids }}">
|
||||
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash"></i> Подтвердить удаление
|
||||
</button>
|
||||
<a href="{% url 'mainapp:satellite_list' %}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.getElementById('deleteForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
fetch('{% url "mainapp:delete_selected_satellites" %}', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = '{% url "mainapp:satellite_list" %}';
|
||||
} else {
|
||||
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Произошла ошибка при удалении');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
975
dbapp/mainapp/templates/mainapp/satellite_form.html
Normal file
975
dbapp/mainapp/templates/mainapp/satellite_form.html
Normal file
@@ -0,0 +1,975 @@
|
||||
{% extends 'mainapp/base.html' %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.frequency-plan {
|
||||
margin-top: 30px;
|
||||
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;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-controls button {
|
||||
padding: 5px 15px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-3">
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<h2>{{ title }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label">
|
||||
{{ form.name.label }} <span class="text-danger">*</span>
|
||||
</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.name.help_text %}
|
||||
<div class="form-text">{{ form.name.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.alternative_name.id_for_label }}" class="form-label">
|
||||
{{ form.alternative_name.label }}
|
||||
</label>
|
||||
{{ form.alternative_name }}
|
||||
{% if form.alternative_name.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.alternative_name.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.alternative_name.help_text %}
|
||||
<div class="form-text">{{ form.alternative_name.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.location_place.id_for_label }}" class="form-label">
|
||||
{{ form.location_place.label }}
|
||||
</label>
|
||||
{{ form.location_place }}
|
||||
{% if form.location_place.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.location_place.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.location_place.help_text %}
|
||||
<div class="form-text">{{ form.location_place.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.norad.id_for_label }}" class="form-label">
|
||||
{{ form.norad.label }}
|
||||
</label>
|
||||
{{ form.norad }}
|
||||
{% if form.norad.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.norad.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.norad.help_text %}
|
||||
<div class="form-text">{{ form.norad.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.international_code.id_for_label }}" class="form-label">
|
||||
{{ form.international_code.label }}
|
||||
</label>
|
||||
{{ form.international_code }}
|
||||
{% if form.international_code.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.international_code.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.international_code.help_text %}
|
||||
<div class="form-text">{{ form.international_code.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.undersat_point.id_for_label }}" class="form-label">
|
||||
{{ form.undersat_point.label }}
|
||||
</label>
|
||||
{{ form.undersat_point }}
|
||||
{% if form.undersat_point.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.undersat_point.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.undersat_point.help_text %}
|
||||
<div class="form-text">{{ form.undersat_point.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.launch_date.id_for_label }}" class="form-label">
|
||||
{{ form.launch_date.label }}
|
||||
</label>
|
||||
{{ form.launch_date }}
|
||||
{% if form.launch_date.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.launch_date.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.launch_date.help_text %}
|
||||
<div class="form-text">{{ form.launch_date.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.band.id_for_label }}" class="form-label">
|
||||
{{ form.band.label }}
|
||||
</label>
|
||||
{{ form.band }}
|
||||
{% if form.band.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.band.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.band.help_text %}
|
||||
<div class="form-text">{{ form.band.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.url.id_for_label }}" class="form-label">
|
||||
{{ form.url.label }}
|
||||
</label>
|
||||
{{ form.url }}
|
||||
{% if form.url.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.url.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.url.help_text %}
|
||||
<div class="form-text">{{ form.url.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.comment.id_for_label }}" class="form-label">
|
||||
{{ form.comment.label }}
|
||||
</label>
|
||||
{{ form.comment }}
|
||||
{% if form.comment.errors %}
|
||||
<div class="invalid-feedback d-block">
|
||||
{{ form.comment.errors.0 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.comment.help_text %}
|
||||
<div class="form-text">{{ form.comment.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Сохранить
|
||||
</button>
|
||||
<a href="{% url 'mainapp:satellite_list' %}" class="btn btn-secondary">
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if action == 'update' and transponders %}
|
||||
<!-- Frequency Plan Visualization -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h4>Частотный план</h4>
|
||||
<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="resetZoom">
|
||||
<i class="bi bi-arrow-clockwise"></i> Сбросить масштаб
|
||||
</button>
|
||||
<!-- <button type="button" class="btn btn-sm btn-outline-secondary" id="zoomIn">
|
||||
<i class="bi bi-zoom-in"></i> Увеличить
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="zoomOut">
|
||||
<i class="bi bi-zoom-out"></i> Уменьшить
|
||||
</button> -->
|
||||
</div>
|
||||
|
||||
<div class="frequency-chart-container">
|
||||
<canvas id="frequencyChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- <div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #0d6efd;"></div>
|
||||
<span>H - Горизонтальная</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #198754;"></div>
|
||||
<span>V - Вертикальная</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #dc3545;"></div>
|
||||
<span>L - Левая круговая</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #ffc107;"></div>
|
||||
<span>R - Правая круговая</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-color" style="background-color: #6c757d;"></div>
|
||||
<span>Другая</span>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="mt-3">
|
||||
<p><strong>Всего транспондеров:</strong> {{ transponder_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
{% if action == 'update' and transponders %}
|
||||
<script>
|
||||
// Transponder data from Django
|
||||
const transpondersData = {{ transponders|safe }};
|
||||
|
||||
// Chart state
|
||||
let canvas, ctx, container;
|
||||
let zoomLevelUL = 1;
|
||||
let zoomLevelDL = 1;
|
||||
let panOffsetUL = 0;
|
||||
let panOffsetDL = 0;
|
||||
let isDragging = false;
|
||||
let dragStartX = 0;
|
||||
let dragStartOffsetUL = 0;
|
||||
let dragStartOffsetDL = 0;
|
||||
let dragArea = null; // 'uplink' or 'downlink'
|
||||
let hoveredTransponder = null;
|
||||
let transponderRects = [];
|
||||
|
||||
// Frequency ranges for uplink and downlink
|
||||
let minFreqUL, maxFreqUL, freqRangeUL;
|
||||
let minFreqDL, maxFreqDL, freqRangeDL;
|
||||
let originalMinFreqUL, originalMaxFreqUL, originalFreqRangeUL;
|
||||
let originalMinFreqDL, originalMaxFreqDL, originalFreqRangeDL;
|
||||
|
||||
// Layout variables (need to be global for event handlers)
|
||||
let uplinkStartY, uplinkHeight, downlinkStartY, downlinkHeight;
|
||||
|
||||
function initializeFrequencyChart() {
|
||||
if (!transpondersData || transpondersData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvas = document.getElementById('frequencyChart');
|
||||
if (!canvas) return;
|
||||
|
||||
container = canvas.parentElement;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// Calculate frequency ranges separately for uplink and downlink
|
||||
minFreqUL = Infinity;
|
||||
maxFreqUL = -Infinity;
|
||||
minFreqDL = Infinity;
|
||||
maxFreqDL = -Infinity;
|
||||
|
||||
transpondersData.forEach(t => {
|
||||
// Downlink
|
||||
const dlStartFreq = t.downlink - (t.frequency_range / 2);
|
||||
const dlEndFreq = t.downlink + (t.frequency_range / 2);
|
||||
minFreqDL = Math.min(minFreqDL, dlStartFreq);
|
||||
maxFreqDL = Math.max(maxFreqDL, dlEndFreq);
|
||||
|
||||
// Uplink (if exists)
|
||||
if (t.uplink) {
|
||||
const ulStartFreq = t.uplink - (t.frequency_range / 2);
|
||||
const ulEndFreq = t.uplink + (t.frequency_range / 2);
|
||||
minFreqUL = Math.min(minFreqUL, ulStartFreq);
|
||||
maxFreqUL = Math.max(maxFreqUL, ulEndFreq);
|
||||
}
|
||||
});
|
||||
|
||||
// Add 2% padding for downlink
|
||||
const paddingDL = (maxFreqDL - minFreqDL) * 0.04;
|
||||
minFreqDL -= paddingDL;
|
||||
maxFreqDL += paddingDL;
|
||||
|
||||
// Add 2% padding for uplink (if exists)
|
||||
if (maxFreqUL !== -Infinity) {
|
||||
const paddingUL = (maxFreqUL - minFreqUL) * 0.04;
|
||||
minFreqUL -= paddingUL;
|
||||
maxFreqUL += paddingUL;
|
||||
}
|
||||
|
||||
// Store original values
|
||||
originalMinFreqDL = minFreqDL;
|
||||
originalMaxFreqDL = maxFreqDL;
|
||||
originalFreqRangeDL = maxFreqDL - minFreqDL;
|
||||
freqRangeDL = originalFreqRangeDL;
|
||||
|
||||
originalMinFreqUL = minFreqUL;
|
||||
originalMaxFreqUL = maxFreqUL;
|
||||
originalFreqRangeUL = maxFreqUL - minFreqUL;
|
||||
freqRangeUL = originalFreqRangeUL;
|
||||
|
||||
// Setup event listeners
|
||||
canvas.addEventListener('wheel', handleWheel, { passive: false });
|
||||
canvas.addEventListener('mousedown', handleMouseDown);
|
||||
canvas.addEventListener('mousemove', handleMouseMove);
|
||||
canvas.addEventListener('mouseup', handleMouseUp);
|
||||
canvas.addEventListener('mouseleave', handleMouseLeave);
|
||||
|
||||
renderChart();
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
canvas.style.width = width + 'px';
|
||||
canvas.style.height = height + 'px';
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Layout constants
|
||||
const leftMargin = 60;
|
||||
const rightMargin = 20;
|
||||
const topMargin = 60;
|
||||
const middleMargin = 60; // Space between UL and DL sections
|
||||
const bottomMargin = 40;
|
||||
const chartWidth = width - leftMargin - rightMargin;
|
||||
const availableHeight = height - topMargin - middleMargin - bottomMargin;
|
||||
|
||||
// Split available height between UL and DL
|
||||
uplinkHeight = availableHeight * 0.48;
|
||||
downlinkHeight = availableHeight * 0.48;
|
||||
|
||||
// Group transponders by polarization (use first letter only)
|
||||
const polarizationGroups = {};
|
||||
transpondersData.forEach(t => {
|
||||
let pol = t.polarization || '-';
|
||||
// Take only first letter for abbreviation
|
||||
pol = pol.charAt(0).toUpperCase();
|
||||
if (!polarizationGroups[pol]) {
|
||||
polarizationGroups[pol] = [];
|
||||
}
|
||||
polarizationGroups[pol].push(t);
|
||||
});
|
||||
|
||||
const polarizations = Object.keys(polarizationGroups);
|
||||
const rowHeightUL = uplinkHeight / polarizations.length;
|
||||
const rowHeightDL = downlinkHeight / polarizations.length;
|
||||
|
||||
// Calculate visible frequency ranges with zoom and pan for UL
|
||||
const visibleFreqRangeUL = freqRangeUL / zoomLevelUL;
|
||||
const centerFreqUL = (minFreqUL + maxFreqUL) / 2;
|
||||
const visibleMinFreqUL = centerFreqUL - visibleFreqRangeUL / 2 + panOffsetUL;
|
||||
const visibleMaxFreqUL = centerFreqUL + visibleFreqRangeUL / 2 + panOffsetUL;
|
||||
|
||||
// Calculate visible frequency ranges with zoom and pan for DL
|
||||
const visibleFreqRangeDL = freqRangeDL / zoomLevelDL;
|
||||
const centerFreqDL = (minFreqDL + maxFreqDL) / 2;
|
||||
const visibleMinFreqDL = centerFreqDL - visibleFreqRangeDL / 2 + panOffsetDL;
|
||||
const visibleMaxFreqDL = centerFreqDL + visibleFreqRangeDL / 2 + panOffsetDL;
|
||||
|
||||
uplinkStartY = topMargin;
|
||||
downlinkStartY = topMargin + uplinkHeight + middleMargin;
|
||||
|
||||
// Draw UPLINK frequency axis
|
||||
ctx.strokeStyle = '#dee2e6';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(leftMargin, uplinkStartY);
|
||||
ctx.lineTo(width - rightMargin, uplinkStartY);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw UPLINK frequency labels and grid
|
||||
ctx.fillStyle = '#6c757d';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
const numTicks = 10;
|
||||
for (let i = 0; i <= numTicks; i++) {
|
||||
const freq = visibleMinFreqUL + (visibleMaxFreqUL - visibleMinFreqUL) * i / numTicks;
|
||||
const x = leftMargin + chartWidth * i / numTicks;
|
||||
|
||||
// Draw tick
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, uplinkStartY);
|
||||
ctx.lineTo(x, uplinkStartY - 5);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw grid line
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, uplinkStartY);
|
||||
ctx.lineTo(x, uplinkStartY + uplinkHeight);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = '#dee2e6';
|
||||
|
||||
// Draw label
|
||||
ctx.fillText(freq.toFixed(1), x, uplinkStartY - 10);
|
||||
}
|
||||
|
||||
// Draw UPLINK axis title
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Uplink Частота (МГц)', width / 2, uplinkStartY - 25);
|
||||
|
||||
// Draw DOWNLINK frequency axis
|
||||
ctx.strokeStyle = '#dee2e6';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(leftMargin, downlinkStartY);
|
||||
ctx.lineTo(width - rightMargin, downlinkStartY);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw DOWNLINK frequency labels and grid
|
||||
ctx.fillStyle = '#6c757d';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
|
||||
for (let i = 0; i <= numTicks; i++) {
|
||||
const freq = visibleMinFreqDL + (visibleMaxFreqDL - visibleMinFreqDL) * i / numTicks;
|
||||
const x = leftMargin + chartWidth * i / numTicks;
|
||||
|
||||
// Draw tick
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, downlinkStartY);
|
||||
ctx.lineTo(x, downlinkStartY - 5);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw grid line
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 0.05)';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, downlinkStartY);
|
||||
ctx.lineTo(x, downlinkStartY + downlinkHeight);
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = '#dee2e6';
|
||||
|
||||
// Draw label
|
||||
ctx.fillText(freq.toFixed(1), x, downlinkStartY - 10);
|
||||
}
|
||||
|
||||
// Draw DOWNLINK axis title
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Downlink Частота (МГц)', width / 2, downlinkStartY - 25);
|
||||
|
||||
// Draw polarization label
|
||||
ctx.save();
|
||||
ctx.translate(15, height / 2);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Поляризация', 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
// Clear transponder rects for hover detection
|
||||
transponderRects = [];
|
||||
|
||||
// Draw transponders
|
||||
polarizations.forEach((pol, index) => {
|
||||
const group = polarizationGroups[pol];
|
||||
const downlinkColor = '#0000ff';
|
||||
const uplinkColor = '#fd7e14';
|
||||
|
||||
// Uplink row (now on top)
|
||||
const uplinkY = uplinkStartY + index * rowHeightUL;
|
||||
const uplinkBarHeight = rowHeightUL * 0.8;
|
||||
const uplinkBarY = uplinkY + (rowHeightUL - uplinkBarHeight) / 2;
|
||||
|
||||
// Downlink row (now on bottom)
|
||||
const downlinkY = downlinkStartY + index * rowHeightDL;
|
||||
const downlinkBarHeight = rowHeightDL * 0.8;
|
||||
const downlinkBarY = downlinkY + (rowHeightDL - downlinkBarHeight) / 2;
|
||||
|
||||
// Draw polarization label for UL section
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(pol, leftMargin - 25, uplinkBarY + uplinkBarHeight / 2 + 4);
|
||||
|
||||
// Draw polarization label for DL section
|
||||
ctx.fillText(pol, leftMargin - 25, downlinkBarY + downlinkBarHeight / 2 + 4);
|
||||
|
||||
// Draw separator lines between polarization groups
|
||||
if (index < polarizations.length - 1) {
|
||||
ctx.strokeStyle = '#adb5bd';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(leftMargin, uplinkY + rowHeightUL);
|
||||
ctx.lineTo(width - rightMargin, uplinkY + rowHeightUL);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(leftMargin, downlinkY + rowHeightDL);
|
||||
ctx.lineTo(width - rightMargin, downlinkY + rowHeightDL);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw uplink transponders (now first, on top)
|
||||
group.forEach(t => {
|
||||
if (!t.uplink) return; // Skip if no uplink data
|
||||
|
||||
const startFreq = t.uplink - (t.frequency_range / 2);
|
||||
const endFreq = t.uplink + (t.frequency_range / 2);
|
||||
|
||||
// Check if transponder is visible in UL range
|
||||
if (endFreq < visibleMinFreqUL || startFreq > visibleMaxFreqUL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate position using UL axis
|
||||
const x1 = leftMargin + ((startFreq - visibleMinFreqUL) / (visibleMaxFreqUL - visibleMinFreqUL)) * chartWidth;
|
||||
const x2 = leftMargin + ((endFreq - visibleMinFreqUL) / (visibleMaxFreqUL - visibleMinFreqUL)) * chartWidth;
|
||||
const barWidth = x2 - x1;
|
||||
|
||||
// Skip if too small
|
||||
if (barWidth < 1) return;
|
||||
|
||||
const isHovered = hoveredTransponder && hoveredTransponder.transponder.name === t.name;
|
||||
|
||||
// Draw uplink bar
|
||||
ctx.fillStyle = uplinkColor;
|
||||
ctx.fillRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
|
||||
|
||||
// Draw border (thicker if hovered)
|
||||
ctx.strokeStyle = isHovered ? '#000' : '#fff';
|
||||
ctx.lineWidth = isHovered ? 3 : 1;
|
||||
ctx.strokeRect(x1, uplinkBarY, barWidth, uplinkBarHeight);
|
||||
|
||||
// Draw name if there's space
|
||||
if (barWidth > 40) {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t.name, x1 + barWidth / 2, uplinkBarY + uplinkBarHeight / 2 + 3);
|
||||
}
|
||||
|
||||
// Store for hover detection
|
||||
transponderRects.push({
|
||||
x: x1,
|
||||
y: uplinkBarY,
|
||||
width: barWidth,
|
||||
height: uplinkBarHeight,
|
||||
transponder: t,
|
||||
type: 'uplink',
|
||||
centerX: x1 + barWidth / 2
|
||||
});
|
||||
});
|
||||
|
||||
// Draw downlink transponders (now second, on bottom)
|
||||
group.forEach(t => {
|
||||
const startFreq = t.downlink - (t.frequency_range / 2);
|
||||
const endFreq = t.downlink + (t.frequency_range / 2);
|
||||
|
||||
// Check if transponder is visible in DL range
|
||||
if (endFreq < visibleMinFreqDL || startFreq > visibleMaxFreqDL) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate position using DL axis
|
||||
const x1 = leftMargin + ((startFreq - visibleMinFreqDL) / (visibleMaxFreqDL - visibleMinFreqDL)) * chartWidth;
|
||||
const x2 = leftMargin + ((endFreq - visibleMinFreqDL) / (visibleMaxFreqDL - visibleMinFreqDL)) * chartWidth;
|
||||
const barWidth = x2 - x1;
|
||||
|
||||
if (barWidth < 1) return;
|
||||
|
||||
const isHovered = hoveredTransponder && hoveredTransponder.transponder.name === t.name;
|
||||
|
||||
// Draw downlink bar
|
||||
ctx.fillStyle = downlinkColor;
|
||||
ctx.fillRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
||||
|
||||
// Draw border (thicker if hovered)
|
||||
ctx.strokeStyle = isHovered ? '#000' : '#fff';
|
||||
ctx.lineWidth = isHovered ? 3 : 1;
|
||||
ctx.strokeRect(x1, downlinkBarY, barWidth, downlinkBarHeight);
|
||||
|
||||
// Draw name if there's space
|
||||
if (barWidth > 40) {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = isHovered ? 'bold 10px sans-serif' : '9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(t.name, x1 + barWidth / 2, downlinkBarY + downlinkBarHeight / 2 + 3);
|
||||
}
|
||||
|
||||
// Store for hover detection
|
||||
transponderRects.push({
|
||||
x: x1,
|
||||
y: downlinkBarY,
|
||||
width: barWidth,
|
||||
height: downlinkBarHeight,
|
||||
transponder: t,
|
||||
type: 'downlink',
|
||||
centerX: x1 + barWidth / 2
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Draw connection line between downlink and uplink when hovering
|
||||
if (hoveredTransponder) {
|
||||
drawConnectionLine(hoveredTransponder);
|
||||
drawTooltip(hoveredTransponder);
|
||||
}
|
||||
}
|
||||
|
||||
function drawConnectionLine(rectInfo) {
|
||||
const t = rectInfo.transponder;
|
||||
if (!t.uplink) return; // No uplink to connect
|
||||
|
||||
// Find both downlink and uplink rects for this transponder
|
||||
const downlinkRect = transponderRects.find(r => r.transponder.name === t.name && r.type === 'downlink');
|
||||
const uplinkRect = transponderRects.find(r => r.transponder.name === t.name && r.type === 'uplink');
|
||||
|
||||
if (!downlinkRect || !uplinkRect) return;
|
||||
|
||||
// Draw connecting line
|
||||
const x1 = downlinkRect.centerX;
|
||||
const y1 = downlinkRect.y + downlinkRect.height;
|
||||
const x2 = uplinkRect.centerX;
|
||||
const y2 = uplinkRect.y;
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#ffc107';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 3]);
|
||||
ctx.globalAlpha = 0.8;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawTooltip(rectInfo) {
|
||||
const t = rectInfo.transponder;
|
||||
const isUplink = rectInfo.type === 'uplink';
|
||||
const freq = isUplink ? t.uplink : t.downlink;
|
||||
const startFreq = freq - (t.frequency_range / 2);
|
||||
const endFreq = freq + (t.frequency_range / 2);
|
||||
|
||||
const lines = [
|
||||
t.name,
|
||||
'Тип: ' + (isUplink ? 'Uplink' : 'Downlink'),
|
||||
'Диапазон: ' + startFreq.toFixed(3) + ' - ' + endFreq.toFixed(3) + ' МГц',
|
||||
'Центр: ' + freq.toFixed(3) + ' МГц',
|
||||
'Полоса: ' + t.frequency_range.toFixed(3) + ' МГц',
|
||||
'Поляризация: ' + t.polarization,
|
||||
'Зона: ' + t.zone_name
|
||||
];
|
||||
|
||||
// Add frequency conversion info for uplink
|
||||
if (isUplink && t.downlink && t.uplink) {
|
||||
const conversion = t.downlink - t.uplink;
|
||||
lines.push('Перенос: ' + conversion.toFixed(3) + ' МГц');
|
||||
}
|
||||
|
||||
// Calculate tooltip size
|
||||
ctx.font = '12px sans-serif';
|
||||
const padding = 10;
|
||||
const lineHeight = 16;
|
||||
let maxWidth = 0;
|
||||
lines.forEach(line => {
|
||||
const width = ctx.measureText(line).width;
|
||||
maxWidth = Math.max(maxWidth, width);
|
||||
});
|
||||
|
||||
const tooltipWidth = maxWidth + padding * 2;
|
||||
const tooltipHeight = lines.length * lineHeight + padding * 2;
|
||||
|
||||
// Position tooltip
|
||||
const mouseX = rectInfo._mouseX || canvas.width / 2;
|
||||
const mouseY = rectInfo._mouseY || canvas.height / 2;
|
||||
let tooltipX = mouseX + 15;
|
||||
let tooltipY = mouseY - tooltipHeight - 15; // Always show above cursor
|
||||
|
||||
// Keep tooltip in bounds horizontally
|
||||
if (tooltipX + tooltipWidth > canvas.width) {
|
||||
tooltipX = mouseX - tooltipWidth - 15;
|
||||
}
|
||||
|
||||
// If tooltip goes above canvas, show below cursor instead
|
||||
if (tooltipY < 0) {
|
||||
tooltipY = mouseY + 15;
|
||||
}
|
||||
|
||||
// Draw tooltip background
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.9)';
|
||||
ctx.fillRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight);
|
||||
|
||||
// Draw tooltip text
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 12px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(lines[0], tooltipX + padding, tooltipY + padding + 12);
|
||||
|
||||
ctx.font = '11px sans-serif';
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
ctx.fillText(lines[i], tooltipX + padding, tooltipY + padding + 12 + i * lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWheel(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Determine which area we're zooming
|
||||
const isUplinkArea = mouseY < (uplinkStartY + uplinkHeight);
|
||||
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
|
||||
if (isUplinkArea) {
|
||||
const newZoom = Math.max(1, Math.min(20, zoomLevelUL * delta));
|
||||
if (newZoom !== zoomLevelUL) {
|
||||
zoomLevelUL = newZoom;
|
||||
|
||||
// Adjust pan to keep center
|
||||
const maxPan = (originalFreqRangeUL * (zoomLevelUL - 1)) / (2 * zoomLevelUL);
|
||||
panOffsetUL = Math.max(-maxPan, Math.min(maxPan, panOffsetUL));
|
||||
|
||||
renderChart();
|
||||
}
|
||||
} else {
|
||||
const newZoom = Math.max(1, Math.min(20, zoomLevelDL * delta));
|
||||
if (newZoom !== zoomLevelDL) {
|
||||
zoomLevelDL = newZoom;
|
||||
|
||||
// Adjust pan to keep center
|
||||
const maxPan = (originalFreqRangeDL * (zoomLevelDL - 1)) / (2 * zoomLevelDL);
|
||||
panOffsetDL = Math.max(-maxPan, Math.min(maxPan, panOffsetDL));
|
||||
|
||||
renderChart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Determine which area we're dragging
|
||||
dragArea = mouseY < (uplinkStartY + uplinkHeight) ? 'uplink' : 'downlink';
|
||||
|
||||
isDragging = true;
|
||||
dragStartX = e.clientX;
|
||||
dragStartOffsetUL = panOffsetUL;
|
||||
dragStartOffsetDL = panOffsetDL;
|
||||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
|
||||
function handleMouseMove(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
if (isDragging) {
|
||||
const dx = e.clientX - dragStartX;
|
||||
|
||||
if (dragArea === 'uplink') {
|
||||
const freqPerPixel = (freqRangeUL / zoomLevelUL) / (rect.width - 80);
|
||||
panOffsetUL = dragStartOffsetUL - dx * freqPerPixel;
|
||||
|
||||
// Limit pan
|
||||
const maxPan = (originalFreqRangeUL * (zoomLevelUL - 1)) / (2 * zoomLevelUL);
|
||||
panOffsetUL = Math.max(-maxPan, Math.min(maxPan, panOffsetUL));
|
||||
} else {
|
||||
const freqPerPixel = (freqRangeDL / zoomLevelDL) / (rect.width - 80);
|
||||
panOffsetDL = dragStartOffsetDL - dx * freqPerPixel;
|
||||
|
||||
// Limit pan
|
||||
const maxPan = (originalFreqRangeDL * (zoomLevelDL - 1)) / (2 * zoomLevelDL);
|
||||
panOffsetDL = Math.max(-maxPan, Math.min(maxPan, panOffsetDL));
|
||||
}
|
||||
|
||||
renderChart();
|
||||
} else {
|
||||
// Check hover
|
||||
let found = null;
|
||||
for (const tr of transponderRects) {
|
||||
if (mouseX >= tr.x && mouseX <= tr.x + tr.width &&
|
||||
mouseY >= tr.y && mouseY <= tr.y + tr.height) {
|
||||
found = tr;
|
||||
found._mouseX = mouseX;
|
||||
found._mouseY = mouseY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (found !== hoveredTransponder) {
|
||||
hoveredTransponder = found;
|
||||
canvas.style.cursor = found ? 'pointer' : 'default';
|
||||
renderChart();
|
||||
} else if (found) {
|
||||
found._mouseX = mouseX;
|
||||
found._mouseY = mouseY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
isDragging = false;
|
||||
canvas.style.cursor = hoveredTransponder ? 'pointer' : 'default';
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
isDragging = false;
|
||||
hoveredTransponder = null;
|
||||
canvas.style.cursor = 'default';
|
||||
renderChart();
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
zoomLevelUL = 1;
|
||||
zoomLevelDL = 1;
|
||||
panOffsetUL = 0;
|
||||
panOffsetDL = 0;
|
||||
renderChart();
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
zoomLevelUL = Math.min(20, zoomLevelUL * 1.2);
|
||||
zoomLevelDL = Math.min(20, zoomLevelDL * 1.2);
|
||||
renderChart();
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
zoomLevelUL = Math.max(1, zoomLevelUL / 1.2);
|
||||
zoomLevelDL = Math.max(1, zoomLevelDL / 1.2);
|
||||
if (zoomLevelUL === 1) {
|
||||
panOffsetUL = 0;
|
||||
}
|
||||
if (zoomLevelDL === 1) {
|
||||
panOffsetDL = 0;
|
||||
}
|
||||
renderChart();
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeFrequencyChart();
|
||||
|
||||
// Control buttons
|
||||
document.getElementById('resetZoom').addEventListener('click', resetZoom);
|
||||
// document.getElementById('zoomIn').addEventListener('click', zoomIn);
|
||||
// document.getElementById('zoomOut').addEventListener('click', zoomOut);
|
||||
});
|
||||
|
||||
// Re-render on window resize
|
||||
let resizeTimeout;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(renderChart, 250);
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
1426
dbapp/mainapp/templates/mainapp/satellite_list.html
Normal file
1426
dbapp/mainapp/templates/mainapp/satellite_list.html
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user