Compare commits

...

66 Commits

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

View File

@@ -2,7 +2,8 @@
# Django Settings # Django Settings
DEBUG=True DEBUG=True
ENVIRONMENT=development # ENVIRONMENT=development
DJANGO_ENVIRONMENT=development
DJANGO_SETTINGS_MODULE=dbapp.settings.development DJANGO_SETTINGS_MODULE=dbapp.settings.development
SECRET_KEY=django-insecure-dev-key-only-for-development SECRET_KEY=django-insecure-dev-key-only-for-development

View File

@@ -1,25 +1,28 @@
# Django Settings
DEBUG=False DEBUG=False
ENVIRONMENT=production # ENVIRONMENT=production
DJANGO_ENVIRONMENT=production
DJANGO_SETTINGS_MODULE=dbapp.settings.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 # Database Configuration
DB_ENGINE=django.contrib.gis.db.backends.postgis DB_ENGINE=django.contrib.gis.db.backends.postgis
DB_NAME=geodb DB_NAME=geodb
DB_USER=geralt DB_USER=geralt
DB_PASSWORD=CHANGE_THIS_STRONG_PASSWORD DB_PASSWORD=123456
DB_HOST=db DB_HOST=db
DB_PORT=5432 DB_PORT=5432
# Allowed Hosts (comma-separated) # Allowed Hosts
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com 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 # PostgreSQL Configuration
POSTGRES_DB=geodb POSTGRES_DB=geodb
POSTGRES_USER=geralt POSTGRES_USER=geralt
POSTGRES_PASSWORD=CHANGE_THIS_STRONG_PASSWORD POSTGRES_PASSWORD=123456
# Gunicorn Configuration # Redis Configuration
GUNICORN_WORKERS=3 REDIS_URL=redis://redis:6379/1
GUNICORN_TIMEOUT=120 CELERY_BROKER_URL=redis://redis:6379/0

11
.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
# Ensure shell scripts always use LF line endings
*.sh text eol=lf
entrypoint.sh text eol=lf
# Python files
*.py text eol=lf
# Docker files
Dockerfile text eol=lf
docker-compose*.yaml text eol=lf
.dockerignore text eol=lf

3
.gitignore vendored
View File

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

View File

@@ -1,249 +0,0 @@
# Чеклист для деплоя в Production
## Перед деплоем
### 1. Безопасность
- [ ] Сгенерирован новый `SECRET_KEY`
```bash
python generate_secret_key.py
```
- [ ] Изменены все пароли в `.env`:
- [ ] `DB_PASSWORD` - сильный пароль для PostgreSQL
- [ ] `POSTGRES_PASSWORD` - должен совпадать с `DB_PASSWORD`
- [ ] Настроен `ALLOWED_HOSTS` в `.env`:
```
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
```
- [ ] `DEBUG=False` в `.env`
### 2. База данных
- [ ] Проверены все миграции:
```bash
docker-compose -f docker-compose.prod.yaml exec web python manage.py showmigrations
```
- [ ] Настроен backup БД (cron job):
```bash
0 2 * * * cd /path/to/project && make backup
```
### 3. Статические файлы
- [ ] Проверена директория для статики:
```bash
docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput
```
### 4. SSL/HTTPS (опционально, но рекомендуется)
- [ ] Получены SSL сертификаты (Let's Encrypt, Certbot)
- [ ] Сертификаты размещены в `nginx/ssl/`
- [ ] Переименован `nginx/conf.d/ssl.conf.example` в `ssl.conf`
- [ ] Обновлен `server_name` в `ssl.conf`
### 5. Nginx
- [ ] Проверена конфигурация Nginx:
```bash
docker-compose -f docker-compose.prod.yaml exec nginx nginx -t
```
- [ ] Настроены правильные домены в `nginx/conf.d/default.conf`
### 6. Docker
- [ ] Проверен `.dockerignore` - исключены ненужные файлы
- [ ] Проверен `.gitignore` - не коммитятся секреты
### 7. Переменные окружения
Проверьте `.env` файл:
```bash
# Django
DEBUG=False
ENVIRONMENT=production
DJANGO_SETTINGS_MODULE=dbapp.settings.production
SECRET_KEY=<ваш-длинный-секретный-ключ>
# Database
DB_NAME=geodb
DB_USER=geralt
DB_PASSWORD=<сильный-пароль>
DB_HOST=db
DB_PORT=5432
# Allowed Hosts
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
# PostgreSQL
POSTGRES_DB=geodb
POSTGRES_USER=geralt
POSTGRES_PASSWORD=<тот-же-сильный-пароль>
# Gunicorn
GUNICORN_WORKERS=3
GUNICORN_TIMEOUT=120
```
## Деплой
### 1. Клонирование репозитория
```bash
git clone <your-repo-url>
cd <project-directory>
```
### 2. Настройка окружения
```bash
cp .env.prod .env
nano .env # Отредактируйте все необходимые переменные
```
### 3. Запуск контейнеров
```bash
docker-compose -f docker-compose.prod.yaml up -d --build
```
### 4. Проверка статуса
```bash
docker-compose -f docker-compose.prod.yaml ps
docker-compose -f docker-compose.prod.yaml logs -f
```
### 5. Создание суперпользователя
```bash
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
```
### 6. Проверка работоспособности
- [ ] Открыть http://yourdomain.com
- [ ] Открыть http://yourdomain.com/admin
- [ ] Проверить статические файлы
- [ ] Проверить медиа файлы
- [ ] Проверить TileServer GL: http://yourdomain.com:8080
## После деплоя
### 1. Мониторинг
- [ ] Настроить мониторинг логов:
```bash
docker-compose -f docker-compose.prod.yaml logs -f web
```
- [ ] Проверить использование ресурсов:
```bash
docker stats
```
### 2. Backup
- [ ] Настроить автоматический backup БД
- [ ] Проверить восстановление из backup
- [ ] Настроить backup медиа файлов
### 3. Обновления
- [ ] Документировать процесс обновления
- [ ] Тестировать обновления на dev окружении
### 4. Безопасность
- [ ] Настроить firewall (UFW, iptables)
- [ ] Ограничить доступ к портам:
- Открыть: 80, 443
- Закрыть: 5432, 8000 (доступ только внутри Docker сети)
- [ ] Настроить fail2ban (опционально)
### 5. Производительность
- [ ] Настроить кэширование (Redis, Memcached)
- [ ] Оптимизировать количество Gunicorn workers
- [ ] Настроить CDN для статики (опционально)
## Troubleshooting
### Проблема: Контейнеры не запускаются
```bash
# Проверить логи
docker-compose -f docker-compose.prod.yaml logs
# Проверить конфигурацию
docker-compose -f docker-compose.prod.yaml config
```
### Проблема: База данных недоступна
```bash
# Проверить статус БД
docker-compose -f docker-compose.prod.yaml exec db pg_isready -U geralt
# Проверить логи БД
docker-compose -f docker-compose.prod.yaml logs db
```
### Проблема: Статические файлы не загружаются
```bash
# Пересобрать статику
docker-compose -f docker-compose.prod.yaml exec web python manage.py collectstatic --noinput
# Проверить права доступа
docker-compose -f docker-compose.prod.yaml exec web ls -la /app/staticfiles
```
### Проблема: 502 Bad Gateway
```bash
# Проверить, что Django запущен
docker-compose -f docker-compose.prod.yaml ps web
# Проверить логи Gunicorn
docker-compose -f docker-compose.prod.yaml logs web
# Проверить конфигурацию Nginx
docker-compose -f docker-compose.prod.yaml exec nginx nginx -t
```
## Полезные команды
```bash
# Перезапуск сервисов
docker-compose -f docker-compose.prod.yaml restart web
docker-compose -f docker-compose.prod.yaml restart nginx
# Обновление кода
git pull
docker-compose -f docker-compose.prod.yaml up -d --build
# Backup БД
docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql
# Восстановление БД
docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql
# Просмотр логов
docker-compose -f docker-compose.prod.yaml logs -f --tail=100 web
# Очистка старых образов
docker system prune -a
```
## Контакты для поддержки
- Документация: [DOCKER_README.md](DOCKER_README.md)
- Быстрый старт: [QUICKSTART.md](QUICKSTART.md)

View File

@@ -1,102 +0,0 @@
# Инструкция по развертыванию изменений
## Шаг 1: Применение миграций
```bash
cd dbapp
python manage.py migrate
```
Это создаст таблицу `lyngsatapp_lyngsat` в базе данных.
## Шаг 2: Запуск FlareSolver (если еще не запущен)
FlareSolver необходим для обхода защиты Cloudflare на сайте Lyngsat.
### Вариант 1: Docker
```bash
docker run -d -p 8191:8191 --name flaresolverr ghcr.io/flaresolverr/flaresolverr:latest
```
### Вариант 2: Docker Compose
Добавьте в `docker-compose.yaml`:
```yaml
services:
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
container_name: flaresolverr
ports:
- "8191:8191"
restart: unless-stopped
```
Затем запустите:
```bash
docker-compose up -d flaresolverr
```
## Шаг 3: Проверка работоспособности
1. Запустите сервер разработки:
```bash
python manage.py runserver
```
2. Откройте браузер и перейдите на:
```
http://localhost:8000/actions/
```
3. Найдите карточку "Заполнение данных Lyngsat" и нажмите на кнопку
4. Выберите один-два спутника для тестирования
5. Выберите регионы (например, только Europe)
6. Нажмите "Заполнить данные" и дождитесь завершения
## Шаг 4: Проверка результатов
1. Перейдите в админ-панель Django:
```
http://localhost:8000/admin/
```
2. Откройте раздел "Lyngsatapp" → "Источники LyngSat"
3. Проверьте, что данные загружены корректно
## Возможные проблемы и решения
### Проблема: FlareSolver не отвечает
**Решение**: Проверьте, что FlareSolver запущен:
```bash
curl http://localhost:8191/v1
```
### Проблема: Спутники не найдены в базе
**Решение**: Убедитесь, что спутники добавлены в базу данных. Используйте функцию "Добавление списка спутников" на странице действий.
### Проблема: Долгое выполнение
**Решение**: Это нормально. Процесс может занять несколько минут на спутник. Начните с 1-2 спутников для тестирования.
### Проблема: Ошибки при парсинге
**Решение**: Проверьте логи. Некоторые ошибки (например, некорректные частоты) не критичны и не прерывают процесс.
## Откат изменений (если необходимо)
Если нужно откатить изменения:
```bash
# Откатить миграцию
python manage.py migrate lyngsatapp zero
# Откатить изменения в коде
git checkout HEAD -- dbapp/
```
## Дополнительная информация
- Подробное руководство пользователя: `LYNGSAT_FILL_GUIDE.md`
- Сводка изменений: `CHANGES_SUMMARY.md`
- Документация по проекту: `README.md`

View File

@@ -1,262 +0,0 @@
# Docker Setup для Django + PostGIS + TileServer GL
## Структура проекта
```
.
├── dbapp/ # Django приложение
│ ├── Dockerfile # Универсальный Dockerfile
│ ├── entrypoint.sh # Скрипт запуска
│ └── ...
├── nginx/ # Конфигурация Nginx (только для prod)
│ └── conf.d/
│ └── default.conf
├── tiles/ # Тайлы для TileServer GL
├── docker-compose.yaml # Development окружение
├── docker-compose.prod.yaml # Production окружение
├── .env.dev # Переменные для development
└── .env.prod # Переменные для production
```
## Быстрый старт
### Development
1. Скопируйте файл окружения:
```bash
cp .env.dev .env
```
2. Запустите контейнеры:
```bash
docker-compose up -d --build
```
3. Создайте суперпользователя:
```bash
docker-compose exec web python manage.py createsuperuser
```
4. Приложение доступно:
- Django: http://localhost:8000
- TileServer GL: http://localhost:8080
- PostgreSQL: localhost:5432
### Production
1. Скопируйте и настройте файл окружения:
```bash
cp .env.prod .env
# Отредактируйте .env и измените SECRET_KEY, пароли и ALLOWED_HOSTS
```
2. Запустите контейнеры:
```bash
docker-compose -f docker-compose.prod.yaml up -d --build
```
3. Создайте суперпользователя:
```bash
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
```
4. Приложение доступно:
- Nginx: http://localhost (порт 80)
- Django (напрямую): http://localhost:8000
- TileServer GL: http://localhost:8080
- PostgreSQL: localhost:5432
## Основные команды
### Development
```bash
# Запуск
docker-compose up -d
# Остановка
docker-compose down
# Просмотр логов
docker-compose logs -f web
# Выполнение команд Django
docker-compose exec web python manage.py migrate
docker-compose exec web python manage.py createsuperuser
docker-compose exec web python manage.py shell
# Пересборка после изменений в Dockerfile
docker-compose up -d --build
# Полная очистка (включая volumes)
docker-compose down -v
```
### Production
```bash
# Запуск
docker-compose -f docker-compose.prod.yaml up -d
# Остановка
docker-compose -f docker-compose.prod.yaml down
# Просмотр логов
docker-compose -f docker-compose.prod.yaml logs -f web
# Выполнение команд Django
docker-compose -f docker-compose.prod.yaml exec web python manage.py migrate
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
# Пересборка
docker-compose -f docker-compose.prod.yaml up -d --build
```
## Различия между Dev и Prod
### Development
- Django development server (runserver)
- DEBUG=True
- Код монтируется как volume (изменения применяются сразу)
- Без Nginx
- Простые пароли (для локальной разработки)
### Production
- Gunicorn WSGI server
- DEBUG=False
- Код копируется в образ (не монтируется)
- Nginx как reverse proxy
- Сильные пароли и SECRET_KEY
- Сбор статики (collectstatic)
- Оптимизированные настройки безопасности
## Переменные окружения
### Основные переменные (.env)
```bash
# Django
DEBUG=True/False
ENVIRONMENT=development/production
DJANGO_SETTINGS_MODULE=dbapp.settings.development/production
SECRET_KEY=your-secret-key
# Database
DB_NAME=geodb
DB_USER=geralt
DB_PASSWORD=your-password
DB_HOST=db
DB_PORT=5432
# Allowed Hosts
ALLOWED_HOSTS=localhost,127.0.0.1,yourdomain.com
# Gunicorn (только для production)
GUNICORN_WORKERS=3
GUNICORN_TIMEOUT=120
```
## Volumes
### Development
- `postgres_data_dev` - данные PostgreSQL
- `static_volume_dev` - статические файлы
- `media_volume_dev` - медиа файлы
- `logs_volume_dev` - логи
- `./dbapp:/app` - код приложения (live reload)
### Production
- `postgres_data_prod` - данные PostgreSQL
- `static_volume_prod` - статические файлы
- `media_volume_prod` - медиа файлы
- `logs_volume_prod` - логи
## TileServer GL
Для работы TileServer GL поместите ваши тайлы в директорию `./tiles/`.
Пример структуры:
```
tiles/
├── config.json
└── your-tiles.mbtiles
```
## Backup и восстановление БД
### Backup
```bash
# Development
docker-compose exec db pg_dump -U geralt geodb > backup.sql
# Production
docker-compose -f docker-compose.prod.yaml exec db pg_dump -U geralt geodb > backup.sql
```
### Восстановление
```bash
# Development
docker-compose exec -T db psql -U geralt geodb < backup.sql
# Production
docker-compose -f docker-compose.prod.yaml exec -T db psql -U geralt geodb < backup.sql
```
## Troubleshooting
### Проблемы с миграциями
```bash
docker-compose exec web python manage.py migrate --fake-initial
```
### Проблемы с правами доступа
```bash
docker-compose exec -u root web chown -R app:app /app
```
### Очистка всех данных
```bash
docker-compose down -v
docker system prune -a
```
### Проверка логов
```bash
# Все сервисы
docker-compose logs -f
# Конкретный сервис
docker-compose logs -f web
docker-compose logs -f db
```
## Безопасность для Production
1. **Измените SECRET_KEY** - используйте длинный случайный ключ
2. **Измените пароли БД** - используйте сильные пароли
3. **Настройте ALLOWED_HOSTS** - укажите ваш домен
4. **Настройте SSL** - добавьте сертификаты в `nginx/ssl/`
5. **Ограничьте доступ к портам** - не открывайте порты БД наружу
## Генерация SECRET_KEY
```python
python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
```
## Мониторинг
### Проверка статуса контейнеров
```bash
docker-compose ps
```
### Использование ресурсов
```bash
docker stats
```
### Healthcheck
```bash
curl http://localhost:8000/admin/
```

View File

@@ -1,307 +0,0 @@
# Docker Setup - Полное руководство
## 📋 Обзор
Этот проект использует Docker для развертывания Django приложения с PostGIS и TileServer GL.
**Основные компоненты:**
- Django 5.2 с PostGIS
- PostgreSQL 17 с расширением PostGIS 3.4
- TileServer GL для работы с картографическими тайлами
- Nginx (только для production)
- Gunicorn WSGI сервер (production)
## 🚀 Быстрый старт
### Development
```bash
cp .env.dev .env
make dev-up
make createsuperuser
```
Откройте http://localhost:8000
### Production
```bash
cp .env.prod .env
# Отредактируйте .env (SECRET_KEY, пароли, домены)
make prod-up
make prod-createsuperuser
```
Откройте http://yourdomain.com
## 📁 Структура файлов
```
.
├── dbapp/ # Django приложение
│ ├── Dockerfile # Универсальный Dockerfile
│ ├── entrypoint.sh # Скрипт инициализации
│ ├── .dockerignore # Исключения для Docker
│ └── ...
├── nginx/ # Nginx конфигурация (prod)
│ ├── conf.d/
│ │ ├── default.conf # HTTP конфигурация
│ │ └── ssl.conf.example # HTTPS конфигурация (пример)
│ └── ssl/ # SSL сертификаты
├── tiles/ # Тайлы для TileServer GL
│ ├── README.md # Инструкция по настройке
│ ├── config.json.example # Пример конфигурации
│ └── .gitignore
├── docker-compose.yaml # Development окружение
├── docker-compose.prod.yaml # Production окружение
├── .env.dev # Переменные для dev
├── .env.prod # Переменные для prod (шаблон)
├── Makefile # Удобные команды
├── generate_secret_key.py # Генератор SECRET_KEY
└── Документация:
├── QUICKSTART.md # Быстрый старт
├── DOCKER_README.md # Подробная документация
├── DEPLOYMENT_CHECKLIST.md # Чеклист для деплоя
└── DOCKER_SETUP.md # Этот файл
```
## 🔧 Конфигурация
### Dockerfile
**Один универсальный Dockerfile** для dev и prod:
- Multi-stage build для оптимизации размера
- Установка GDAL, PostGIS зависимостей
- Использование uv для управления зависимостями
- Non-root пользователь для безопасности
- Healthcheck для мониторинга
### entrypoint.sh
Скрипт автоматически:
- Ждет готовности PostgreSQL
- Выполняет миграции
- Собирает статику (только prod)
- Запускает runserver (dev) или Gunicorn (prod)
Поведение определяется переменной `ENVIRONMENT`:
- `development` → Django development server
- `production` → Gunicorn WSGI server
### docker-compose.yaml (Development)
**Сервисы:**
- `db` - PostgreSQL с PostGIS
- `web` - Django приложение
- `tileserver` - TileServer GL
**Особенности:**
- Код монтируется как volume (live reload)
- DEBUG=True
- Django development server
- Простые пароли для локальной разработки
### docker-compose.prod.yaml (Production)
**Сервисы:**
- `db` - PostgreSQL с PostGIS
- `web` - Django с Gunicorn
- `tileserver` - TileServer GL
- `nginx` - Reverse proxy
**Особенности:**
- Код копируется в образ (не монтируется)
- DEBUG=False
- Gunicorn WSGI server
- Nginx для статики и проксирования
- Сильные пароли из .env
- Сбор статики (collectstatic)
## 🔐 Безопасность
### Для Production обязательно:
1. **Сгенерируйте SECRET_KEY:**
```bash
python generate_secret_key.py
```
2. **Измените пароли БД** в `.env`
3. **Настройте ALLOWED_HOSTS:**
```
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
```
4. **Настройте SSL/HTTPS** (рекомендуется):
- Получите сертификаты (Let's Encrypt)
- Поместите в `nginx/ssl/`
- Используйте `nginx/conf.d/ssl.conf.example`
5. **Ограничьте доступ к портам:**
- Открыть: 80, 443
- Закрыть: 5432, 8000
## 📊 Мониторинг
### Логи
```bash
# Development
make dev-logs
# Production
make prod-logs
# Конкретный сервис
docker-compose logs -f web
docker-compose logs -f db
```
### Статус
```bash
make status # Development
make prod-status # Production
docker stats # Использование ресурсов
```
### Healthcheck
```bash
curl http://localhost:8000/admin/
```
## 💾 Backup и восстановление
### Backup
```bash
make backup
# или
docker-compose exec db pg_dump -U geralt geodb > backup_$(date +%Y%m%d).sql
```
### Восстановление
```bash
docker-compose exec -T db psql -U geralt geodb < backup.sql
```
### Автоматический backup (cron)
```bash
# Добавьте в crontab
0 2 * * * cd /path/to/project && make backup
```
## 🔄 Обновление
### Development
```bash
git pull
make dev-build
```
### Production
```bash
git pull
make prod-build
make prod-migrate
```
## 🗺️ TileServer GL
Поместите `.mbtiles` файлы в директорию `tiles/`:
```bash
tiles/
├── world.mbtiles
└── satellite.mbtiles
```
Доступ: http://localhost:8080
Подробнее: [tiles/README.md](tiles/README.md)
## 🛠️ Makefile команды
### Development
```bash
make dev-up # Запустить
make dev-down # Остановить
make dev-build # Пересобрать
make dev-logs # Логи
make dev-restart # Перезапустить web
```
### Production
```bash
make prod-up # Запустить
make prod-down # Остановить
make prod-build # Пересобрать
make prod-logs # Логи
make prod-restart # Перезапустить web
```
### Django
```bash
make shell # Django shell
make migrate # Миграции
make makemigrations # Создать миграции
make createsuperuser # Создать суперпользователя
make collectstatic # Собрать статику
```
### Утилиты
```bash
make backup # Backup БД
make status # Статус контейнеров
make clean # Очистка (с volumes)
make clean-all # Полная очистка
```
## 📚 Дополнительная документация
- **[QUICKSTART.md](QUICKSTART.md)** - Быстрый старт для нетерпеливых
- **[DOCKER_README.md](DOCKER_README.md)** - Подробная документация по Docker
- **[DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md)** - Чеклист для деплоя
- **[tiles/README.md](tiles/README.md)** - Настройка TileServer GL
## ❓ Troubleshooting
### Контейнеры не запускаются
```bash
docker-compose logs
docker-compose config
```
### База данных недоступна
```bash
docker-compose exec db pg_isready -U geralt
docker-compose logs db
```
### Статические файлы не загружаются
```bash
docker-compose exec web python manage.py collectstatic --noinput
docker-compose exec web ls -la /app/staticfiles
```
### 502 Bad Gateway
```bash
docker-compose ps web
docker-compose logs web
docker-compose exec nginx nginx -t
```
## 🎯 Следующие шаги
1. ✅ Прочитайте [QUICKSTART.md](QUICKSTART.md)
2. ✅ Запустите development окружение
3. ✅ Изучите [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) перед деплоем
4. ✅ Настройте TileServer GL ([tiles/README.md](tiles/README.md))
5. ✅ Настройте SSL для production
## 📞 Поддержка
При возникновении проблем:
1. Проверьте логи: `make dev-logs` или `make prod-logs`
2. Изучите документацию в этой директории
3. Проверьте [DOCKER_README.md](DOCKER_README.md) для подробностей

View File

@@ -1,240 +0,0 @@
# Обзор созданных файлов Docker Setup
## 🐳 Docker файлы
### `dbapp/Dockerfile`
**Универсальный Dockerfile** для dev и prod окружений.
- Multi-stage build для оптимизации
- Установка GDAL, PostGIS, PostgreSQL клиента
- Использование uv для управления зависимостями
- Non-root пользователь для безопасности
- Healthcheck для мониторинга
### `dbapp/entrypoint.sh`
**Скрипт инициализации контейнера.**
- Ожидание готовности PostgreSQL
- Автоматические миграции
- Сбор статики (только prod)
- Запуск runserver (dev) или Gunicorn (prod)
### `dbapp/.dockerignore`
**Исключения для Docker build.**
- Исключает ненужные файлы из образа
- Уменьшает размер образа
- Ускоряет сборку
## 🔧 Docker Compose файлы
### `docker-compose.yaml`
**Development окружение.**
- PostgreSQL с PostGIS
- Django с development server
- TileServer GL
- Код монтируется как volume (live reload)
- DEBUG=True
### `docker-compose.prod.yaml`
**Production окружение.**
- PostgreSQL с PostGIS
- Django с Gunicorn
- TileServer GL
- Nginx reverse proxy
- Код копируется в образ
- DEBUG=False
- Оптимизированные настройки
## 🌐 Nginx конфигурация
### `nginx/conf.d/default.conf`
**HTTP конфигурация для production.**
- Проксирование к Django
- Раздача статики и медиа
- Оптимизированные таймауты
- Кэширование статики
### `nginx/conf.d/ssl.conf.example`
**HTTPS конфигурация (пример).**
- SSL/TLS настройки
- Редирект с HTTP на HTTPS
- Security headers
- Оптимизированные SSL параметры
### `nginx/ssl/.gitkeep`
**Директория для SSL сертификатов.**
- Поместите сюда fullchain.pem и privkey.pem
## 🗺️ TileServer GL
### `tiles/README.md`
**Инструкция по настройке TileServer GL.**
- Как добавить тайлы
- Примеры конфигурации
- Использование в Django/Leaflet
- Где взять тайлы
### `tiles/config.json.example`
**Пример конфигурации TileServer GL.**
- Настройки путей
- Форматы и качество
- Домены
### `tiles/.gitignore`
**Исключения для git.**
- Игнорирует большие .mbtiles файлы
- Сохраняет примеры конфигурации
## 🔐 Переменные окружения
### `.env.dev`
**Переменные для development.**
- DEBUG=True
- Простые пароли для локальной разработки
- Настройки БД для dev
### `.env.prod`
**Шаблон переменных для production.**
- DEBUG=False
- Требует изменения SECRET_KEY и паролей
- Настройки для production
## 🛠️ Утилиты
### `Makefile`
**Удобные команды для работы с Docker.**
- `make dev-up` - запуск dev
- `make prod-up` - запуск prod
- `make migrate` - миграции
- `make backup` - backup БД
- И многое другое
### `generate_secret_key.py`
**Генератор Django SECRET_KEY.**
```bash
python generate_secret_key.py
```
## 📚 Документация
### `QUICKSTART.md`
**Быстрый старт.**
- Минимальные команды для запуска
- Development и Production
- Основные команды
### `DOCKER_README.md`
**Подробная документация.**
- Полное описание структуры
- Все команды с примерами
- Troubleshooting
- Backup и восстановление
### `DOCKER_SETUP.md`
**Полное руководство.**
- Обзор всей системы
- Конфигурация
- Безопасность
- Мониторинг
### `DEPLOYMENT_CHECKLIST.md`
**Чеклист для деплоя.**
- Пошаговая инструкция
- Проверка безопасности
- Настройка production
- Troubleshooting
### `FILES_OVERVIEW.md`
**Этот файл.**
- Описание всех созданных файлов
- Назначение каждого файла
## 📝 Обновленные файлы
### `.gitignore`
**Обновлен для Docker.**
- Исключает .env файлы
- Исключает логи и backup
- Исключает временные файлы
## 🎯 Как использовать
### Для начала работы:
1. Прочитайте **QUICKSTART.md**
2. Выберите окружение (dev или prod)
3. Скопируйте соответствующий .env файл
4. Запустите с помощью Makefile
### Для деплоя:
1. Прочитайте **DEPLOYMENT_CHECKLIST.md**
2. Следуйте чеклисту пошагово
3. Используйте **DOCKER_README.md** для справки
### Для настройки TileServer:
1. Прочитайте **tiles/README.md**
2. Добавьте .mbtiles файлы
3. Настройте config.json (опционально)
## 📊 Структура проекта
```
.
├── Docker конфигурация
│ ├── dbapp/Dockerfile
│ ├── dbapp/entrypoint.sh
│ ├── dbapp/.dockerignore
│ ├── docker-compose.yaml
│ └── docker-compose.prod.yaml
├── Nginx
│ ├── nginx/conf.d/default.conf
│ ├── nginx/conf.d/ssl.conf.example
│ └── nginx/ssl/.gitkeep
├── TileServer GL
│ ├── tiles/README.md
│ ├── tiles/config.json.example
│ └── tiles/.gitignore
├── Переменные окружения
│ ├── .env.dev
│ └── .env.prod
├── Утилиты
│ ├── Makefile
│ └── generate_secret_key.py
└── Документация
├── QUICKSTART.md
├── DOCKER_README.md
├── DOCKER_SETUP.md
├── DEPLOYMENT_CHECKLIST.md
└── FILES_OVERVIEW.md
```
## ✅ Что было сделано
1. ✅ Создан универсальный Dockerfile (один для dev и prod)
2. ✅ Настроен entrypoint.sh с автоматической инициализацией
3. ✅ Созданы docker-compose.yaml для dev и prod
4. ✅ Настроен Nginx для production
5. ✅ Добавлена поддержка TileServer GL
6. ✅ Созданы .env файлы для разных окружений
7. ✅ Добавлен Makefile с удобными командами
8. ✅ Написана подробная документация
9. ✅ Создан чеклист для деплоя
10. ✅ Добавлены утилиты (генератор SECRET_KEY)
## 🚀 Следующие шаги
1. Запустите development окружение
2. Протестируйте все функции
3. Подготовьте production окружение
4. Следуйте DEPLOYMENT_CHECKLIST.md
5. Настройте мониторинг и backup
## 💡 Полезные ссылки
- Django Documentation: https://docs.djangoproject.com/
- Docker Documentation: https://docs.docker.com/
- PostGIS Documentation: https://postgis.net/documentation/
- TileServer GL: https://github.com/maptiler/tileserver-gl
- Nginx Documentation: https://nginx.org/en/docs/

View File

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

View File

@@ -2,14 +2,26 @@
help: help:
@echo "Доступные команды:" @echo "Доступные команды:"
@echo ""
@echo "Development:"
@echo " make dev-up - Запустить development окружение" @echo " make dev-up - Запустить development окружение"
@echo " make dev-down - Остановить development окружение" @echo " make dev-down - Остановить development окружение"
@echo " make dev-build - Пересобрать development контейнеры" @echo " make dev-build - Пересобрать development контейнеры"
@echo " make dev-logs - Показать логи development" @echo " make dev-logs - Показать логи development"
@echo ""
@echo "Production:"
@echo " make prod-up - Запустить production окружение" @echo " make prod-up - Запустить production окружение"
@echo " make prod-down - Остановить production окружение" @echo " make prod-down - Остановить production окружение"
@echo " make prod-build - Пересобрать production контейнеры" @echo " make prod-build - Пересобрать production контейнеры"
@echo " make prod-logs - Показать логи 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 shell - Открыть Django shell"
@echo " make migrate - Выполнить миграции" @echo " make migrate - Выполнить миграции"
@echo " make createsuperuser - Создать суперпользователя" @echo " make createsuperuser - Создать суперпользователя"
@@ -97,3 +109,29 @@ status:
prod-status: prod-status:
docker-compose -f docker-compose.prod.yaml ps docker-compose -f docker-compose.prod.yaml ps
# Celery команды для production
prod-worker-logs:
docker-compose -f docker-compose.prod.yaml logs -f worker
prod-beat-logs:
docker-compose -f docker-compose.prod.yaml logs -f beat
prod-celery-status:
docker-compose -f docker-compose.prod.yaml exec web uv run celery -A dbapp inspect active
prod-celery-test:
docker-compose -f docker-compose.prod.yaml exec web uv run python test_celery.py
prod-redis-test:
docker-compose -f docker-compose.prod.yaml exec web uv run python check_redis.py
# Celery команды для development
celery-status:
cd dbapp && uv run celery -A dbapp inspect active
celery-test:
cd dbapp && uv run python test_celery.py
redis-test:
cd dbapp && uv run python check_redis.py

View File

@@ -1,106 +0,0 @@
# Быстрый старт с Docker
## Development (разработка)
```bash
# 1. Скопировать переменные окружения
cp .env.dev .env
# 2. Запустить контейнеры
make dev-up
# или
docker-compose up -d --build
# 3. Создать суперпользователя
make createsuperuser
# или
docker-compose exec web python manage.py createsuperuser
# 4. Открыть в браузере
# Django: http://localhost:8000
# Admin: http://localhost:8000/admin
# TileServer: http://localhost:8080
```
## Production (продакшн)
```bash
# 1. Скопировать и настроить переменные
cp .env.prod .env
nano .env # Измените SECRET_KEY, пароли, ALLOWED_HOSTS
# 2. Запустить контейнеры
make prod-up
# или
docker-compose -f docker-compose.prod.yaml up -d --build
# 3. Создать суперпользователя
make prod-createsuperuser
# или
docker-compose -f docker-compose.prod.yaml exec web python manage.py createsuperuser
# 4. Открыть в браузере
# Nginx: http://localhost
# Django: http://localhost:8000
# TileServer: http://localhost:8080
```
## Полезные команды
```bash
# Просмотр логов
make dev-logs # development
make prod-logs # production
# Остановка
make dev-down # development
make prod-down # production
# Перезапуск после изменений
make dev-build # development
make prod-build # production
# Django shell
make shell # development
make prod-shell # production
# Миграции
make migrate # development
make prod-migrate # production
# Backup БД
make backup
# Статус контейнеров
make status # development
make prod-status # production
```
## Структура проекта
```
.
├── dbapp/ # Django приложение
│ ├── Dockerfile # Универсальный Dockerfile
│ ├── entrypoint.sh # Скрипт запуска
│ ├── manage.py
│ └── ...
├── nginx/ # Nginx (только prod)
│ └── conf.d/
│ └── default.conf
├── tiles/ # Тайлы для TileServer GL
│ ├── README.md
│ └── config.json.example
├── docker-compose.yaml # Development
├── docker-compose.prod.yaml # Production
├── .env.dev # Переменные dev
├── .env.prod # Переменные prod
├── Makefile # Команды для удобства
└── DOCKER_README.md # Подробная документация
```
## Что дальше?
1. Прочитайте [DOCKER_README.md](DOCKER_README.md) для подробной информации
2. Настройте TileServer GL - см. [tiles/README.md](tiles/README.md)
3. Для production настройте SSL сертификаты в `nginx/ssl/`

View File

@@ -1,117 +0,0 @@
# Быстрый старт: Асинхронное заполнение данных Lyngsat
## Минимальная настройка (5 минут)
### 1. Установите зависимости
```bash
pip install -r dbapp/requirements.txt
```
### 2. Примените миграции
```bash
cd dbapp
python manage.py migrate
```
### 3. Запустите необходимые сервисы
**Терминал 1 - Redis и FlareSolver:**
```bash
docker-compose up -d redis flaresolverr
```
**Терминал 2 - Django:**
```bash
cd dbapp
python manage.py runserver
```
**Терминал 3 - Celery Worker:**
```bash
cd dbapp
celery -A dbapp worker --loglevel=info
```
### 4. Используйте систему
1. Откройте браузер: `http://localhost:8000/actions/`
2. Нажмите "Заполнить данные Lyngsat"
3. Выберите 1-2 спутника для теста
4. Выберите регион (например, Europe)
5. Нажмите "Заполнить данные"
6. Наблюдайте за прогрессом в реальном времени!
## Проверка работоспособности
### Redis
```bash
redis-cli ping
# Должно вернуть: PONG
```
### FlareSolver
```bash
curl http://localhost:8191/v1
# Должно вернуть JSON с информацией о сервисе
```
### Celery Worker
Проверьте вывод в терминале 3 - должны быть сообщения:
```
[2024-01-15 10:30:00,000: INFO/MainProcess] Connected to redis://localhost:6379/0
[2024-01-15 10:30:00,000: INFO/MainProcess] celery@hostname ready.
```
## Остановка сервисов
```bash
# Остановить Docker контейнеры
docker-compose down
# Остановить Django (Ctrl+C в терминале 2)
# Остановить Celery Worker (Ctrl+C в терминале 3)
```
## Просмотр логов
```bash
# Логи Celery Worker (если запущен с --logfile)
tail -f dbapp/logs/celery_worker.log
# Логи Docker контейнеров
docker-compose logs -f redis
docker-compose logs -f flaresolverr
```
## Что дальше?
- Прочитайте полную документацию: `ASYNC_LYNGSAT_GUIDE.md`
- Настройте production окружение
- Добавьте периодические задачи
- Настройте email уведомления
## Решение проблем
**Worker не запускается:**
```bash
# Проверьте Redis
redis-cli ping
# Проверьте переменные окружения
echo $CELERY_BROKER_URL
```
**Задача не выполняется:**
```bash
# Проверьте FlareSolver
curl http://localhost:8191/v1
# Проверьте логи worker
tail -f dbapp/logs/celery_worker.log
```
**Прогресс не обновляется:**
- Откройте консоль браузера (F12)
- Проверьте Network tab на наличие ошибок
- Обновите страницу

View File

@@ -1,57 +1,53 @@
FROM python:3.13-slim FROM python:3.13.7-slim AS builder
# Install system dependencies # Устанавливаем системные библиотеки для GIS, Postgres, сборки пакетов
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y --no-install-recommends \
gdal-bin \ build-essential \
libgdal-dev \ gdal-bin libgdal-dev \
proj-bin \ libproj-dev proj-bin \
proj-data \ libpq-dev \
libproj-dev \ && rm -rf /var/lib/apt/lists/*
libproj25 \
libgeos-dev \ WORKDIR /app
libgeos-c1v5 \
build-essential \ # Устанавливаем uv пакетно-менеджер глобально
postgresql-client \ RUN pip install --no-cache-dir uv
libpq-dev \
libpq5 \ # Копируем зависимости
netcat-openbsd \ COPY pyproject.toml uv.lock ./
gcc \
g++ \ # Синхронизируем зависимости (включая prod + dev), чтобы билдить
&& rm -rf /var/lib/apt/lists/* RUN uv sync --locked
# Set environment variables # Копируем весь код приложения
ENV PYTHONDONTWRITEBYTECODE=1 \ COPY . .
PYTHONUNBUFFERED=1
# --- рантайм-стадия — минимальный образ для продакшена ---
# Set work directory FROM python:3.13.7-slim
WORKDIR /app
WORKDIR /app
# Upgrade pip
RUN pip install --upgrade pip # Устанавливаем только runtime-системные библиотеки
RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy requirements file gdal-bin \
COPY requirements.txt ./ libproj-dev proj-bin \
libpq5 \
# Install dependencies postgresql-client \
RUN pip install --no-cache-dir -r requirements.txt && rm -rf /var/lib/apt/lists/*
# Copy project files # Копируем всё из билдера
COPY . . COPY --from=builder /usr/local/lib/python3.13 /usr/local/lib/python3.13
COPY --from=builder /usr/local/bin /usr/local/bin
# Create directories COPY --from=builder /app /app
RUN mkdir -p /app/staticfiles /app/logs /app/media
# Загружаем переменные окружения из .env (см. docker-compose)
# Set permissions for entrypoint ENV PYTHONUNBUFFERED=1 \
RUN chmod +x /app/entrypoint.sh PATH="/usr/local/bin:$PATH"
# Create non-root user # Делаем entrypoint скрипты исполняемыми
RUN useradd --create-home --shell /bin/bash app && \ RUN chmod +x /app/entrypoint.sh /app/entrypoint-celery.sh
chown -R app:app /app
EXPOSE 8000
USER app
# Используем entrypoint для инициализации (миграции, статика)
# Expose port ENTRYPOINT ["/app/entrypoint.sh"]
EXPOSE 8000
# Run entrypoint script
ENTRYPOINT ["/app/entrypoint.sh"]

96
dbapp/check_redis.py Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env python
"""
Скрипт для проверки подключения к Redis.
Запуск: python check_redis.py
"""
import os
import sys
try:
import redis
except ImportError:
print("❌ Redis библиотека не установлена")
print("Установите: pip install redis")
sys.exit(1)
def check_redis():
"""Проверка подключения к Redis"""
print("=" * 60)
print("ПРОВЕРКА REDIS")
print("=" * 60)
# Получаем URL из переменных окружения
broker_url = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
cache_url = os.getenv("REDIS_URL", "redis://localhost:6379/1")
print(f"\n1. Broker URL: {broker_url}")
print(f"2. Cache URL: {cache_url}")
# Проверка broker (database 0)
print("\n3. Проверка Celery Broker (db 0)...")
try:
r_broker = redis.from_url(broker_url)
r_broker.ping()
print(" ✓ Подключение успешно")
# Проверка ключей
keys = r_broker.keys("*")
print(f" ✓ Ключей в базе: {len(keys)}")
# Проверка очереди celery
queue_length = r_broker.llen("celery")
print(f" ✓ Задач в очереди 'celery': {queue_length}")
except redis.ConnectionError as e:
print(f" ✗ Ошибка подключения: {e}")
return False
except Exception as e:
print(f" ✗ Ошибка: {e}")
return False
# Проверка cache (database 1)
print("\n4. Проверка Django Cache (db 1)...")
try:
r_cache = redis.from_url(cache_url)
r_cache.ping()
print(" ✓ Подключение успешно")
# Проверка ключей
keys = r_cache.keys("*")
print(f" ✓ Ключей в базе: {len(keys)}")
except redis.ConnectionError as e:
print(f" ✗ Ошибка подключения: {e}")
return False
except Exception as e:
print(f" ✗ Ошибка: {e}")
return False
# Тест записи/чтения
print("\n5. Тест записи/чтения...")
try:
test_key = "test:celery:connection"
test_value = "OK"
r_broker.set(test_key, test_value, ex=10) # TTL 10 секунд
result = r_broker.get(test_key)
if result and result.decode() == test_value:
print(f" ✓ Запись/чтение работает")
r_broker.delete(test_key)
else:
print(f" ✗ Ошибка: ожидалось '{test_value}', получено '{result}'")
return False
except Exception as e:
print(f" ✗ Ошибка: {e}")
return False
print("\n" + "=" * 60)
print("ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ")
print("=" * 60)
return True
if __name__ == "__main__":
success = check_redis()
sys.exit(0 if success else 1)

View File

@@ -4,18 +4,12 @@ Celery configuration for dbapp project.
import os import os
from celery import Celery 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')) os.environ.setdefault('DJANGO_SETTINGS_MODULE', os.getenv('DJANGO_SETTINGS_MODULE', 'dbapp.settings.development'))
app = Celery('dbapp') 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') app.config_from_object('django.conf:settings', namespace='CELERY')
# Load task modules from all registered Django apps.
app.autodiscover_tasks() app.autodiscover_tasks()

View File

@@ -175,8 +175,8 @@ USE_TZ = True
# ============================================================================ # ============================================================================
LOGIN_URL = "login" LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "mainapp:home" LOGIN_REDIRECT_URL = "mainapp:source_list"
LOGOUT_REDIRECT_URL = "mainapp:home" LOGOUT_REDIRECT_URL = "mainapp:source_list"
# ============================================================================ # ============================================================================
# STATIC FILES CONFIGURATION # STATIC FILES CONFIGURATION
@@ -197,6 +197,8 @@ STATICFILES_DIRS = [
# Default primary key field type # Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://flaresolverr:8191/v1")
# ============================================================================ # ============================================================================
# THIRD-PARTY APP CONFIGURATION # THIRD-PARTY APP CONFIGURATION
# ============================================================================ # ============================================================================

View File

@@ -1,135 +1,173 @@
""" """
Production-specific settings. Production-specific settings.
""" """
import os import os
from .base import * from .base import *
# ============================================================================ # ============================================================================
# DEBUG CONFIGURATION # DEBUG CONFIGURATION
# ============================================================================ # ============================================================================
DEBUG = False DEBUG = False
# ============================================================================ # ============================================================================
# ALLOWED HOSTS # ALLOWED HOSTS
# ============================================================================ # ============================================================================
# In production, specify allowed hosts explicitly from environment variable # In production, specify allowed hosts explicitly from environment variable
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
# ============================================================================ # CSRF trusted origins (required for forms to work behind proxy)
# SECURITY SETTINGS CSRF_TRUSTED_ORIGINS = os.getenv(
# ============================================================================ "CSRF_TRUSTED_ORIGINS",
"http://localhost,http://127.0.0.1,http://localhost:8080,http://127.0.0.1:8080"
# SSL/HTTPS settings ).split(",")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True # ============================================================================
CSRF_COOKIE_SECURE = True # SECURITY SETTINGS
# ============================================================================
# Security headers
SECURE_BROWSER_XSS_FILTER = True # SSL/HTTPS settings (disable for local testing without SSL)
SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_SSL_REDIRECT = os.getenv("SECURE_SSL_REDIRECT", "False") == "True"
SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "False") == "True"
# HSTS settings CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "False") == "True"
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True # Security headers
SECURE_HSTS_PRELOAD = True SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# Additional security settings
SECURE_REDIRECT_EXEMPT = [] # HSTS settings (disable for local testing)
X_FRAME_OPTIONS = "DENY" 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"
# TEMPLATE CACHING
# ============================================================================ # Additional security settings
SECURE_REDIRECT_EXEMPT = []
TEMPLATES = [ X_FRAME_OPTIONS = "DENY"
{
"BACKEND": "django.template.backends.django.DjangoTemplates", # ============================================================================
"DIRS": [ # TEMPLATE CACHING
BASE_DIR / "templates", # ============================================================================
],
"APP_DIRS": True, TEMPLATES = [
"OPTIONS": { {
"context_processors": [ "BACKEND": "django.template.backends.django.DjangoTemplates",
"django.template.context_processors.debug", "DIRS": [
"django.template.context_processors.request", BASE_DIR / "templates",
"django.contrib.auth.context_processors.auth", ],
"django.contrib.messages.context_processors.messages", "APP_DIRS": False,
], "OPTIONS": {
"loaders": [ "context_processors": [
( "django.template.context_processors.debug",
"django.template.loaders.cached.Loader", "django.template.context_processors.request",
[ "django.contrib.auth.context_processors.auth",
"django.template.loaders.filesystem.Loader", "django.contrib.messages.context_processors.messages",
"django.template.loaders.app_directories.Loader", ],
], "loaders": [
), (
], "django.template.loaders.cached.Loader",
}, [
}, "django.template.loaders.filesystem.Loader",
] "django.template.loaders.app_directories.Loader",
],
# ============================================================================ ),
# STATIC FILES CONFIGURATION ],
# ============================================================================ },
},
STATIC_ROOT = BASE_DIR.parent / "staticfiles" ]
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
# ============================================================================
# ============================================================================ # STATIC FILES CONFIGURATION
# LOGGING CONFIGURATION # ============================================================================
# ============================================================================
STATIC_ROOT = BASE_DIR.parent / "staticfiles"
LOGGING = { STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
"version": 1,
"disable_existing_loggers": False, # ============================================================================
"formatters": { # LOGGING CONFIGURATION
"verbose": { # ============================================================================
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", LOGS_DIR = BASE_DIR.parent / "logs"
"style": "{", LOGS_DIR.mkdir(parents=True, exist_ok=True)
},
"simple": { # ============================================================================
"format": "{levelname} {message}", # CELERY LOGGING CONFIGURATION
"style": "{", # ============================================================================
}, CELERY_WORKER_HIJACK_ROOT_LOGGER = False
},
"filters": { LOGGING = {
"require_debug_false": { "version": 1,
"()": "django.utils.log.RequireDebugFalse", "disable_existing_loggers": False,
}, "formatters": {
}, "verbose": {
"handlers": { "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"console": { "style": "{",
"level": "INFO", },
"class": "logging.StreamHandler", "simple": {
"formatter": "simple", "format": "{levelname} {message}",
}, "style": "{",
"file": { },
"level": "ERROR", },
"class": "logging.FileHandler", "filters": {
"filename": BASE_DIR.parent / "logs" / "django_errors.log", "require_debug_false": {
"formatter": "verbose", "()": "django.utils.log.RequireDebugFalse",
}, },
"mail_admins": { },
"level": "ERROR", "handlers": {
"class": "django.utils.log.AdminEmailHandler", "console": {
"filters": ["require_debug_false"], "level": "INFO",
"formatter": "verbose", "class": "logging.StreamHandler",
}, "formatter": "simple",
}, },
"loggers": { "file": {
"django": { "level": "ERROR",
"handlers": ["console", "file"], "class": "logging.FileHandler",
"level": "INFO", "filename": LOGS_DIR / "django_errors.log",
"propagate": True, "formatter": "verbose",
}, },
"django.request": { "celery_file": {
"handlers": ["mail_admins", "file"], "level": "INFO",
"level": "ERROR", "class": "logging.FileHandler",
"propagate": False, "filename": LOGS_DIR / "celery.log",
}, "formatter": "verbose",
}, },
} "mail_admins": {
"level": "ERROR",
"class": "django.utils.log.AdminEmailHandler",
"filters": ["require_debug_false"],
"formatter": "verbose",
},
},
"loggers": {
"django": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": True,
},
"django.request": {
"handlers": ["mail_admins", "file"],
"level": "ERROR",
"propagate": False,
},
"celery": {
"handlers": ["console", "celery_file"],
"level": "INFO",
"propagate": False,
},
"celery.task": {
"handlers": ["console", "celery_file"],
"level": "INFO",
"propagate": False,
},
"celery.worker": {
"handlers": ["console", "celery_file"],
"level": "INFO",
"propagate": False,
},
},
}
# Force Celery to log to stdout for Docker
CELERY_WORKER_REDIRECT_STDOUTS = True
CELERY_WORKER_REDIRECT_STDOUTS_LEVEL = "INFO"

View File

@@ -1,31 +1,36 @@
""" """
URL configuration for dbapp project. URL configuration for dbapp project.
The `urlpatterns` list routes URLs to views. For more information please see: The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/ https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples: Examples:
Function views Function views
1. Add an import: from my_app import views 1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home') 2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views Class-based views
1. Add an import: from other_app.views import Home 1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.conf import settings
from django.urls import path, include from django.contrib import admin
from mainapp.views import custom_logout from django.urls import path, include
from django.contrib.auth import views as auth_views from mainapp.views import custom_logout
from debug_toolbar.toolbar import debug_toolbar_urls from django.contrib.auth import views as auth_views
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls, name='admin'), path('admin/', admin.site.urls, name='admin'),
path('', include('mainapp.urls', namespace='mainapp')), path('', include('mainapp.urls', namespace='mainapp')),
path('', include('mapsapp.urls', namespace='mapsapp')), path('', include('mapsapp.urls', namespace='mapsapp')),
path('lyngsat/', include('lyngsatapp.urls', namespace='lyngsatapp')), path('lyngsat/', include('lyngsatapp.urls', namespace='lyngsatapp')),
# Authentication URLs # Authentication URLs
path('login/', auth_views.LoginView.as_view(), name='login'), path('login/', auth_views.LoginView.as_view(), name='login'),
path('logout/', custom_logout, name='logout'), path('logout/', custom_logout, name='logout'),
] + debug_toolbar_urls() ]
# Only include debug toolbar in development
if settings.DEBUG:
from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns += debug_toolbar_urls()

View File

@@ -0,0 +1,26 @@
#!/bin/bash
set -e
echo "Starting Celery Worker..."
# Ждем PostgreSQL
echo "Waiting for PostgreSQL..."
until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
echo "PostgreSQL is unavailable - sleeping"
sleep 1
done
echo "PostgreSQL started"
# Ждем Redis (проверяем через Python, т.к. redis-cli не установлен)
echo "Waiting for Redis..."
until uv run python -c "import redis; r = redis.from_url('${CELERY_BROKER_URL}'); r.ping()" 2>/dev/null; do
echo "Redis is unavailable - sleeping"
sleep 1
done
echo "Redis started"
# Создаем директорию для логов
mkdir -p /app/logs
# Запускаем команду (celery worker или beat)
exec "$@"

77
dbapp/entrypoint.sh Executable file → Normal file
View File

@@ -1,37 +1,40 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Определяем окружение (по умолчанию production) # Определяем окружение (по умолчанию production)
ENVIRONMENT=${ENVIRONMENT:-production} ENVIRONMENT=${ENVIRONMENT:-production}
echo "Starting in $ENVIRONMENT mode..." echo "Starting in $ENVIRONMENT mode..."
# Ждем PostgreSQL if [ -d "logs" ]; then
echo "Waiting for PostgreSQL..." echo "Directory logs already exists."
while ! nc -z $DB_HOST $DB_PORT; do else
sleep 0.1 echo "Creating logs directory..."
done mkdir -p logs
echo "PostgreSQL started" fi
# Выполняем миграции echo "Waiting for PostgreSQL..."
echo "Running migrations..." until PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c '\q' 2>/dev/null; do
python manage.py migrate --noinput echo "PostgreSQL is unavailable - sleeping"
sleep 1
# Собираем статику (только для production) done
if [ "$ENVIRONMENT" = "production" ]; then echo "PostgreSQL started"
echo "Collecting static files..."
python manage.py collectstatic --noinput echo "Running migrations..."
fi uv run python manage.py migrate --noinput
# Запускаем сервер в зависимости от окружения if [ "$ENVIRONMENT" = "production" ]; then
if [ "$ENVIRONMENT" = "development" ]; then echo "Collecting static files..."
echo "Starting Django development server..." uv run python manage.py collectstatic --noinput
exec python manage.py runserver 0.0.0.0:8000 fi
else
echo "Starting Gunicorn..." if [ "$ENVIRONMENT" = "development" ]; then
exec gunicorn --bind 0.0.0.0:8000 \ echo "Starting Django development server..."
--workers ${GUNICORN_WORKERS:-3} \ exec uv run python manage.py runserver 0.0.0.0:8000
--timeout ${GUNICORN_TIMEOUT:-120} \ else
--reload \ echo "Starting Gunicorn..."
dbapp.wsgi:application exec uv run gunicorn --bind 0.0.0.0:8000 \
fi --workers ${GUNICORN_WORKERS:-3} \
--timeout ${GUNICORN_TIMEOUT:-120} \
dbapp.wsgi:application
fi

View File

@@ -6,6 +6,7 @@ from typing import Callable, Optional
from .async_parser import AsyncLyngSatParser from .async_parser import AsyncLyngSatParser
from .models import LyngSat from .models import LyngSat
from mainapp.models import Polarization, Standard, Modulation, Satellite from mainapp.models import Polarization, Standard, Modulation, Satellite
from dbapp.settings.base import FLARESOLVERR_URL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -53,9 +54,13 @@ def process_single_satellite(
logger.info(f"Найдено {len(sources)} источников для {sat_name}") logger.info(f"Найдено {len(sources)} источников для {sat_name}")
# Находим спутник в базе # Находим спутник в базе по имени или альтернативному имени (lowercase)
from django.db.models import Q
sat_name_lower = sat_name.lower()
try: 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})") logger.debug(f"Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
except Satellite.DoesNotExist: except Satellite.DoesNotExist:
error_msg = f"Спутник '{sat_name}' не найден в базе данных" error_msg = f"Спутник '{sat_name}' не найден в базе данных"
@@ -185,7 +190,7 @@ def fill_lyngsat_data_async(
try: try:
# Создаем парсер # Создаем парсер
parser = AsyncLyngSatParser( parser = AsyncLyngSatParser(
flaresolver_url="http://localhost:8191/v1", flaresolver_url=FLARESOLVERR_URL,
target_sats=target_sats, target_sats=target_sats,
regions=regions, regions=regions,
use_cache=use_cache use_cache=use_cache

View File

@@ -17,178 +17,22 @@
{% block content %} {% block content %}
<div class="container-fluid px-3"> <div class="container-fluid px-3">
<!-- Page Header -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-12"> <div class="col-12">
<h2>Источники LyngSat</h2> <h2>Данные по ИРИ с ресурса LyngSat</h2>
</div> </div>
</div> </div>
<!-- Toolbar --> <!-- Toolbar Component -->
<div class="row mb-3"> <div class="row mb-3">
<div class="col-12"> <div class="col-12">
<div class="card"> {% include 'mainapp/components/_toolbar.html' with show_search=True show_filters=True show_actions=True search_placeholder="Поиск по ID..." action_buttons=action_buttons_html %}
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-3">
<!-- 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="Поиск по ID..."
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>
<!-- 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>
<!-- Filter Toggle Button -->
<div>
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters">
<i class="bi bi-funnel"></i> Фильтры
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
</button>
</div>
<!-- Pagination -->
<div class="ms-auto">
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %}
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- Offcanvas Filter Panel --> <!-- Filter Panel Component -->
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel"> {% include 'mainapp/components/_filter_panel.html' with filters=filter_html_list %}
<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">
<!-- 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>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Polarization 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('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">
{% for polarization in polarizations %}
<option value="{{ polarization.id }}" {% if polarization.id in selected_polarizations %}selected{% endif %}>
{{ polarization.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Modulation 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('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">
{% for modulation in modulations %}
<option value="{{ modulation.id }}" {% if modulation.id in selected_modulations %}selected{% endif %}>
{{ modulation.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Standard 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('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">
{% for standard in standards %}
<option value="{{ standard.id }}" {% if standard.id in selected_standards %}selected{% endif %}>
{{ standard.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Frequency Filter -->
<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|default:'' }}">
<input type="number" step="0.001" name="freq_max" class="form-control form-control-sm"
placeholder="До" value="{{ freq_max|default:'' }}">
</div>
<!-- Symbol Rate Filter -->
<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|default:'' }}">
<input type="number" step="0.001" name="sym_max" class="form-control form-control-sm"
placeholder="До" value="{{ sym_max|default:'' }}">
</div>
<!-- Date Filter -->
<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|default:'' }}">
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm"
placeholder="До" value="{{ date_to|default:'' }}">
</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>
</form>
</div>
</div>
<!-- Main Table --> <!-- Main Table -->
<div class="row"> <div class="row">
@@ -196,54 +40,26 @@
<div class="card"> <div class="card">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;"> <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;"> <table class="table table-striped table-hover table-sm table-bordered mb-0" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top"> <thead class="table-dark sticky-top">
<tr> <tr>
<th scope="col" class="text-center" style="min-width: 60px;"> <th scope="col" class="text-center" style="min-width: 60px;">
<a href="javascript:void(0)" onclick="updateSort('id')" class="text-white text-decoration-none"> {% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}
ID
{% if sort == 'id' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-id' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th> </th>
<th scope="col" style="min-width: 120px;">Спутник</th> <th scope="col" style="min-width: 120px;">Спутник</th>
<th scope="col" style="min-width: 100px;"> <th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('frequency')" class="text-white text-decoration-none"> {% include 'mainapp/components/_sort_header.html' with field='frequency' label='Частота, МГц' current_sort=sort %}
Частота, МГц
{% if sort == 'frequency' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-frequency' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th> </th>
<th scope="col" style="min-width: 100px;">Поляризация</th> <th scope="col" style="min-width: 100px;">Поляризация</th>
<th scope="col" style="min-width: 120px;"> <th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('sym_velocity')" class="text-white text-decoration-none"> {% include 'mainapp/components/_sort_header.html' with field='sym_velocity' label='Сим. скорость, БОД' current_sort=sort %}
Сим. скорость, БОД
{% if sort == 'sym_velocity' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-sym_velocity' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th> </th>
<th scope="col" style="min-width: 100px;">Модуляция</th> <th scope="col" style="min-width: 100px;">Модуляция</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: 80px;">FEC</th>
<th scope="col" style="min-width: 150px;">Описание</th> <th scope="col" style="min-width: 150px;">Описание</th>
<th scope="col" style="min-width: 120px;"> <th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('last_update')" class="text-white text-decoration-none"> {% include 'mainapp/components/_sort_header.html' with field='last_update' label='Обновлено' current_sort=sort %}
Обновлено
{% if sort == 'last_update' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-last_update' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th> </th>
<th scope="col" style="min-width: 100px;">Ссылка</th> <th scope="col" style="min-width: 100px;">Ссылка</th>
</tr> </tr>
@@ -252,10 +68,19 @@
{% for item in lyngsat_items %} {% for item in lyngsat_items %}
<tr> <tr>
<td class="text-center">{{ item.id }}</td> <td class="text-center">{{ item.id }}</td>
<td>{{ item.id_satellite.name|default:"-" }}</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.frequency|floatformat:3|default:"-" }}</td>
<td>{{ item.polarization.name|default:"-" }}</td> <td>{{ item.polarization.name|default:"-" }}</td>
<td>{{ item.sym_velocity|floatformat:3|default:"-" }}</td> <td>{{ item.sym_velocity|floatformat:0|default:"-" }}</td>
<td>{{ item.modulation.name|default:"-" }}</td> <td>{{ item.modulation.name|default:"-" }}</td>
<td>{{ item.standard.name|default:"-" }}</td> <td>{{ item.standard.name|default:"-" }}</td>
<td>{{ item.fec|default:"-" }}</td> <td>{{ item.fec|default:"-" }}</td>
@@ -288,65 +113,11 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
{% load static %}
<!-- Include sorting functionality -->
<script src="{% static 'js/sorting.js' %}"></script>
<script> <script>
// Search functionality
function performSearch() {
const searchValue = document.getElementById('toolbar-search').value.trim();
const urlParams = new URLSearchParams(window.location.search);
if (searchValue) {
urlParams.set('search', searchValue);
} else {
urlParams.delete('search');
}
urlParams.delete('page');
window.location.search = urlParams.toString();
}
function clearSearch() {
document.getElementById('toolbar-search').value = '';
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('search');
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Handle Enter key in search input
document.getElementById('toolbar-search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
// Items per page functionality
function updateItemsPerPage() {
const itemsPerPage = document.getElementById('items-per-page').value;
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('items_per_page', itemsPerPage);
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Sorting functionality
function updateSort(field) {
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
let newSort;
if (currentSort === field) {
newSort = '-' + field;
} else if (currentSort === '-' + field) {
newSort = field;
} else {
newSort = field;
}
urlParams.set('sort', newSort);
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Function to select/deselect all options in a select element // Function to select/deselect all options in a select element
function selectAllOptions(selectName, selectAll) { function selectAllOptions(selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`); const selectElement = document.querySelector(`select[name="${selectName}"]`);
@@ -357,72 +128,24 @@ function selectAllOptions(selectName, selectAll) {
} }
} }
// Filter counter functionality // Enhanced filter counter for multi-select fields
function updateFilterCounter() {
const form = document.getElementById('filter-form');
const formData = new FormData(form);
let filterCount = 0;
// Count non-empty form fields
for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') {
// For multi-select fields, skip counting individual selections
if (key === 'satellite_id' || key === 'polarization_id' || key === 'modulation_id' || key === 'standard_id') {
continue;
}
filterCount++;
}
}
// Count selected options in multi-select fields
const multiSelectFields = ['satellite_id', 'polarization_id', 'modulation_id', 'standard_id'];
multiSelectFields.forEach(fieldName => {
const selectElement = document.querySelector(`select[name="${fieldName}"]`);
if (selectElement) {
const selectedOptions = Array.from(selectElement.selectedOptions).filter(opt => opt.selected);
if (selectedOptions.length > 0) {
filterCount++;
}
}
});
// Display the filter counter
const counterElement = document.getElementById('filterCounter');
if (counterElement) {
if (filterCount > 0) {
counterElement.textContent = filterCount;
counterElement.style.display = 'inline';
} else {
counterElement.style.display = 'none';
}
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
// Update filter counter on page load
updateFilterCounter();
// Add event listeners to form elements to update counter when filters change
const form = document.getElementById('filter-form'); const form = document.getElementById('filter-form');
if (form) { if (form) {
const inputFields = form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"]'); // Add event listeners to multi-select fields
inputFields.forEach(input => { const selectFields = form.querySelectorAll('select[multiple]');
input.addEventListener('input', updateFilterCounter);
input.addEventListener('change', updateFilterCounter);
});
const selectFields = form.querySelectorAll('select');
selectFields.forEach(select => { selectFields.forEach(select => {
select.addEventListener('change', updateFilterCounter); select.addEventListener('change', function() {
// Trigger the filter counter update from _filter_panel.html
const event = new Event('change', { bubbles: true });
form.dispatchEvent(event);
});
}); });
} }
// Update counter when offcanvas is shown
const offcanvasElement = document.getElementById('offcanvasFilters');
if (offcanvasElement) {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
}); });
</script> </script>
<!-- Include the satellite modal component -->
{% include 'mainapp/components/_satellite_modal.html' %}
{% endblock %} {% endblock %}

View File

@@ -1,175 +1,180 @@
import logging import logging
from .parser import LyngSatParser from .parser import LyngSatParser
from .models import LyngSat from .models import LyngSat
from mainapp.models import Polarization, Standard, Modulation, Satellite from mainapp.models import Polarization, Standard, Modulation, Satellite
from dbapp.settings.base import FLARESOLVERR_URL
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
def fill_lyngsat_data(
target_sats: list[str], def fill_lyngsat_data(
regions: list[str] = None, target_sats: list[str],
task_id: str = None, regions: list[str] = None,
update_progress=None task_id: str = None,
): update_progress=None
""" ):
Заполняет данные Lyngsat для указанных спутников и регионов. """
Заполняет данные Lyngsat для указанных спутников и регионов.
Args:
target_sats: Список названий спутников для обработки Args:
regions: Список регионов для парсинга (по умолчанию все) target_sats: Список названий спутников для обработки
task_id: ID задачи Celery для логирования regions: Список регионов для парсинга (по умолчанию все)
update_progress: Функция для обновления прогресса (current, total, status) task_id: ID задачи Celery для логирования
update_progress: Функция для обновления прогресса (current, total, status)
Returns:
dict: Статистика обработки с ключами: Returns:
- total_satellites: общее количество спутников dict: Статистика обработки с ключами:
- total_sources: общее количество источников - total_satellites: общее количество спутников
- created: количество созданных записей - total_sources: общее количество источников
- updated: количество обновленных записей - created: количество созданных записей
- errors: список ошибок - updated: количество обновленных записей
""" - errors: список ошибок
log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat]" """
stats = { log_prefix = f"[Task {task_id}]" if task_id else "[Lyngsat]"
'total_satellites': 0, stats = {
'total_sources': 0, 'total_satellites': 0,
'created': 0, 'total_sources': 0,
'updated': 0, 'created': 0,
'errors': [] 'updated': 0,
} 'errors': []
}
if regions is None:
regions = ["europe", "asia", "america", "atlantic"] if regions is None:
regions = ["europe", "asia", "america", "atlantic"]
logger.info(f"{log_prefix} Начало парсинга данных")
logger.info(f"{log_prefix} Спутники: {', '.join(target_sats)}") logger.info(f"{log_prefix} Начало парсинга данных")
logger.info(f"{log_prefix} Регионы: {', '.join(regions)}") logger.info(f"{log_prefix} Спутники: {', '.join(target_sats)}")
logger.info(f"{log_prefix} Регионы: {', '.join(regions)}")
if update_progress:
update_progress(0, len(target_sats), "Инициализация парсера...") if update_progress:
update_progress(0, len(target_sats), "Инициализация парсера...")
try:
parser = LyngSatParser( try:
flaresolver_url="http://localhost:8191/v1", parser = LyngSatParser(
target_sats=target_sats, flaresolver_url=FLARESOLVERR_URL,
regions=regions target_sats=target_sats,
) regions=regions
)
logger.info(f"{log_prefix} Получение данных со спутников...")
if update_progress: logger.info(f"{log_prefix} Получение данных со спутников...")
update_progress(0, len(target_sats), "Получение данных со спутников...") if update_progress:
update_progress(0, len(target_sats), "Получение данных со спутников...")
lyngsat_data = parser.get_satellites_data()
stats['total_satellites'] = len(lyngsat_data) lyngsat_data = parser.get_satellites_data()
stats['total_satellites'] = len(lyngsat_data)
logger.info(f"{log_prefix} Получено данных по {stats['total_satellites']} спутникам")
logger.info(f"{log_prefix} Получено данных по {stats['total_satellites']} спутникам")
for idx, (sat_name, data) in enumerate(lyngsat_data.items(), 1):
logger.info(f"{log_prefix} Обработка спутника {idx}/{stats['total_satellites']}: {sat_name}") for idx, (sat_name, data) in enumerate(lyngsat_data.items(), 1):
logger.info(f"{log_prefix} Обработка спутника {idx}/{stats['total_satellites']}: {sat_name}")
if update_progress:
update_progress(idx, stats['total_satellites'], f"Обработка {sat_name}...") if update_progress:
update_progress(idx, stats['total_satellites'], f"Обработка {sat_name}...")
url = data['url']
sources = data['sources'] url = data['url']
stats['total_sources'] += len(sources) sources = data['sources']
stats['total_sources'] += len(sources)
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
logger.info(f"{log_prefix} Найдено {len(sources)} источников для {sat_name}")
# Находим спутник в базе
try: # Находим спутник в базе по имени или альтернативному имени (lowercase)
sat_obj = Satellite.objects.get(name__icontains=sat_name) from django.db.models import Q
logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})") sat_name_lower = sat_name.lower()
except Satellite.DoesNotExist: try:
error_msg = f"Спутник '{sat_name}' не найден в базе данных" sat_obj = Satellite.objects.get(
logger.warning(f"{log_prefix} {error_msg}") Q(name__icontains=sat_name_lower) | Q(alternative_name__icontains=sat_name_lower)
stats['errors'].append(error_msg) )
continue logger.debug(f"{log_prefix} Спутник {sat_name} найден в базе (ID: {sat_obj.id})")
except Satellite.MultipleObjectsReturned: except Satellite.DoesNotExist:
error_msg = f"Найдено несколько спутников с именем '{sat_name}'" error_msg = f"Спутник '{sat_name}' не найден в базе данных"
logger.warning(f"{log_prefix} {error_msg}") logger.warning(f"{log_prefix} {error_msg}")
stats['errors'].append(error_msg) stats['errors'].append(error_msg)
continue continue
except Satellite.MultipleObjectsReturned:
for source_idx, source in enumerate(sources, 1): error_msg = f"Найдено несколько спутников с именем '{sat_name}'"
try: logger.warning(f"{log_prefix} {error_msg}")
# Парсим частоту stats['errors'].append(error_msg)
try: continue
freq = float(source['freq'])
except (ValueError, TypeError): for source_idx, source in enumerate(sources, 1):
freq = -1.0 try:
error_msg = f"Некорректная частота для {sat_name}: {source.get('freq')}" # Парсим частоту
logger.debug(f"{log_prefix} {error_msg}") try:
stats['errors'].append(error_msg) freq = float(source['freq'])
except (ValueError, TypeError):
last_update = source['last_update'] freq = -1.0
fec = source['metadata'].get('fec') error_msg = f"Некорректная частота для {sat_name}: {source.get('freq')}"
modulation_name = source['metadata'].get('modulation') logger.debug(f"{log_prefix} {error_msg}")
standard_name = source['metadata'].get('standard') stats['errors'].append(error_msg)
symbol_velocity = source['metadata'].get('symbol_rate')
polarization_name = source['pol'] last_update = source['last_update']
channel_info = source['provider_name'] fec = source['metadata'].get('fec')
modulation_name = source['metadata'].get('modulation')
# Создаем или получаем связанные объекты standard_name = source['metadata'].get('standard')
pol_obj, _ = Polarization.objects.get_or_create( symbol_velocity = source['metadata'].get('symbol_rate')
name=polarization_name if polarization_name else "-" polarization_name = source['pol']
) channel_info = source['provider_name']
mod_obj, _ = Modulation.objects.get_or_create( # Создаем или получаем связанные объекты
name=modulation_name if modulation_name else "-" pol_obj, _ = Polarization.objects.get_or_create(
) name=polarization_name if polarization_name else "-"
)
standard_obj, _ = Standard.objects.get_or_create(
name=standard_name if standard_name else "-" mod_obj, _ = Modulation.objects.get_or_create(
) name=modulation_name if modulation_name else "-"
)
# Создаем или обновляем запись Lyngsat
lyng_obj, created = LyngSat.objects.update_or_create( standard_obj, _ = Standard.objects.get_or_create(
id_satellite=sat_obj, name=standard_name if standard_name else "-"
frequency=freq, )
polarization=pol_obj,
defaults={ # Создаем или обновляем запись Lyngsat
"modulation": mod_obj, lyng_obj, created = LyngSat.objects.update_or_create(
"standard": standard_obj, id_satellite=sat_obj,
"sym_velocity": symbol_velocity if symbol_velocity else 0, frequency=freq,
"channel_info": channel_info[:20] if channel_info else "", polarization=pol_obj,
"last_update": last_update, defaults={
"fec": fec[:30] if fec else "", "modulation": mod_obj,
"url": url "standard": standard_obj,
} "sym_velocity": symbol_velocity if symbol_velocity else 0,
) "channel_info": channel_info[:20] if channel_info else "",
"last_update": last_update,
if created: "fec": fec[:30] if fec else "",
stats['created'] += 1 "url": url
logger.debug(f"{log_prefix} Создана запись для {sat_name} {freq} МГц") }
else: )
stats['updated'] += 1
logger.debug(f"{log_prefix} Обновлена запись для {sat_name} {freq} МГц") if created:
stats['created'] += 1
# Логируем прогресс каждые 10 источников logger.debug(f"{log_prefix} Создана запись для {sat_name} {freq} МГц")
if source_idx % 10 == 0: else:
logger.info(f"{log_prefix} Обработано {source_idx}/{len(sources)} источников для {sat_name}") stats['updated'] += 1
logger.debug(f"{log_prefix} Обновлена запись для {sat_name} {freq} МГц")
except Exception as e:
error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}" # Логируем прогресс каждые 10 источников
logger.error(f"{log_prefix} {error_msg}", exc_info=True) if source_idx % 10 == 0:
stats['errors'].append(error_msg) logger.info(f"{log_prefix} Обработано {source_idx}/{len(sources)} источников для {sat_name}")
continue
except Exception as e:
logger.info(f"{log_prefix} Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}") error_msg = f"Ошибка при обработке источника {sat_name}: {str(e)}"
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
except Exception as e: stats['errors'].append(error_msg)
error_msg = f"Критическая ошибка: {str(e)}" continue
logger.error(f"{log_prefix} {error_msg}", exc_info=True)
stats['errors'].append(error_msg) logger.info(f"{log_prefix} Завершена обработка {sat_name}: создано {stats['created']}, обновлено {stats['updated']}")
logger.info(f"{log_prefix} Обработка завершена. Итого: создано {stats['created']}, обновлено {stats['updated']}, ошибок {len(stats['errors'])}") except Exception as e:
error_msg = f"Критическая ошибка: {str(e)}"
if update_progress: logger.error(f"{log_prefix} {error_msg}", exc_info=True)
update_progress(stats['total_satellites'], stats['total_satellites'], "Завершено") stats['errors'].append(error_msg)
return stats logger.info(f"{log_prefix} Обработка завершена. Итого: создано {stats['created']}, обновлено {stats['updated']}, ошибок {len(stats['errors'])}")
if update_progress:
def link_lyngsat_to_sources(): update_progress(stats['total_satellites'], stats['total_satellites'], "Завершено")
return stats
def link_lyngsat_to_sources():
pass pass

View File

@@ -1,147 +1,285 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Q from django.db.models import Q
from django.views.generic import ListView from django.views.generic import ListView
from .models import LyngSat from .models import LyngSat
from mainapp.models import Satellite, Polarization, Modulation, Standard from mainapp.models import Satellite, Polarization, Modulation, Standard
from mainapp.utils import parse_pagination_params from mainapp.utils import parse_pagination_params
class LyngSatListView(LoginRequiredMixin, ListView): class LyngSatListView(LoginRequiredMixin, ListView):
""" """
Представление для отображения списка источников LyngSat с фильтрацией и пагинацией. Представление для отображения списка источников LyngSat с фильтрацией и пагинацией.
""" """
model = LyngSat model = LyngSat
template_name = 'lyngsatapp/lyngsat_list.html' template_name = 'lyngsatapp/lyngsat_list.html'
context_object_name = 'lyngsat_items' context_object_name = 'lyngsat_items'
paginate_by = 50 paginate_by = 50
def get_queryset(self): def get_queryset(self):
""" """
Возвращает отфильтрованный и отсортированный queryset. Возвращает отфильтрованный и отсортированный queryset.
""" """
queryset = LyngSat.objects.select_related( queryset = LyngSat.objects.select_related(
'id_satellite', 'id_satellite',
'polarization', 'polarization',
'modulation', 'modulation',
'standard' 'standard'
).all() ).all()
# Поиск по ID # Поиск по ID
search_query = self.request.GET.get('search', '').strip() search_query = self.request.GET.get('search', '').strip()
if search_query: if search_query:
try: try:
search_id = int(search_query) search_id = int(search_query)
queryset = queryset.filter(id=search_id) queryset = queryset.filter(id=search_id)
except ValueError: except ValueError:
queryset = queryset.none() queryset = queryset.none()
# Фильтр по спутнику # Фильтр по спутнику
satellite_ids = self.request.GET.getlist('satellite_id') satellite_ids = self.request.GET.getlist('satellite_id')
if satellite_ids: if satellite_ids:
queryset = queryset.filter(id_satellite_id__in=satellite_ids) queryset = queryset.filter(id_satellite_id__in=satellite_ids)
# Фильтр по поляризации # Фильтр по поляризации
polarization_ids = self.request.GET.getlist('polarization_id') polarization_ids = self.request.GET.getlist('polarization_id')
if polarization_ids: if polarization_ids:
queryset = queryset.filter(polarization_id__in=polarization_ids) queryset = queryset.filter(polarization_id__in=polarization_ids)
# Фильтр по модуляции # Фильтр по модуляции
modulation_ids = self.request.GET.getlist('modulation_id') modulation_ids = self.request.GET.getlist('modulation_id')
if modulation_ids: if modulation_ids:
queryset = queryset.filter(modulation_id__in=modulation_ids) queryset = queryset.filter(modulation_id__in=modulation_ids)
# Фильтр по стандарту # Фильтр по стандарту
standard_ids = self.request.GET.getlist('standard_id') standard_ids = self.request.GET.getlist('standard_id')
if standard_ids: if standard_ids:
queryset = queryset.filter(standard_id__in=standard_ids) queryset = queryset.filter(standard_id__in=standard_ids)
# Фильтр по частоте # Фильтр по частоте
freq_min = self.request.GET.get('freq_min', '').strip() freq_min = self.request.GET.get('freq_min', '').strip()
freq_max = self.request.GET.get('freq_max', '').strip() freq_max = self.request.GET.get('freq_max', '').strip()
if freq_min: if freq_min:
try: try:
queryset = queryset.filter(frequency__gte=float(freq_min)) queryset = queryset.filter(frequency__gte=float(freq_min))
except ValueError: except ValueError:
pass pass
if freq_max: if freq_max:
try: try:
queryset = queryset.filter(frequency__lte=float(freq_max)) queryset = queryset.filter(frequency__lte=float(freq_max))
except ValueError: except ValueError:
pass pass
# Фильтр по символьной скорости # Фильтр по символьной скорости
sym_min = self.request.GET.get('sym_min', '').strip() sym_min = self.request.GET.get('sym_min', '').strip()
sym_max = self.request.GET.get('sym_max', '').strip() sym_max = self.request.GET.get('sym_max', '').strip()
if sym_min: if sym_min:
try: try:
queryset = queryset.filter(sym_velocity__gte=float(sym_min)) queryset = queryset.filter(sym_velocity__gte=float(sym_min))
except ValueError: except ValueError:
pass pass
if sym_max: if sym_max:
try: try:
queryset = queryset.filter(sym_velocity__lte=float(sym_max)) queryset = queryset.filter(sym_velocity__lte=float(sym_max))
except ValueError: except ValueError:
pass pass
# Фильтр по дате обновления # Фильтр по дате обновления
date_from = self.request.GET.get('date_from', '').strip() date_from = self.request.GET.get('date_from', '').strip()
date_to = self.request.GET.get('date_to', '').strip() date_to = self.request.GET.get('date_to', '').strip()
if date_from: if date_from:
queryset = queryset.filter(last_update__gte=date_from) queryset = queryset.filter(last_update__gte=date_from)
if date_to: if date_to:
queryset = queryset.filter(last_update__lte=date_to) queryset = queryset.filter(last_update__lte=date_to)
# Сортировка # Сортировка
sort = self.request.GET.get('sort', '-id') sort = self.request.GET.get('sort', '-id')
valid_sort_fields = ['id', '-id', 'frequency', '-frequency', 'sym_velocity', '-sym_velocity', 'last_update', '-last_update'] valid_sort_fields = ['id', '-id', 'frequency', '-frequency', 'sym_velocity', '-sym_velocity', 'last_update', '-last_update']
if sort in valid_sort_fields: if sort in valid_sort_fields:
queryset = queryset.order_by(sort) queryset = queryset.order_by(sort)
else: else:
queryset = queryset.order_by('-id') queryset = queryset.order_by('-id')
return queryset return queryset
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
""" """
Добавляет дополнительный контекст для шаблона. Добавляет дополнительный контекст для шаблона.
""" """
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# Параметры пагинации # Параметры пагинации
page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50) page_number, items_per_page = parse_pagination_params(self.request, default_per_page=50)
context['items_per_page'] = items_per_page context['items_per_page'] = items_per_page
context['available_items_per_page'] = [25, 50, 100, 200, 500] context['available_items_per_page'] = [25, 50, 100, 200, 500]
# Пагинация # Пагинация
paginator = Paginator(self.get_queryset(), items_per_page) paginator = Paginator(self.get_queryset(), items_per_page)
page_obj = paginator.get_page(page_number) page_obj = paginator.get_page(page_number)
context['page_obj'] = page_obj context['page_obj'] = page_obj
context['lyngsat_items'] = page_obj.object_list context['lyngsat_items'] = page_obj.object_list
# Параметры поиска и фильтрации # Параметры поиска и фильтрации
context['search_query'] = self.request.GET.get('search', '') context['search_query'] = self.request.GET.get('search', '')
context['sort'] = self.request.GET.get('sort', '-id') context['sort'] = self.request.GET.get('sort', '-id')
# Данные для фильтров # Данные для фильтров - только спутники с существующими записями LyngSat
context['satellites'] = Satellite.objects.all().order_by('name') satellites = Satellite.objects.filter(
context['polarizations'] = Polarization.objects.all().order_by('name') lyngsat__isnull=False
context['modulations'] = Modulation.objects.all().order_by('name') ).distinct().order_by('name')
context['standards'] = Standard.objects.all().order_by('name') polarizations = Polarization.objects.all().order_by('name')
modulations = Modulation.objects.all().order_by('name')
# Выбранные фильтры standards = Standard.objects.all().order_by('name')
context['selected_satellites'] = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
context['selected_polarizations'] = [int(x) for x in self.request.GET.getlist('polarization_id') if x.isdigit()] # Выбранные фильтры
context['selected_modulations'] = [int(x) for x in self.request.GET.getlist('modulation_id') if x.isdigit()] selected_satellites = [int(x) for x in self.request.GET.getlist('satellite_id') if x.isdigit()]
context['selected_standards'] = [int(x) for x in self.request.GET.getlist('standard_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()]
context['freq_min'] = self.request.GET.get('freq_min', '')
context['freq_max'] = self.request.GET.get('freq_max', '') # Параметры фильтров
context['sym_min'] = self.request.GET.get('sym_min', '') freq_min = self.request.GET.get('freq_min', '')
context['sym_max'] = self.request.GET.get('sym_max', '') freq_max = self.request.GET.get('freq_max', '')
context['date_from'] = self.request.GET.get('date_from', '') sym_min = self.request.GET.get('sym_min', '')
context['date_to'] = self.request.GET.get('date_to', '') sym_max = self.request.GET.get('sym_max', '')
date_from = self.request.GET.get('date_from', '')
return context 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

View File

@@ -23,17 +23,20 @@ from .models import (
Polarization, Polarization,
Modulation, Modulation,
Standard, Standard,
SigmaParMark,
ObjectMark, ObjectMark,
ObjectInfo,
ObjectOwnership,
SigmaParameter, SigmaParameter,
Parameter, Parameter,
Satellite, Satellite,
Mirror,
Geo, Geo,
ObjItem, ObjItem,
CustomUser, CustomUser,
Band, Band,
Source, Source,
TechAnalyze,
SourceRequest,
SourceRequestStatusHistory,
) )
from .filters import ( from .filters import (
GeoKupDistanceFilter, GeoKupDistanceFilter,
@@ -344,27 +347,28 @@ class ParameterInline(admin.StackedInline):
class ObjectMarkAdmin(BaseAdmin): class ObjectMarkAdmin(BaseAdmin):
"""Админ-панель для модели ObjectMark.""" """Админ-панель для модели ObjectMark."""
list_display = ("source", "mark", "timestamp", "created_by") list_display = ("id", "tech_analyze", "mark", "timestamp", "created_by")
list_select_related = ("source", "created_by__user") list_display_links = ("id",)
search_fields = ("source__id",) list_select_related = ("tech_analyze", "tech_analyze__satellite", "created_by__user")
list_editable = ("tech_analyze", "mark", "timestamp")
search_fields = ("tech_analyze__name", "tech_analyze__id")
ordering = ("-timestamp",) ordering = ("-timestamp",)
list_filter = ( list_filter = (
"mark", "mark",
("timestamp", DateRangeQuickSelectListFilterBuilder()), ("timestamp", DateRangeQuickSelectListFilterBuilder()),
("source", MultiSelectRelatedDropdownFilter), ("tech_analyze__satellite", MultiSelectRelatedDropdownFilter),
) )
readonly_fields = ("timestamp", "created_by") autocomplete_fields = ("tech_analyze",)
autocomplete_fields = ("source",)
@admin.register(SigmaParMark) # @admin.register(SigmaParMark)
class SigmaParMarkAdmin(BaseAdmin): # class SigmaParMarkAdmin(BaseAdmin):
"""Админ-панель для модели SigmaParMark.""" # """Админ-панель для модели SigmaParMark."""
list_display = ("mark", "timestamp") # list_display = ("mark", "timestamp")
search_fields = ("mark",) # search_fields = ("mark",)
ordering = ("-timestamp",) # ordering = ("-timestamp",)
list_filter = (("timestamp", DateRangeQuickSelectListFilterBuilder()),) # list_filter = (("timestamp", DateRangeQuickSelectListFilterBuilder()),)
@admin.register(Polarization) @admin.register(Polarization)
@@ -394,10 +398,27 @@ class StandardAdmin(BaseAdmin):
ordering = ("name",) ordering = ("name",)
@admin.register(ObjectInfo)
class ObjectInfoAdmin(BaseAdmin):
"""Админ-панель для модели ObjectInfo (Тип объекта)."""
list_display = ("name",)
search_fields = ("name",)
ordering = ("name",)
@admin.register(ObjectOwnership)
class ObjectOwnershipAdmin(BaseAdmin):
"""Админ-панель для модели ObjectOwnership (Принадлежность объекта)."""
list_display = ("name",)
search_fields = ("name",)
ordering = ("name",)
class SigmaParameterInline(admin.StackedInline): class SigmaParameterInline(admin.StackedInline):
model = SigmaParameter model = SigmaParameter
extra = 0 extra = 0
autocomplete_fields = ["mark"]
readonly_fields = ( readonly_fields = (
"datetime_begin", "datetime_begin",
"datetime_end", "datetime_end",
@@ -541,7 +562,6 @@ class SigmaParameterAdmin(ImportExportActionModelAdmin, BaseAdmin):
"modulation__name", "modulation__name",
"standard__name", "standard__name",
) )
autocomplete_fields = ("mark",)
ordering = ("-frequency",) ordering = ("-frequency",)
def get_queryset(self, request): def get_queryset(self, request):
@@ -556,26 +576,28 @@ class SatelliteAdmin(BaseAdmin):
list_display = ( list_display = (
"name", "name",
"alternative_name",
"norad", "norad",
"international_code",
"undersat_point", "undersat_point",
"launch_date", "launch_date",
"created_at", "created_at",
"updated_at", "updated_at",
) )
search_fields = ("name", "norad") search_fields = ("name", "alternative_name", "norad", "international_code")
ordering = ("name",) ordering = ("name",)
filter_horizontal = ("band",) filter_horizontal = ("band",)
autocomplete_fields = ("band",) autocomplete_fields = ("band",)
readonly_fields = ("created_at", "created_by", "updated_at", "updated_by") readonly_fields = ("created_at", "created_by", "updated_at", "updated_by")
@admin.register(Mirror) # @admin.register(Mirror)
class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin): # class MirrorAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""Админ-панель для модели Mirror с поддержкой импорта/экспорта.""" # """Админ-панель для модели Mirror с поддержкой импорта/экспорта."""
list_display = ("name",) # list_display = ("name",)
search_fields = ("name",) # search_fields = ("name",)
ordering = ("name",) # ordering = ("name",)
@admin.register(Geo) @admin.register(Geo)
@@ -1036,20 +1058,26 @@ class ObjItemInline(admin.TabularInline):
class SourceAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin): class SourceAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
"""Админ-панель для модели Source.""" """Админ-панель для модели Source."""
list_display = ("id", "created_at", "updated_at") list_display = ("id", "info", "created_at", "updated_at")
list_select_related = ("info",)
list_filter = ( list_filter = (
("info", MultiSelectRelatedDropdownFilter),
("created_at", DateRangeQuickSelectListFilterBuilder()), ("created_at", DateRangeQuickSelectListFilterBuilder()),
("updated_at", DateRangeQuickSelectListFilterBuilder()), ("updated_at", DateRangeQuickSelectListFilterBuilder()),
) )
search_fields = ("id",) search_fields = ("id", "info__name")
ordering = ("-created_at",) ordering = ("-created_at",)
readonly_fields = ("created_at", "created_by", "updated_at", "updated_by") readonly_fields = ("created_at", "created_by", "updated_at", "updated_by")
inlines = [ObjItemInline] inlines = [ObjItemInline]
fieldsets = ( fieldsets = (
( (
"Координаты: геолокация", "Основная информация",
{"fields": ("coords_kupsat", "coords_valid", "coords_reference")}, {"fields": ("info",)},
),
(
"Координаты",
{"fields": ("coords_average", "coords_kupsat", "coords_valid", "coords_reference")},
), ),
( (
"Метаданные", "Метаданные",
@@ -1059,3 +1087,199 @@ class SourceAdmin(ImportExportActionModelAdmin, LeafletGeoAdmin, BaseAdmin):
}, },
), ),
) )
autocomplete_fields = ("info",)
@admin.register(TechAnalyze)
class TechAnalyzeAdmin(ImportExportActionModelAdmin, BaseAdmin):
"""Админ-панель для модели TechAnalyze."""
list_display = (
"name",
"satellite",
"frequency",
"freq_range",
"polarization",
"bod_velocity",
"modulation",
"standard",
"created_at",
"updated_at",
)
list_display_links = ("name",)
list_select_related = (
"satellite",
"polarization",
"modulation",
"standard",
"created_by__user",
"updated_by__user",
)
list_filter = (
("satellite", MultiSelectRelatedDropdownFilter),
("polarization", MultiSelectRelatedDropdownFilter),
("modulation", MultiSelectRelatedDropdownFilter),
("standard", MultiSelectRelatedDropdownFilter),
("frequency", NumericRangeFilterBuilder()),
("freq_range", NumericRangeFilterBuilder()),
("created_at", DateRangeQuickSelectListFilterBuilder()),
("updated_at", DateRangeQuickSelectListFilterBuilder()),
)
search_fields = (
"name",
"satellite__name",
"frequency",
"note",
)
ordering = ("-created_at",)
readonly_fields = ("created_at", "created_by", "updated_at", "updated_by")
autocomplete_fields = ("satellite", "polarization", "modulation", "standard")
fieldsets = (
(
"Основная информация",
{"fields": ("name", "satellite", "note")},
),
(
"Технические параметры",
{
"fields": (
"frequency",
"freq_range",
"polarization",
"bod_velocity",
"modulation",
"standard",
)
},
),
(
"Метаданные",
{
"fields": ("created_at", "created_by", "updated_at", "updated_by"),
"classes": ("collapse",),
},
),
)
class SourceRequestStatusHistoryInline(admin.TabularInline):
"""Inline для отображения истории статусов заявки."""
model = SourceRequestStatusHistory
extra = 0
readonly_fields = ('old_status', 'new_status', 'changed_at', 'changed_by')
can_delete = False
def has_add_permission(self, request, obj=None):
return False
@admin.register(SourceRequest)
class SourceRequestAdmin(BaseAdmin):
"""Админ-панель для модели SourceRequest."""
list_display = (
'id',
'source',
'status',
'priority',
'planned_at',
'request_date',
'gso_success',
'kubsat_success',
'points_count',
'status_updated_at',
'created_at',
'created_by',
)
list_display_links = ('id', 'source')
list_select_related = ('source', 'created_by__user', 'updated_by__user')
list_filter = (
'status',
'priority',
'gso_success',
'kubsat_success',
('planned_at', DateRangeQuickSelectListFilterBuilder()),
('request_date', DateRangeQuickSelectListFilterBuilder()),
('created_at', DateRangeQuickSelectListFilterBuilder()),
)
search_fields = (
'source__id',
'comment',
)
ordering = ('-created_at',)
readonly_fields = ('status_updated_at', 'created_at', 'created_by', 'updated_by', 'coords', 'points_count')
autocomplete_fields = ('source',)
inlines = [SourceRequestStatusHistoryInline]
fieldsets = (
(
'Основная информация',
{'fields': ('source', 'status', 'priority')},
),
(
'Даты',
{'fields': ('planned_at', 'request_date', 'status_updated_at')},
),
(
'Результаты',
{'fields': ('gso_success', 'kubsat_success')},
),
(
'Координаты',
{'fields': ('coords', 'points_count')},
),
(
'Комментарий',
{'fields': ('comment',)},
),
(
'Метаданные',
{
'fields': ('created_at', 'created_by', 'updated_by'),
'classes': ('collapse',),
},
),
)
@admin.register(SourceRequestStatusHistory)
class SourceRequestStatusHistoryAdmin(BaseAdmin):
"""Админ-панель для модели SourceRequestStatusHistory."""
list_display = (
'id',
'source_request',
'old_status',
'new_status',
'changed_at',
'changed_by',
)
list_display_links = ('id',)
list_select_related = ('source_request', 'changed_by__user')
list_filter = (
'old_status',
'new_status',
('changed_at', DateRangeQuickSelectListFilterBuilder()),
)
search_fields = (
'source_request__id',
)
ordering = ('-changed_at',)
readonly_fields = ('source_request', 'old_status', 'new_status', 'changed_at', 'changed_by')
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False

View File

@@ -1,34 +0,0 @@
# Third-party imports
import matplotlib.pyplot as plt
import numpy as np
from sklearn.cluster import DBSCAN, HDBSCAN, KMeans
# Local imports
from .models import ObjItem
def get_clusters(coords: list[tuple[float, float]]):
coords = np.radians(coords)
lat, lon = coords[:, 0], coords[:, 1]
db = DBSCAN(eps=0.06, min_samples=5, algorithm='ball_tree', metric='haversine')
# db = HDBSCAN()
cluster_labels = db.fit_predict(coords)
plt.figure(figsize=(10, 8))
unique_labels = set(cluster_labels)
colors = plt.cm.tab10(np.linspace(0, 1, len(unique_labels)))
for label, color in zip(unique_labels, colors):
if label == -1:
color = 'k'
label_name = 'Шум'
else:
label_name = f'Кластер {label}'
mask = cluster_labels == label
plt.scatter(lon[mask], lat[mask], c=[color], label=label_name, s=30)
plt.xlabel('Долгота')
plt.ylabel('Широта')
plt.title('Кластеризация геоданных с DBSCAN (метрика Хаверсина)')
plt.legend()
plt.grid(True)
plt.show()

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,169 @@
"""
Management command для генерации тестовых отметок сигналов.
Использование:
python manage.py generate_test_marks --satellite_id=1 --user_id=1 --date_range=10.10.2025-15.10.2025
Параметры:
--satellite_id: ID спутника (обязательный)
--user_id: ID пользователя CustomUser (обязательный)
--date_range: Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (обязательный)
--clear: Удалить существующие отметки перед генерацией
Особенности:
- Генерирует отметки только в будние дни (пн-пт)
- Время отметок: утро с 8:00 до 11:00
- Одна отметка в день для всех сигналов спутника
- Все отметки в один день имеют одинаковый timestamp (пакетное сохранение)
- Все отметки имеют значение True (сигнал присутствует)
"""
import random
from datetime import datetime, timedelta
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from mainapp.models import TechAnalyze, ObjectMark, Satellite, CustomUser
class Command(BaseCommand):
help = 'Генерирует тестовые отметки сигналов для теханализов выбранного спутника'
def add_arguments(self, parser):
parser.add_argument(
'--satellite_id',
type=int,
required=True,
help='ID спутника для генерации отметок'
)
parser.add_argument(
'--user_id',
type=int,
required=True,
help='ID пользователя CustomUser - автор всех отметок'
)
parser.add_argument(
'--date_range',
type=str,
required=True,
help='Диапазон дат в формате ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025)'
)
parser.add_argument(
'--clear',
action='store_true',
help='Удалить существующие отметки перед генерацией'
)
def handle(self, *args, **options):
satellite_id = options['satellite_id']
user_id = options['user_id']
date_range = options['date_range']
clear = options['clear']
# Проверяем существование пользователя
try:
custom_user = CustomUser.objects.select_related('user').get(id=user_id)
except CustomUser.DoesNotExist:
raise CommandError(f'Пользователь CustomUser с ID {user_id} не найден')
# Парсим диапазон дат
try:
start_str, end_str = date_range.split('-')
start_date = datetime.strptime(start_str.strip(), '%d.%m.%Y')
end_date = datetime.strptime(end_str.strip(), '%d.%m.%Y')
# Делаем timezone-aware
start_date = timezone.make_aware(start_date)
end_date = timezone.make_aware(end_date)
if start_date > end_date:
raise CommandError('Начальная дата должна быть раньше конечной')
except ValueError as e:
raise CommandError(
f'Неверный формат даты. Используйте ДД.ММ.ГГГГ-ДД.ММ.ГГГГ (например: 10.10.2025-15.10.2025). Ошибка: {e}'
)
# Проверяем существование спутника
try:
satellite = Satellite.objects.get(id=satellite_id)
except Satellite.DoesNotExist:
raise CommandError(f'Спутник с ID {satellite_id} не найден')
# Получаем теханализы для спутника
tech_analyzes = list(TechAnalyze.objects.filter(satellite=satellite))
ta_count = len(tech_analyzes)
if ta_count == 0:
raise CommandError(f'Нет теханализов для спутника "{satellite.name}"')
self.stdout.write(f'Спутник: {satellite.name}')
self.stdout.write(f'Теханализов: {ta_count}')
self.stdout.write(f'Пользователь: {custom_user}')
self.stdout.write(f'Период: {start_str} - {end_str} (только будние дни)')
self.stdout.write(f'Время: 8:00 - 11:00')
# Удаляем существующие отметки если указан флаг
if clear:
deleted_count = ObjectMark.objects.filter(
tech_analyze__satellite=satellite
).delete()[0]
self.stdout.write(
self.style.WARNING(f'Удалено существующих отметок: {deleted_count}')
)
# Генерируем отметки
total_marks = 0
marks_to_create = []
workdays_count = 0
current_date = start_date
# Включаем конечную дату в диапазон
end_date_inclusive = end_date + timedelta(days=1)
while current_date < end_date_inclusive:
# Проверяем, что это будний день (0=пн, 4=пт)
if current_date.weekday() < 5:
workdays_count += 1
# Генерируем случайное время в диапазоне 8:00-11:00
random_hour = random.randint(8, 10)
random_minute = random.randint(0, 59)
random_second = random.randint(0, 59)
mark_time = current_date.replace(
hour=random_hour,
minute=random_minute,
second=random_second,
microsecond=0
)
# Создаём отметки для всех теханализов с одинаковым timestamp
for ta in tech_analyzes:
marks_to_create.append(ObjectMark(
tech_analyze=ta,
mark=True, # Всегда True
timestamp=mark_time,
created_by=custom_user,
))
total_marks += 1
current_date += timedelta(days=1)
# Bulk create для производительности
self.stdout.write(f'Рабочих дней: {workdays_count}')
self.stdout.write(f'Создание {total_marks} отметок...')
# Создаём партиями по 1000
batch_size = 1000
for i in range(0, len(marks_to_create), batch_size):
batch = marks_to_create[i:i + batch_size]
ObjectMark.objects.bulk_create(batch)
self.stdout.write(f' Создано: {min(i + batch_size, len(marks_to_create))}/{total_marks}')
self.stdout.write(
self.style.SUCCESS(
f'Успешно создано {total_marks} отметок для {ta_count} теханализов за {workdays_count} рабочих дней'
)
)

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.7 on 2025-11-17 12:26
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0007_make_source_required'),
]
operations = [
migrations.CreateModel(
name='ObjectInfo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Информация о типе объекта', max_length=255, unique=True, verbose_name='Тип объекта')),
],
options={
'verbose_name': 'Тип объекта',
'verbose_name_plural': 'Типы объектов',
'ordering': ['name'],
},
),
migrations.AddField(
model_name='source',
name='info',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.2.7 on 2025-11-20 11:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0008_objectinfo_source_info'),
]
operations = [
migrations.CreateModel(
name='ObjectOwnership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Принадлежность объекта (страна, организация и т.д.)', max_length=255, unique=True, verbose_name='Принадлежность')),
],
options={
'verbose_name': 'Принадлежность объекта',
'verbose_name_plural': 'Принадлежности объектов',
'ordering': ['name'],
},
),
migrations.AlterField(
model_name='source',
name='info',
field=models.ForeignKey(blank=True, help_text='Тип объекта', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_info', to='mainapp.objectinfo', verbose_name='Тип объекта'),
),
migrations.AddField(
model_name='source',
name='ownership',
field=models.ForeignKey(blank=True, help_text='Принадлежность объекта (страна, организация и т.д.)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_ownership', to='mainapp.objectownership', verbose_name='Принадлежность объекта'),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2025-11-21 07:35
from django.db import migrations
def set_default_source_type(apps, schema_editor):
"""
Устанавливает тип "Стационарные" для всех Source, у которых не указан тип.
"""
Source = apps.get_model('mainapp', 'Source')
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
# Создаем или получаем тип "Стационарные"
stationary_info, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
# Обновляем все Source без типа
sources_without_type = Source.objects.filter(info__isnull=True)
count = sources_without_type.update(info=stationary_info)
print(f"Обновлено {count} источников с типом 'Стационарные'")
def reverse_set_default_source_type(apps, schema_editor):
"""
Обратная миграция - ничего не делаем, так как это безопасная операция.
"""
pass
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0009_objectownership_alter_source_info_source_ownership'),
]
operations = [
migrations.RunPython(set_default_source_type, reverse_set_default_source_type),
]

View File

@@ -0,0 +1,74 @@
# Generated by Django 5.2.7 on 2025-11-21 07:42
from django.db import migrations
def fix_capitalization(apps, schema_editor):
"""
Исправляет регистр типов объектов: "стационарные" -> "Стационарные", "подвижные" -> "Подвижные"
"""
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
Source = apps.get_model('mainapp', 'Source')
# Создаем правильные типы с большой буквы
stationary_new, _ = ObjectInfo.objects.get_or_create(name="Стационарные")
mobile_new, _ = ObjectInfo.objects.get_or_create(name="Подвижные")
# Находим старые типы с маленькой буквы
try:
stationary_old = ObjectInfo.objects.get(name="стационарные")
# Обновляем все Source, которые используют старый тип
count = Source.objects.filter(info=stationary_old).update(info=stationary_new)
print(f"Обновлено {count} источников: 'стационарные' -> 'Стационарные'")
# Удаляем старый тип
stationary_old.delete()
except ObjectInfo.DoesNotExist:
pass
try:
mobile_old = ObjectInfo.objects.get(name="подвижные")
# Обновляем все Source, которые используют старый тип
count = Source.objects.filter(info=mobile_old).update(info=mobile_new)
print(f"Обновлено {count} источников: 'подвижные' -> 'Подвижные'")
# Удаляем старый тип
mobile_old.delete()
except ObjectInfo.DoesNotExist:
pass
def reverse_fix_capitalization(apps, schema_editor):
"""
Обратная миграция - возвращаем маленькие буквы
"""
ObjectInfo = apps.get_model('mainapp', 'ObjectInfo')
Source = apps.get_model('mainapp', 'Source')
# Создаем типы с маленькой буквы
stationary_old, _ = ObjectInfo.objects.get_or_create(name="стационарные")
mobile_old, _ = ObjectInfo.objects.get_or_create(name="подвижные")
# Находим типы с большой буквы
try:
stationary_new = ObjectInfo.objects.get(name="Стационарные")
Source.objects.filter(info=stationary_new).update(info=stationary_old)
stationary_new.delete()
except ObjectInfo.DoesNotExist:
pass
try:
mobile_new = ObjectInfo.objects.get(name="Подвижные")
Source.objects.filter(info=mobile_new).update(info=mobile_old)
mobile_new.delete()
except ObjectInfo.DoesNotExist:
pass
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0010_set_default_source_type'),
]
operations = [
migrations.RunPython(fix_capitalization, reverse_fix_capitalization),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-11-21 12:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0011_fix_source_type_capitalization'),
]
operations = [
migrations.AddField(
model_name='source',
name='confirm_at',
field=models.DateTimeField(blank=True, help_text='Дата и время добавления последней полученной точки ГЛ', null=True, verbose_name='Дата подтверждения'),
),
migrations.AddField(
model_name='source',
name='last_signal_at',
field=models.DateTimeField(blank=True, help_text='Дата и время последней отметки о наличии сигнала', null=True, verbose_name='Последний сигнал'),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2025-11-24 19:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0012_source_confirm_at_source_last_signal_at'),
]
operations = [
migrations.DeleteModel(
name='Mirror',
),
migrations.RemoveField(
model_name='sigmaparameter',
name='mark',
),
migrations.AddField(
model_name='objitem',
name='is_automatic',
field=models.BooleanField(db_index=True, default=False, help_text='Если True, точка не добавляется к объектам (Source), а хранится отдельно', verbose_name='Автоматическая'),
),
migrations.DeleteModel(
name='SigmaParMark',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-25 12:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0013_add_is_automatic_to_objitem'),
]
operations = [
migrations.AddField(
model_name='source',
name='note',
field=models.TextField(blank=True, help_text='Дополнительное описание объекта', null=True, verbose_name='Примечание'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-11-26 20:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0014_source_note'),
]
operations = [
migrations.AddField(
model_name='satellite',
name='international_code',
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=20, null=True, verbose_name='Международный код'),
),
]

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.2.7 on 2025-11-27 07:10
import django.db.models.deletion
import mainapp.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0015_add_international_code_to_satellite'),
]
operations = [
migrations.AlterField(
model_name='satellite',
name='international_code',
field=models.CharField(blank=True, help_text='Международный идентификатор спутника (например, 2011-074A)', max_length=50, null=True, verbose_name='Международный код'),
),
migrations.CreateModel(
name='TechAnalyze',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(db_index=True, help_text='Уникальное название для технического анализа', max_length=255, unique=True, verbose_name='Имя')),
('frequency', models.FloatField(blank=True, db_index=True, default=0, help_text='Центральная частота сигнала', null=True, verbose_name='Частота, МГц')),
('freq_range', models.FloatField(blank=True, default=0, help_text='Полоса частот сигнала', null=True, verbose_name='Полоса частот, МГц')),
('bod_velocity', models.FloatField(blank=True, default=0, help_text='Символьная скорость', null=True, verbose_name='Символьная скорость, БОД')),
('note', models.TextField(blank=True, help_text='Дополнительные примечания', null=True, verbose_name='Примечание')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего изменения', verbose_name='Дата последнего изменения')),
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
('modulation', models.ForeignKey(blank=True, default=mainapp.models.get_default_modulation, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_modulations', to='mainapp.modulation', verbose_name='Модуляция')),
('polarization', models.ForeignKey(blank=True, default=mainapp.models.get_default_polarization, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_polarizations', to='mainapp.polarization', verbose_name='Поляризация')),
('satellite', models.ForeignKey(help_text='Спутник, к которому относится анализ', on_delete=django.db.models.deletion.PROTECT, related_name='tech_analyzes', to='mainapp.satellite', verbose_name='Спутник')),
('standard', models.ForeignKey(blank=True, default=mainapp.models.get_default_standard, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='tech_analyze_standards', to='mainapp.standard', verbose_name='Стандарт')),
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tech_analyze_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
],
options={
'verbose_name': 'Тех. анализ',
'verbose_name_plural': 'Тех. анализы',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.7 on 2025-12-01 08:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0016_alter_satellite_international_code_techanalyze'),
]
operations = [
migrations.AddField(
model_name='satellite',
name='alternative_name',
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника (например, из скобок)', max_length=100, null=True, verbose_name='Альтернативное имя'),
),
migrations.AlterField(
model_name='standard',
name='name',
field=models.CharField(db_index=True, help_text='Стандарт передачи данных (DVB-S, DVB-S2, DVB-S2X и т.д.)', max_length=80, unique=True, verbose_name='Стандарт'),
),
]

View File

@@ -0,0 +1,87 @@
# Generated by Django 5.2.7 on 2025-12-08 08:45
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0017_add_satellite_alternative_name'),
]
operations = [
migrations.AlterField(
model_name='objectownership',
name='name',
field=models.CharField(help_text='Принадлежность объекта', max_length=255, unique=True, verbose_name='Принадлежность'),
),
migrations.AlterField(
model_name='satellite',
name='alternative_name',
field=models.CharField(blank=True, db_index=True, help_text='Альтернативное название спутника', max_length=100, null=True, verbose_name='Альтернативное имя'),
),
migrations.CreateModel(
name='SourceRequest',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус')),
('priority', models.CharField(choices=[('low', 'Низкий'), ('medium', 'Средний'), ('high', 'Высокий')], db_index=True, default='medium', help_text='Приоритет заявки', max_length=10, verbose_name='Приоритет')),
('planned_at', models.DateTimeField(blank=True, help_text='Запланированная дата и время', null=True, verbose_name='Дата и время планирования')),
('request_date', models.DateField(blank=True, help_text='Дата подачи заявки', null=True, verbose_name='Дата заявки')),
('status_updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления статуса', verbose_name='Дата обновления статуса')),
('gso_success', models.BooleanField(blank=True, help_text='Успешность ГСО', null=True, verbose_name='ГСО успешно?')),
('kubsat_success', models.BooleanField(blank=True, help_text='Успешность Кубсат', null=True, verbose_name='Кубсат успешно?')),
('comment', models.TextField(blank=True, help_text='Дополнительные комментарии к заявке', null=True, verbose_name='Комментарий')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='Дата и время создания записи', verbose_name='Дата создания')),
('created_by', models.ForeignKey(blank=True, help_text='Пользователь, создавший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_created', to='mainapp.customuser', verbose_name='Создан пользователем')),
('source', models.ForeignKey(help_text='Связанный источник', on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник')),
('updated_by', models.ForeignKey(blank=True, help_text='Пользователь, последним изменивший запись', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_requests_updated', to='mainapp.customuser', verbose_name='Изменен пользователем')),
],
options={
'verbose_name': 'Заявка на источник',
'verbose_name_plural': 'Заявки на источники',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='SourceRequestStatusHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('old_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус')),
('new_status', models.CharField(choices=[('planned', 'Запланировано'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус')),
('changed_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время изменения статуса', verbose_name='Дата изменения')),
('changed_by', models.ForeignKey(blank=True, help_text='Пользователь, изменивший статус', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='status_changes', to='mainapp.customuser', verbose_name='Изменен пользователем')),
('source_request', models.ForeignKey(help_text='Связанная заявка', on_delete=django.db.models.deletion.CASCADE, related_name='status_history', to='mainapp.sourcerequest', verbose_name='Заявка')),
],
options={
'verbose_name': 'История статуса заявки',
'verbose_name_plural': 'История статусов заявок',
'ordering': ['-changed_at'],
},
),
migrations.AddIndex(
model_name='sourcerequest',
index=models.Index(fields=['-created_at'], name='mainapp_sou_created_61d8ae_idx'),
),
migrations.AddIndex(
model_name='sourcerequest',
index=models.Index(fields=['status'], name='mainapp_sou_status_31dc99_idx'),
),
migrations.AddIndex(
model_name='sourcerequest',
index=models.Index(fields=['priority'], name='mainapp_sou_priorit_5b5044_idx'),
),
migrations.AddIndex(
model_name='sourcerequest',
index=models.Index(fields=['source', '-created_at'], name='mainapp_sou_source__6bb459_idx'),
),
migrations.AddIndex(
model_name='sourcerequeststatushistory',
index=models.Index(fields=['-changed_at'], name='mainapp_sou_changed_9b876e_idx'),
),
migrations.AddIndex(
model_name='sourcerequeststatushistory',
index=models.Index(fields=['source_request', '-changed_at'], name='mainapp_sou_source__957c28_idx'),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.7 on 2025-12-08 09:24
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0018_add_source_request_models'),
]
operations = [
migrations.AddField(
model_name='sourcerequest',
name='coords',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Усреднённые координаты по выбранным точкам (WGS84)', null=True, srid=4326, verbose_name='Координаты'),
),
migrations.AddField(
model_name='sourcerequest',
name='points_count',
field=models.PositiveIntegerField(default=0, help_text='Количество точек ГЛ, использованных для расчёта координат', verbose_name='Количество точек'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-12-08 12:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0019_add_coords_to_source_request'),
]
operations = [
migrations.AddField(
model_name='satellite',
name='location_place',
field=models.CharField(choices=[('kr', 'КР'), ('dv', 'ДВ')], default='kr', help_text='К какому комплексу принадлежит спутник', max_length=30, null=True, verbose_name='Комплекс'),
),
]

View File

@@ -0,0 +1,60 @@
# Generated by Django 5.2.7 on 2025-12-09 12:39
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0020_satellite_location_place'),
]
operations = [
migrations.AddField(
model_name='sourcerequest',
name='card_date',
field=models.DateField(blank=True, help_text='Дата формирования карточки', null=True, verbose_name='Дата формирования карточки'),
),
migrations.AddField(
model_name='sourcerequest',
name='coords_source',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты источника (WGS84)', null=True, srid=4326, verbose_name='Координаты источника'),
),
migrations.AddField(
model_name='sourcerequest',
name='downlink',
field=models.FloatField(blank=True, help_text='Частота downlink в МГц', null=True, verbose_name='Частота Downlink, МГц'),
),
migrations.AddField(
model_name='sourcerequest',
name='region',
field=models.CharField(blank=True, help_text='Район/местоположение', max_length=255, null=True, verbose_name='Район'),
),
migrations.AddField(
model_name='sourcerequest',
name='satellite',
field=models.ForeignKey(blank=True, help_text='Связанный спутник', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='satellite_requests', to='mainapp.satellite', verbose_name='Спутник'),
),
migrations.AddField(
model_name='sourcerequest',
name='transfer',
field=models.FloatField(blank=True, help_text='Перенос по частоте в МГц', null=True, verbose_name='Перенос, МГц'),
),
migrations.AddField(
model_name='sourcerequest',
name='uplink',
field=models.FloatField(blank=True, help_text='Частота uplink в МГц', null=True, verbose_name='Частота Uplink, МГц'),
),
migrations.AlterField(
model_name='sourcerequest',
name='coords',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты ГСО (WGS84)', null=True, srid=4326, verbose_name='Координаты ГСО'),
),
migrations.AlterField(
model_name='sourcerequest',
name='source',
field=models.ForeignKey(blank=True, help_text='Связанный источник', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='source_requests', to='mainapp.source', verbose_name='Источник'),
),
]

View File

@@ -0,0 +1,73 @@
"""
Миграция для изменения модели ObjectMark:
- Удаление всех существующих отметок
- Удаление поля source
- Добавление поля tech_analyze
"""
from django.db import migrations, models
import django.db.models.deletion
def delete_all_marks(apps, schema_editor):
"""Удаляем все существующие отметки перед изменением структуры."""
ObjectMark = apps.get_model('mainapp', 'ObjectMark')
count = ObjectMark.objects.count()
ObjectMark.objects.all().delete()
print(f"Удалено {count} отметок ObjectMark")
def noop(apps, schema_editor):
"""Обратная операция - ничего не делаем."""
pass
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0021_add_source_request_fields'),
]
operations = [
# Сначала удаляем все отметки
migrations.RunPython(delete_all_marks, noop),
# Удаляем старое поле source
migrations.RemoveField(
model_name='objectmark',
name='source',
),
# Добавляем новое поле tech_analyze
migrations.AddField(
model_name='objectmark',
name='tech_analyze',
field=models.ForeignKey(
help_text='Связанный технический анализ',
on_delete=django.db.models.deletion.CASCADE,
related_name='marks',
to='mainapp.techanalyze',
verbose_name='Тех. анализ',
),
preserve_default=False,
),
# Обновляем метаданные модели
migrations.AlterModelOptions(
name='objectmark',
options={
'ordering': ['-timestamp'],
'verbose_name': 'Отметка сигнала',
'verbose_name_plural': 'Отметки сигналов'
},
),
# Добавляем индекс для оптимизации запросов
migrations.AddIndex(
model_name='objectmark',
index=models.Index(
fields=['tech_analyze', '-timestamp'],
name='mainapp_obj_tech_an_idx'
),
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.7 on 2025-12-11 12:08
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0022_change_objectmark_to_techanalyze'),
]
operations = [
migrations.RenameIndex(
model_name='objectmark',
new_name='mainapp_obj_tech_an_b0c804_idx',
old_name='mainapp_obj_tech_an_idx',
),
migrations.AddField(
model_name='sourcerequest',
name='coords_object',
field=django.contrib.gis.db.models.fields.PointField(blank=True, help_text='Координаты объекта (WGS84)', null=True, srid=4326, verbose_name='Координаты объекта'),
),
migrations.AlterField(
model_name='objectmark',
name='mark',
field=models.BooleanField(blank=True, help_text='True - сигнал обнаружен, False - сигнал отсутствует', null=True, verbose_name='Наличие сигнала'),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.7 on 2025-12-12 12:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mainapp', '0023_add_coords_object_to_sourcerequest'),
]
operations = [
migrations.AlterField(
model_name='objectmark',
name='timestamp',
field=models.DateTimeField(blank=True, db_index=True, help_text='Время фиксации отметки', null=True, verbose_name='Время'),
),
migrations.AlterField(
model_name='sourcerequest',
name='status',
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], db_index=True, default='planned', help_text='Текущий статус заявки', max_length=20, verbose_name='Статус'),
),
migrations.AlterField(
model_name='sourcerequeststatushistory',
name='new_status',
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус после изменения', max_length=20, verbose_name='Новый статус'),
),
migrations.AlterField(
model_name='sourcerequeststatushistory',
name='old_status',
field=models.CharField(choices=[('planned', 'Запланировано'), ('canceled_gso', 'Отменено ГСО'), ('canceled_kub', 'Отменено МКА'), ('conducted', 'Проведён'), ('successful', 'Успешно'), ('no_correlation', 'Нет корреляции'), ('no_signal', 'Нет сигнала в спектре'), ('unsuccessful', 'Неуспешно'), ('downloading', 'Скачивание'), ('processing', 'Обработка'), ('result_received', 'Результат получен')], help_text='Статус до изменения', max_length=20, verbose_name='Старый статус'),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,17 @@
# Django imports # Django imports
from django.contrib.auth.models import User # from django.contrib.auth.models import User
from django.db.models.signals import post_save # from django.db.models.signals import post_save
from django.dispatch import receiver # from django.dispatch import receiver
# Local imports # # Local imports
from .models import CustomUser # from .models import CustomUser
@receiver(post_save, sender=User) # @receiver(post_save, sender=User)
def create_or_update_user_profile(sender, instance, created, **kwargs): # def create_or_update_user_profile(sender, instance, created, **kwargs):
if created: # if created:
CustomUser.objects.create(user=instance) # CustomUser.objects.get_or_create(user=instance)
instance.customuser.save() # else:
# # Only save if customuser exists (avoid error if it doesn't)
# if hasattr(instance, 'customuser'):
# instance.customuser.save()

View File

@@ -6,7 +6,7 @@
.multiselect-input-container { .multiselect-input-container {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: flex-start;
min-height: 38px; min-height: 38px;
border: 1px solid #ced4da; border: 1px solid #ced4da;
border-radius: 0.25rem; border-radius: 0.25rem;
@@ -27,7 +27,8 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
flex: 0 0 auto; flex: 1 1 auto;
max-width: calc(100% - 150px);
} }
.multiselect-tag { .multiselect-tag {

View File

@@ -0,0 +1,148 @@
# Sorting Functionality Documentation
## Overview
This document describes the centralized sorting functionality implemented for table columns across the Django application.
## Files Created/Modified
### Created Files:
1. **`dbapp/mainapp/static/js/sorting.js`** - Main sorting JavaScript library
2. **`dbapp/mainapp/static/js/sorting-test.html`** - Test page for manual verification
### Modified Files:
1. **`dbapp/mainapp/templates/mainapp/base.html`** - Added sorting.js script include
2. **`dbapp/mainapp/templates/mainapp/components/_sort_header.html`** - Removed inline script, added data attributes
## Features
### 1. Sort Toggle Logic
- **First click**: Sort ascending (field)
- **Second click**: Sort descending (-field)
- **Third click**: Sort ascending again (cycles back)
### 2. URL Parameter Management
- Preserves all existing GET parameters (search, filters, etc.)
- Automatically resets page number to 1 when sorting changes
- Updates the `sort` parameter in the URL
### 3. Visual Indicators
- Shows up arrow (↑) for ascending sort
- Shows down arrow (↓) for descending sort
- Automatically initializes indicators on page load
- Adds `sort-active` class to currently sorted column
## Usage
### In Templates
Use the `_sort_header.html` component in your table headers:
```django
<thead class="table-dark sticky-top">
<tr>
<th>{% include 'mainapp/components/_sort_header.html' with field='id' label='ID' current_sort=sort %}</th>
<th>{% include 'mainapp/components/_sort_header.html' with field='name' label='Название' current_sort=sort %}</th>
<th>{% include 'mainapp/components/_sort_header.html' with field='created_at' label='Дата создания' current_sort=sort %}</th>
</tr>
</thead>
```
### In Views
Pass the current sort parameter to the template context:
```python
def get(self, request):
sort = request.GET.get('sort', '-id') # Default sort
# Validate allowed sorts
allowed_sorts = ['id', '-id', 'name', '-name', 'created_at', '-created_at']
if sort not in allowed_sorts:
sort = '-id'
# Apply sorting
queryset = Model.objects.all().order_by(sort)
context = {
'sort': sort,
'objects': queryset,
# ... other context
}
return render(request, 'template.html', context)
```
## JavaScript API
### Functions
#### `updateSort(field)`
Updates the sort parameter and reloads the page.
**Parameters:**
- `field` (string): The field name to sort by
**Example:**
```javascript
updateSort('created_at'); // Sort by created_at ascending
```
#### `getCurrentSort()`
Gets the current sort field and direction from URL.
**Returns:**
- Object with `field` and `direction` properties
- `direction` can be 'asc', 'desc', or null
**Example:**
```javascript
const sort = getCurrentSort();
console.log(sort.field); // 'created_at'
console.log(sort.direction); // 'asc' or 'desc'
```
#### `initializeSortIndicators()`
Automatically called on page load to show current sort state.
## Requirements Satisfied
This implementation satisfies the following requirements from the specification:
- **5.1**: Supports ascending and descending order for sortable columns
- **5.2**: Toggles between ascending, descending when clicking column headers
- **5.3**: Displays visual indicators (arrow icons) showing sort direction
- **5.5**: Preserves sort state in URL parameters during navigation
- **5.6**: Preserves other active filters and resets pagination when sorting
## Testing
### Manual Testing
1. Open `dbapp/mainapp/static/js/sorting-test.html` in a browser
2. Click column headers to test sorting
3. Verify URL updates correctly
4. Add query parameters (e.g., ?page=5&search=test) and verify they're preserved
### Integration Testing
Test in actual Django views:
1. Navigate to any list view (sources, objitems, transponders)
2. Click column headers to sort
3. Verify data is sorted correctly
4. Apply filters and verify they're preserved when sorting
5. Navigate to page 2+, then sort - verify it resets to page 1
## Browser Compatibility
- Modern browsers supporting ES6 (URLSearchParams)
- Chrome 49+
- Firefox 44+
- Safari 10.1+
- Edge 17+
## Notes
- The sorting.js file is loaded with `defer` attribute for better performance
- All GET parameters are preserved except `page` which is reset to 1
- The function is globally available and can be called from any template
- Sort indicators are automatically initialized on page load

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sorting Test</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>Sorting Functionality Test</h1>
<div class="alert alert-info">
<strong>Current URL:</strong> <span id="currentUrl"></span>
</div>
<table class="table table-striped">
<thead class="table-dark">
<tr>
<th>
<a href="javascript:void(0)"
onclick="updateSort('id')"
class="text-white text-decoration-none"
data-sort-field="id">
ID
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
</a>
</th>
<th>
<a href="javascript:void(0)"
onclick="updateSort('name')"
class="text-white text-decoration-none"
data-sort-field="name">
Name
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
</a>
</th>
<th>
<a href="javascript:void(0)"
onclick="updateSort('created_at')"
class="text-white text-decoration-none"
data-sort-field="created_at">
Created At
<i class="bi bi-arrow-up sort-icon" style="display: none;"></i>
</a>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Test Item 1</td>
<td>2024-01-01</td>
</tr>
<tr>
<td>2</td>
<td>Test Item 2</td>
<td>2024-01-02</td>
</tr>
</tbody>
</table>
<div class="card">
<div class="card-body">
<h5>Test Instructions:</h5>
<ol>
<li>Click on any column header (ID, Name, or Created At)</li>
<li>The URL should update with ?sort=field_name</li>
<li>Click again to toggle to descending (?sort=-field_name)</li>
<li>Click a third time to toggle back to ascending</li>
<li>Add ?page=5 to the URL and click a header - page should reset to 1</li>
<li>Add ?search=test to the URL and click a header - search should be preserved</li>
</ol>
</div>
</div>
</div>
<script src="sorting.js"></script>
<script>
// Display current URL
function updateUrlDisplay() {
document.getElementById('currentUrl').textContent = window.location.href;
}
updateUrlDisplay();
// Update URL display on page load
window.addEventListener('load', updateUrlDisplay);
</script>
</body>
</html>

View File

@@ -0,0 +1,106 @@
/**
* Sorting functionality for table columns
* Handles toggling between ascending, descending, and no sort
* Preserves other GET parameters and resets pagination
*/
/**
* Updates the sort parameter in the URL and reloads the page
* @param {string} field - The field name to sort by
*/
function updateSort(field) {
// Get current URL parameters
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
let newSort;
// Toggle sort direction logic:
// 1. If not sorted by this field -> sort ascending (field)
// 2. If sorted ascending -> sort descending (-field)
// 3. If sorted descending -> sort ascending (field)
if (currentSort === field) {
// Currently ascending, switch to descending
newSort = '-' + field;
} else if (currentSort === '-' + field) {
// Currently descending, switch to ascending
newSort = field;
} else {
// Not sorted by this field, start with ascending
newSort = field;
}
// Update sort parameter
urlParams.set('sort', newSort);
// Reset to first page when sorting changes
urlParams.delete('page');
// Reload page with new parameters
window.location.search = urlParams.toString();
}
/**
* Gets the current sort field and direction
* @returns {Object} Object with field and direction properties
*/
function getCurrentSort() {
const urlParams = new URLSearchParams(window.location.search);
const sort = urlParams.get('sort');
if (!sort) {
return { field: null, direction: null };
}
if (sort.startsWith('-')) {
return {
field: sort.substring(1),
direction: 'desc'
};
}
return {
field: sort,
direction: 'asc'
};
}
/**
* Initializes sort indicators on page load
* Adds visual indicators to show current sort state
*/
function initializeSortIndicators() {
const currentSort = getCurrentSort();
if (!currentSort.field) {
return;
}
// Find all sort headers and update their indicators
const sortHeaders = document.querySelectorAll('[data-sort-field]');
sortHeaders.forEach(header => {
const field = header.getAttribute('data-sort-field');
if (field === currentSort.field) {
// Add active class or update icon
header.classList.add('sort-active');
// Update icon if present
const icon = header.querySelector('.sort-icon');
if (icon) {
if (currentSort.direction === 'asc') {
icon.className = 'bi bi-arrow-up sort-icon';
} else {
icon.className = 'bi bi-arrow-down sort-icon';
}
}
}
});
}
// Initialize sort indicators when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeSortIndicators);
} else {
initializeSortIndicators();
}

View File

@@ -9,9 +9,6 @@
<p class="lead">Управление данными спутников</p> <p class="lead">Управление данными спутников</p>
</div> </div>
<!-- Alert messages -->
{% include 'mainapp/components/_messages.html' %}
<!-- Main feature cards --> <!-- Main feature cards -->
<div class="row g-4"> <div class="row g-4">
<!-- Excel Data Upload Card --> <!-- Excel Data Upload Card -->
@@ -82,26 +79,7 @@
</div> </div>
</div> </div>
<!-- Transponders Card -->
<div class="col-lg-6">
<div class="card h-100 shadow-sm border-0">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<div class="bg-warning bg-opacity-10 rounded-circle p-2 me-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-wifi text-warning" viewBox="0 0 16 16">
<path d="M6.002 3.5a5.5 5.5 0 1 1 3.996 9.5H10A5.5 5.5 0 0 1 6.002 3.5M6.002 5.5a3.5 3.5 0 1 0 3.996 5.5H10A3.5 3.5 0 0 0 6.002 5.5"/>
<path d="M10.5 12.5a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5.5.5 0 0 0-1 0 .5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5.5.5 0 0 0-1 0 .5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5 3.5 3.5 0 0 1 7 0"/>
</svg>
</div>
<h3 class="card-title mb-0">Добавление транспондеров</h3>
</div>
<p class="card-text">Добавьте список транспондеров в базу данных.</p>
<a href="{% url 'mainapp:add_trans' %}" class="btn btn-warning">
Добавить транспондеры
</a>
</div>
</div>
</div>
<!-- VCH Load Data Card --> <!-- VCH Load Data Card -->
<div class="col-lg-6"> <div class="col-lg-6">

View File

@@ -11,8 +11,6 @@
<h2 class="mb-0">Загрузка данных из CSV</h2> <h2 class="mb-0">Загрузка данных из CSV</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
{% include 'mainapp/components/_messages.html' %}
<p class="card-text">Загрузите CSV-файл для загрузки данных в базу.</p> <p class="card-text">Загрузите CSV-файл для загрузки данных в базу.</p>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
@@ -21,8 +19,21 @@
<!-- Form fields with Bootstrap styling --> <!-- Form fields with Bootstrap styling -->
{% include 'mainapp/components/_form_field.html' with field=form.file %} {% 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"> <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> <button type="submit" class="btn btn-success">Добавить в базу</button>
</div> </div>
</form> </form>

View File

@@ -11,8 +11,6 @@
<h2 class="mb-0">Загрузка данных из Excel</h2> <h2 class="mb-0">Загрузка данных из Excel</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
{% include 'mainapp/components/_messages.html' %}
<p class="card-text">Загрузите Excel-файл и выберите спутник для загрузки данных в базу.</p> <p class="card-text">Загрузите Excel-файл и выберите спутник для загрузки данных в базу.</p>
<form method="post" enctype="multipart/form-data"> <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.sat_choice %}
{% include 'mainapp/components/_form_field.html' with field=form.number_input %} {% 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"> <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> <button type="submit" class="btn btn-primary">Добавить в базу</button>
</div> </div>
</form> </form>

View File

@@ -35,6 +35,9 @@
<!-- Bootstrap JS --> <!-- Bootstrap JS -->
<script src="{% static 'bootstrap/bootstrap.bundle.min.js' %}" defer></script> <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 %} {% block extra_js %}{% endblock %}
</body> </body>

View File

@@ -40,7 +40,6 @@
{% 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=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=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=20 column_label="Тип источника" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Sigma" checked=True %} {% include 'mainapp/components/_column_toggle_item.html' with column_index=21 column_label="Зеркала" checked=True %}
{% include 'mainapp/components/_column_toggle_item.html' with column_index=22 column_label="Зеркала" checked=True %}
</ul> </ul>
</div> </div>

View File

@@ -0,0 +1,99 @@
{% comment %}
Переиспользуемый компонент панели фильтров (Offcanvas)
Параметры:
- filters: список HTML-кода фильтров для отображения (опционально)
- filter_form: объект формы Django для фильтров (опционально)
- reset_url: URL для сброса фильтров (по умолчанию: текущая страница без параметров)
Использование:
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list %}
{% include 'mainapp/components/_filter_panel.html' with filter_form=form %}
{% include 'mainapp/components/_filter_panel.html' with filters=filter_list reset_url='/sources/' %}
Примечание:
- Можно передать либо список HTML-кода фильтров через 'filters', либо форму Django через 'filter_form'
- Форма отправляется методом GET для сохранения параметров в URL
- Кнопка "Сбросить" очищает все параметры фильтрации
{% endcomment %}
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body">
<form method="get" id="filter-form">
{% if filter_form %}
{# Если передана форма Django, отображаем её поля #}
{% for field in filter_form %}
<div class="mb-3">
<label for="{{ field.id_for_label }}" class="form-label">
{{ field.label }}
</label>
{{ field }}
{% if field.help_text %}
<div class="form-text">{{ field.help_text }}</div>
{% endif %}
{% if field.errors %}
<div class="invalid-feedback d-block">
{{ field.errors }}
</div>
{% endif %}
</div>
{% endfor %}
{% elif filters %}
{# Если переданы готовые HTML-блоки фильтров #}
{% for filter in filters %}
{{ filter|safe }}
{% endfor %}
{% endif %}
{# Сохраняем параметры сортировки и поиска при применении фильтров #}
{% if request.GET.sort %}
<input type="hidden" name="sort" value="{{ request.GET.sort }}">
{% endif %}
{% if request.GET.search %}
<input type="hidden" name="search" value="{{ request.GET.search }}">
{% endif %}
{% if request.GET.items_per_page %}
<input type="hidden" name="items_per_page" value="{{ request.GET.items_per_page }}">
{% endif %}
<div class="d-grid gap-2 mt-3">
<button type="submit" class="btn btn-primary btn-sm">
Применить
</button>
<a href="{{ reset_url|default:'?' }}" class="btn btn-secondary btn-sm">
Сбросить
</a>
</div>
</form>
</div>
</div>
<script>
// Update filter counter badge when filters are active
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const filterCounter = document.getElementById('filterCounter');
if (filterCounter) {
// Count active filters (excluding pagination, sort, search, and items_per_page)
const excludedParams = ['page', 'sort', 'search', 'items_per_page'];
let activeFilters = 0;
for (const [key, value] of urlParams.entries()) {
if (!excludedParams.includes(key) && value) {
activeFilters++;
}
}
if (activeFilters > 0) {
filterCounter.textContent = activeFilters;
filterCounter.style.display = 'inline-block';
} else {
filterCounter.style.display = 'none';
}
}
});
</script>

View File

@@ -0,0 +1,71 @@
<!-- Frequency Plan Modal -->
<div class="modal fade" id="frequencyPlanModal" tabindex="-1" aria-labelledby="frequencyPlanModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="frequencyPlanModalLabel">Частотный план</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div id="modalLoadingSpinner" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
</div>
<div id="modalFrequencyContent" style="display: none;">
<p class="text-muted">Визуализация транспондеров спутника по частотам. <span style="color: #0d6efd;"></span> Downlink (синий), <span style="color: #fd7e14;"></span> Uplink (оранжевый). Используйте колесико мыши для масштабирования, наведите курсор на полосу для подробной информации и связи с парным каналом.</p>
<div class="frequency-plan">
<div class="chart-controls">
<button type="button" class="btn btn-sm btn-outline-primary" id="modalResetZoom">
<i class="bi bi-arrow-clockwise"></i> Сбросить масштаб
</button>
</div>
<div class="frequency-chart-container">
<canvas id="modalFrequencyChart"></canvas>
</div>
<div class="mt-3">
<p><strong>Всего транспондеров:</strong> <span id="modalTransponderCount">0</span></p>
</div>
</div>
</div>
<div id="modalNoData" style="display: none;" class="text-center text-muted py-5">
<p>Нет данных о транспондерах для этого спутника</p>
</div>
</div>
</div>
</div>
</div>
<style>
.frequency-plan {
padding: 20px;
background-color: #f8f9fa;
border-radius: 8px;
}
.frequency-chart-container {
position: relative;
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 20px;
height: 400px;
}
.chart-controls {
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.chart-controls button {
padding: 5px 15px;
font-size: 0.9rem;
}
</style>

View File

@@ -0,0 +1,833 @@
{% load l10n %}
<!-- Вкладка фильтров и экспорта -->
<form method="get" id="filterForm" class="mb-4">
<input type="hidden" name="tab" value="filters">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Фильтры</h5>
</div>
<div class="card-body">
<div class="row">
<!-- Спутники -->
<div class="col-md-3 mb-3">
<label for="{{ form.satellites.id_for_label }}" class="form-label">{{ form.satellites.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellites', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellites', false)">Снять</button>
</div>
{{ form.satellites }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
<!-- Полоса спутника -->
<div class="col-md-3 mb-3">
<label for="{{ form.band.id_for_label }}" class="form-label">{{ form.band.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('band', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('band', false)">Снять</button>
</div>
{{ form.band }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
<!-- Поляризация -->
<div class="col-md-3 mb-3">
<label for="{{ form.polarization.id_for_label }}" class="form-label">{{ form.polarization.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', false)">Снять</button>
</div>
{{ form.polarization }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
<!-- Модуляция -->
<div class="col-md-3 mb-3">
<label for="{{ form.modulation.id_for_label }}" class="form-label">{{ form.modulation.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('modulation', false)">Снять</button>
</div>
{{ form.modulation }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
</div>
<div class="row">
<!-- Центральная частота -->
<div class="col-md-3 mb-3">
<label class="form-label">Центральная частота (МГц)</label>
<div class="input-group">
{{ form.frequency_min }}
<span class="input-group-text"></span>
{{ form.frequency_max }}
</div>
</div>
<!-- Полоса -->
<div class="col-md-3 mb-3">
<label class="form-label">Полоса (МГц)</label>
<div class="input-group">
{{ form.freq_range_min }}
<span class="input-group-text"></span>
{{ form.freq_range_max }}
</div>
</div>
<!-- Тип объекта -->
<div class="col-md-3 mb-3">
<label for="{{ form.object_type.id_for_label }}" class="form-label">{{ form.object_type.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('object_type', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('object_type', false)">Снять</button>
</div>
{{ form.object_type }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
<!-- Принадлежность объекта -->
<div class="col-md-3 mb-3">
<label for="{{ form.object_ownership.id_for_label }}" class="form-label">{{ form.object_ownership.label }}</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('object_ownership', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('object_ownership', false)">Снять</button>
</div>
{{ form.object_ownership }}
<small class="form-text text-muted">Удерживайте Ctrl для выбора нескольких</small>
</div>
</div>
<div class="row">
<!-- Количество ObjItem -->
<div class="col-md-3 mb-3">
<label class="form-label">Количество привязанных точек ГЛ</label>
<div class="input-group mb-2">
{{ form.objitem_count_min }}
</div>
<div class="input-group">
{{ form.objitem_count_max }}
</div>
</div>
<!-- Планы на Кубсат -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.has_plans.label }}</label>
<div>
{% for radio in form.has_plans %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- ГСО успешно -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.success_1.label }}</label>
<div>
{% for radio in form.success_1 %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
<!-- Кубсат успешно -->
<div class="col-md-3 mb-3">
<label class="form-label">{{ form.success_2.label }}</label>
<div>
{% for radio in form.success_2 %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">
{{ radio.choice_label }}
</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="row">
<!-- Диапазон дат -->
<div class="col-md-6 mb-3">
<label class="form-label">Диапазон дат ГЛ:</label>
<div class="input-group">
{{ form.date_from }}
<span class="input-group-text"></span>
{{ form.date_to }}
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<button type="submit" class="btn btn-primary">Применить фильтры</button>
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">Сбросить</a>
</div>
</div>
</div>
</div>
</form>
<!-- Кнопка экспорта и статистика -->
{% if sources_with_date_info %}
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-3">
<!-- Поиск по имени точки -->
<div class="input-group" style="max-width: 350px;">
<input type="text" id="searchObjitemName" class="form-control"
placeholder="Поиск по имени точки..."
oninput="filterTableByName()">
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">
<i class="bi bi-x-lg"></i>
</button>
</div>
<button type="button" class="btn btn-success" onclick="exportToExcel()">
<i class="bi bi-file-earmark-excel"></i> Экспорт в Excel
</button>
<button type="button" class="btn btn-primary" onclick="createRequestsFromTable()">
<i class="bi bi-plus-circle"></i> Создать заявки
</button>
<span class="text-muted" id="statsCounter">
Найдено объектов: {{ sources_with_date_info|length }},
точек: {% for source_data in sources_with_date_info %}{{ source_data.objitems_data|length }}{% if not forloop.last %}+{% endif %}{% endfor %}
</span>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Таблица результатов -->
{% if sources_with_date_info %}
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm table-bordered" style="font-size: 0.85rem;" id="resultsTable">
<thead class="table-dark sticky-top">
<tr>
<th style="min-width: 80px;">ID объекта</th>
<th style="min-width: 120px;">Тип объекта</th>
<th style="min-width: 150px;">Принадлежность объекта</th>
<th class="text-center" style="min-width: 60px;" title="Всего заявок">Заявки</th>
<th class="text-center" style="min-width: 80px;">ГСО</th>
<th class="text-center" style="min-width: 80px;">Кубсат</th>
<th class="text-center" style="min-width: 100px;">Статус заявки</th>
<th class="text-center" style="min-width: 100px;">Кол-во точек</th>
<th style="min-width: 150px;">Усреднённая координата</th>
<th style="min-width: 120px;">Имя точки</th>
<th style="min-width: 150px;">Спутник</th>
<th style="min-width: 100px;">Частота (МГц)</th>
<th style="min-width: 100px;">Полоса (МГц)</th>
<th style="min-width: 100px;">Поляризация</th>
<th style="min-width: 100px;">Модуляция</th>
<th style="min-width: 150px;">Координаты ГЛ</th>
<th style="min-width: 100px;">Дата ГЛ</th>
<th style="min-width: 150px;">Действия</th>
</tr>
</thead>
<tbody>
{% for source_data in sources_with_date_info %}
{% for objitem_data in source_data.objitems_data %}
<tr data-source-id="{{ source_data.source.id }}"
data-objitem-id="{{ objitem_data.objitem.id }}"
data-objitem-name="{{ objitem_data.objitem.name|default:'' }}"
data-matches-date="{{ objitem_data.matches_date|yesno:'true,false' }}"
data-is-first-in-source="{% if forloop.first %}true{% else %}false{% endif %}"
data-lat="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.y }}{% endif %}"
data-lon="{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}{{ objitem_data.objitem.geo_obj.coords.x }}{% endif %}">
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-id-cell">{{ source_data.source.id }}</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-type-cell">{{ source_data.source.info.name|default:"-" }}</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-ownership-cell">
{% if source_data.source.ownership %}
{% if source_data.source.ownership.name == "ТВ" and source_data.has_lyngsat %}
<a href="#" class="text-primary text-decoration-none"
onclick="showLyngsatModal({{ source_data.lyngsat_id }}); return false;">
<i class="bi bi-tv"></i> {{ source_data.source.ownership.name }}
</a>
{% else %}
{{ source_data.source.ownership.name }}
{% endif %}
{% else %}
-
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-requests-count-cell">
{% if source_data.requests_count > 0 %}
<span class="badge bg-info">{{ source_data.requests_count }}</span>
{% else %}
<span class="text-muted">0</span>
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-gso-cell">
{% if source_data.gso_success == True %}
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
{% elif source_data.gso_success == False %}
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-kubsat-cell">
{% if source_data.kubsat_success == True %}
<span class="badge bg-success"><i class="bi bi-check-lg"></i></span>
{% elif source_data.kubsat_success == False %}
<span class="badge bg-danger"><i class="bi bi-x-lg"></i></span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-status-cell">
{% if source_data.request_status %}
{% if source_data.request_status_raw == 'successful' or source_data.request_status_raw == 'result_received' %}
<span class="badge bg-success">{{ source_data.request_status }}</span>
{% elif source_data.request_status_raw == 'unsuccessful' or source_data.request_status_raw == 'no_correlation' or source_data.request_status_raw == 'no_signal' %}
<span class="badge bg-danger">{{ source_data.request_status }}</span>
{% elif source_data.request_status_raw == 'planned' %}
<span class="badge bg-primary">{{ source_data.request_status }}</span>
{% elif source_data.request_status_raw == 'downloading' or source_data.request_status_raw == 'processing' %}
<span class="badge bg-warning text-dark">{{ source_data.request_status }}</span>
{% else %}
<span class="badge bg-secondary">{{ source_data.request_status }}</span>
{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="text-center source-count-cell" data-initial-count="{{ source_data.objitems_data|length }}">{{ source_data.objitems_data|length }}</td>
{% endif %}
{% if forloop.first %}
<td rowspan="{{ source_data.objitems_data|length }}" class="source-avg-coords-cell"
data-avg-lat="{{ source_data.avg_lat|default:''|unlocalize }}"
data-avg-lon="{{ source_data.avg_lon|default:''|unlocalize }}">
{% if source_data.avg_lat and source_data.avg_lon %}
{{ source_data.avg_lat|floatformat:6|unlocalize }}, {{ source_data.avg_lon|floatformat:6|unlocalize }}
{% else %}
-
{% endif %}
</td>
{% endif %}
<td>{{ objitem_data.objitem.name|default:"-" }}</td>
<td>
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.id_satellite %}
{{ objitem_data.objitem.parameter_obj.id_satellite.name }}
{% if objitem_data.objitem.parameter_obj.id_satellite.norad %}
({{ objitem_data.objitem.parameter_obj.id_satellite.norad }})
{% endif %}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.parameter_obj %}
{{ objitem_data.objitem.parameter_obj.frequency|default:"-"|floatformat:3 }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.parameter_obj %}
{{ objitem_data.objitem.parameter_obj.freq_range|default:"-"|floatformat:3 }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.polarization %}
{{ objitem_data.objitem.parameter_obj.polarization.name }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.parameter_obj and objitem_data.objitem.parameter_obj.modulation %}
{{ objitem_data.objitem.parameter_obj.modulation.name }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.objitem.geo_obj and objitem_data.objitem.geo_obj.coords %}
{{ objitem_data.objitem.geo_obj.coords.y|floatformat:6|unlocalize }}, {{ objitem_data.objitem.geo_obj.coords.x|floatformat:6|unlocalize }}
{% else %}
-
{% endif %}
</td>
<td>
{% if objitem_data.geo_date %}
{{ objitem_data.geo_date|date:"d.m.Y" }}
{% else %}
-
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-danger" onclick="removeObjItem(this)" title="Удалить точку">
<i class="bi bi-trash"></i>
</button>
{% if forloop.first %}
<button type="button" class="btn btn-sm btn-warning" onclick="removeSource(this)" title="Удалить весь объект">
<i class="bi bi-trash-fill"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% elif request.GET %}
<div class="alert alert-info">
По заданным критериям ничего не найдено.
</div>
{% endif %}
<script>
// Функция для пересчёта усреднённых координат источника через Python API
// Координаты рассчитываются на сервере с сортировкой по дате ГЛ
function recalculateAverageCoords(sourceId) {
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
if (sourceRows.length === 0) return;
// Собираем ID всех оставшихся точек для этого источника
const objitemIds = sourceRows.map(row => row.dataset.objitemId).filter(id => id);
if (objitemIds.length === 0) {
// Нет точек - очищаем координаты
updateAvgCoordsCell(sourceId, null, null);
return;
}
// Вызываем Python API для пересчёта координат
const formData = new FormData();
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken) {
formData.append('csrfmiddlewaretoken', csrfToken.value);
}
objitemIds.forEach(id => formData.append('objitem_ids', id));
fetch('{% url "mainapp:kubsat_recalculate_coords" %}', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken ? csrfToken.value : ''
},
body: formData
})
.then(response => response.json())
.then(result => {
if (result.success && result.results[sourceId]) {
const coords = result.results[sourceId];
updateAvgCoordsCell(sourceId, coords.avg_lat, coords.avg_lon);
}
})
.catch(error => {
console.error('Error recalculating coords:', error);
});
}
// Обновляет ячейку с усреднёнными координатами
function updateAvgCoordsCell(sourceId, avgLat, avgLon) {
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
if (sourceRows.length === 0) return;
const firstRow = sourceRows[0];
const avgCoordsCell = firstRow.querySelector('.source-avg-coords-cell');
if (avgCoordsCell) {
if (avgLat !== null && avgLon !== null) {
avgCoordsCell.textContent = `${avgLat.toFixed(6)}, ${avgLon.toFixed(6)}`;
avgCoordsCell.dataset.avgLat = avgLat;
avgCoordsCell.dataset.avgLon = avgLon;
} else {
avgCoordsCell.textContent = '-';
avgCoordsCell.dataset.avgLat = '';
avgCoordsCell.dataset.avgLon = '';
}
}
}
function removeObjItem(button) {
const row = button.closest('tr');
const sourceId = row.dataset.sourceId;
const isFirstInSource = row.dataset.isFirstInSource === 'true';
const sourceRows = Array.from(document.querySelectorAll(`tr[data-source-id="${sourceId}"]`));
// All rowspan cells that need to be handled
const rowspanCellClasses = [
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
];
if (sourceRows.length === 1) {
row.remove();
} else if (isFirstInSource) {
const nextRow = sourceRows[1];
const cells = rowspanCellClasses.map(cls => row.querySelector(cls)).filter(c => c);
if (cells.length > 0) {
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
const newRowspan = currentRowspan - 1;
// Clone and update all rowspan cells
const newCells = cells.map(cell => {
const newCell = cell.cloneNode(true);
newCell.setAttribute('rowspan', newRowspan);
if (newCell.classList.contains('source-count-cell')) {
newCell.textContent = newRowspan;
}
return newCell;
});
// Insert cells in reverse order to maintain correct order
newCells.reverse().forEach(cell => {
nextRow.insertBefore(cell, nextRow.firstChild);
});
const actionsCell = nextRow.querySelector('td:last-child');
if (actionsCell) {
const btnGroup = actionsCell.querySelector('.btn-group');
if (btnGroup && btnGroup.children.length === 1) {
const deleteSourceBtn = document.createElement('button');
deleteSourceBtn.type = 'button';
deleteSourceBtn.className = 'btn btn-sm btn-warning';
deleteSourceBtn.onclick = function() { removeSource(this); };
deleteSourceBtn.title = 'Удалить весь объект';
deleteSourceBtn.innerHTML = '<i class="bi bi-trash-fill"></i>';
btnGroup.appendChild(deleteSourceBtn);
}
}
}
nextRow.dataset.isFirstInSource = 'true';
row.remove();
// Пересчитываем усреднённые координаты после удаления точки
recalculateAverageCoords(sourceId);
} else {
const firstRow = sourceRows[0];
const cells = rowspanCellClasses.map(cls => firstRow.querySelector(cls)).filter(c => c);
if (cells.length > 0) {
const currentRowspan = parseInt(cells[0].getAttribute('rowspan'));
const newRowspan = currentRowspan - 1;
cells.forEach(cell => {
cell.setAttribute('rowspan', newRowspan);
if (cell.classList.contains('source-count-cell')) {
cell.textContent = newRowspan;
}
});
}
row.remove();
// Пересчитываем усреднённые координаты после удаления точки
recalculateAverageCoords(sourceId);
}
updateCounter();
}
function removeSource(button) {
const row = button.closest('tr');
const sourceId = row.dataset.sourceId;
const rows = document.querySelectorAll(`tr[data-source-id="${sourceId}"]`);
rows.forEach(r => r.remove());
updateCounter();
}
function updateCounter() {
const rows = document.querySelectorAll('#resultsTable tbody tr');
const counter = document.getElementById('statsCounter');
if (counter) {
// Подсчитываем уникальные источники и точки (только видимые)
const uniqueSources = new Set();
let visibleRowsCount = 0;
rows.forEach(row => {
if (row.style.display !== 'none') {
uniqueSources.add(row.dataset.sourceId);
visibleRowsCount++;
}
});
counter.textContent = `Найдено объектов: ${uniqueSources.size}, точек: ${visibleRowsCount}`;
}
}
function exportToExcel() {
const rows = document.querySelectorAll('#resultsTable tbody tr');
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
if (objitemIds.length === 0) {
alert('Нет данных для экспорта');
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '{% url "mainapp:kubsat_export" %}';
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken) {
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrfmiddlewaretoken';
csrfInput.value = csrfToken.value;
form.appendChild(csrfInput);
}
objitemIds.forEach(id => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'objitem_ids';
input.value = id;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
function selectAllOptions(selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let i = 0; i < selectElement.options.length; i++) {
selectElement.options[i].selected = selectAll;
}
}
}
function createRequestsFromTable() {
const rows = document.querySelectorAll('#resultsTable tbody tr');
const objitemIds = Array.from(rows).map(row => row.dataset.objitemId);
if (objitemIds.length === 0) {
alert('Нет данных для создания заявок');
return;
}
// Подсчитываем уникальные источники
const uniqueSources = new Set();
rows.forEach(row => uniqueSources.add(row.dataset.sourceId));
if (!confirm(`Будет создано ${uniqueSources.size} заявок (по одной на каждый источник) со статусом "Запланировано".\n\nКоординаты будут рассчитаны как среднее по выбранным точкам.\n\nПродолжить?`)) {
return;
}
// Показываем индикатор загрузки
const btn = event.target.closest('button');
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status"></span> Создание...';
const formData = new FormData();
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (csrfToken) {
formData.append('csrfmiddlewaretoken', csrfToken.value);
}
objitemIds.forEach(id => {
formData.append('objitem_ids', id);
});
fetch('{% url "mainapp:kubsat_create_requests" %}', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken ? csrfToken.value : ''
},
body: formData
})
.then(response => response.json())
.then(result => {
btn.disabled = false;
btn.innerHTML = originalText;
if (result.success) {
let message = `Создано заявок: ${result.created_count} из ${result.total_sources}`;
if (result.errors && result.errors.length > 0) {
message += `\n\nОшибки:\n${result.errors.join('\n')}`;
}
alert(message);
// Перезагружаем страницу для обновления данных
location.reload();
} else {
alert('Ошибка: ' + result.error);
}
})
.catch(error => {
btn.disabled = false;
btn.innerHTML = originalText;
console.error('Error creating requests:', error);
alert('Ошибка создания заявок');
});
}
// Фильтрация таблицы по имени точки
function filterTableByName() {
const searchValue = document.getElementById('searchObjitemName').value.toLowerCase().trim();
const rows = document.querySelectorAll('#resultsTable tbody tr');
if (!searchValue) {
// Показываем все строки
rows.forEach(row => {
row.style.display = '';
});
// Восстанавливаем rowspan
recalculateRowspans();
updateCounter();
return;
}
// Группируем строки по source_id
const sourceGroups = {};
rows.forEach(row => {
const sourceId = row.dataset.sourceId;
if (!sourceGroups[sourceId]) {
sourceGroups[sourceId] = [];
}
sourceGroups[sourceId].push(row);
});
// Фильтруем по имени точки используя data-атрибут
Object.keys(sourceGroups).forEach(sourceId => {
const sourceRows = sourceGroups[sourceId];
let hasVisibleRows = false;
sourceRows.forEach(row => {
// Используем data-атрибут для получения имени точки
const name = (row.dataset.objitemName || '').toLowerCase();
if (name.includes(searchValue)) {
row.style.display = '';
hasVisibleRows = true;
} else {
row.style.display = 'none';
}
});
// Если нет видимых строк в группе, скрываем все (включая ячейки с rowspan)
if (!hasVisibleRows) {
sourceRows.forEach(row => {
row.style.display = 'none';
});
}
});
// Пересчитываем rowspan для видимых строк
recalculateRowspans();
updateCounter();
}
// Пересчет rowspan для видимых строк
function recalculateRowspans() {
const rows = document.querySelectorAll('#resultsTable tbody tr');
// Группируем видимые строки по source_id
const sourceGroups = {};
rows.forEach(row => {
if (row.style.display !== 'none') {
const sourceId = row.dataset.sourceId;
if (!sourceGroups[sourceId]) {
sourceGroups[sourceId] = [];
}
sourceGroups[sourceId].push(row);
}
});
// All rowspan cell classes
const rowspanCellClasses = [
'.source-id-cell', '.source-type-cell', '.source-ownership-cell', '.source-requests-count-cell',
'.source-gso-cell', '.source-kubsat-cell', '.source-status-cell', '.source-count-cell', '.source-avg-coords-cell'
];
// Обновляем rowspan для каждой группы
Object.keys(sourceGroups).forEach(sourceId => {
const visibleRows = sourceGroups[sourceId];
const newRowspan = visibleRows.length;
if (visibleRows.length > 0) {
const firstRow = visibleRows[0];
rowspanCellClasses.forEach(cls => {
const cell = firstRow.querySelector(cls);
if (cell) {
cell.setAttribute('rowspan', newRowspan);
// Обновляем отображаемое количество точек
if (cell.classList.contains('source-count-cell')) {
cell.textContent = newRowspan;
}
}
});
}
});
}
// Очистка поиска
function clearSearch() {
document.getElementById('searchObjitemName').value = '';
filterTableByName();
}
document.addEventListener('DOMContentLoaded', function() {
updateCounter();
});
</script>

View File

@@ -2,24 +2,40 @@
Переиспользуемый компонент для отображения сообщений Django Переиспользуемый компонент для отображения сообщений Django
Использование: Использование:
{% include 'mainapp/components/_messages.html' %} {% include 'mainapp/components/_messages.html' %}
Для отключения автоскрытия добавьте extra_tags='persistent':
messages.success(request, "Сообщение", extra_tags='persistent')
{% endcomment %} {% endcomment %}
{% if messages %} {% if messages %}
<div class="messages-container"> <div class="messages-container">
{% for message in messages %} {% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert"> <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 message.tags == 'error' %} {% if 'error' in message.tags %}
<i class="bi bi-exclamation-triangle-fill me-2"></i> <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> <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> <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> <i class="bi bi-info-circle-fill me-2"></i>
{% endif %} {% endif %}
{{ message }} {{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
{% endfor %} {% endfor %}
</div> </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 %} {% endif %}

View File

@@ -6,7 +6,7 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container"> <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"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@@ -14,7 +14,7 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
<!-- <li class="nav-item"> <!-- <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> -->
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:source_list' %}">Объекты</a> <a class="nav-link" href="{% url 'mainapp:source_list' %}">Объекты</a>
@@ -22,23 +22,29 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Точки</a> <a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Точки</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:satellite_list' %}">Спутники</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:transponder_list' %}">Транспондеры</a> <a class="nav-link" href="{% url 'mainapp:transponder_list' %}">Транспондеры</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">LyngSat</a> <a class="nav-link" href="{% url 'lyngsatapp:lyngsat_list' %}">Справочные данные</a>
</li> </li>
<li class="nav-item"> <!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a> <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>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:object_marks' %}">Отметки</a> <a class="nav-link" href="{% url 'mainapp:kubsat' %}">Кубсат</a>
</li> </li>
<li class="nav-item"> <!-- <li class="nav-item">
<a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a> <a class="nav-link" href="{% url 'mapsapp:3dmap' %}">3D карта</a>
</li> </li> -->
<li class="nav-item"> <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>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'admin:index' %}">Админ панель</a> <a class="nav-link" href="{% url 'admin:index' %}">Админ панель</a>

View File

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

View File

@@ -1,5 +1,5 @@
<!-- Selected Items Offcanvas Component --> <!-- 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"> <div class="offcanvas-header">
<h5 class="offcanvas-title" id="selectedItemsOffcanvasLabel">Выбранные элементы</h5> <h5 class="offcanvas-title" id="selectedItemsOffcanvasLabel">Выбранные элементы</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button> <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()"> <button type="button" class="btn btn-danger btn-sm" onclick="removeSelectedItems()">
<i class="bi bi-trash"></i> Убрать из списка <i class="bi bi-trash"></i> Убрать из списка
</button> </button>
<button type="button" class="btn btn-primary btn-sm" onclick="sendSelectedItems()"> <button type="button" class="btn btn-primary btn-sm" onclick="showSelectedItemsOnMap()">
<i class="bi bi-send"></i> Отправить <i class="bi bi-map"></i> Карта
</button> </button>
<button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="offcanvas"> <button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="offcanvas">
Закрыть Закрыть

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

View File

@@ -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, '&quot;')}">${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>

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

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

View File

@@ -17,9 +17,6 @@
</h3> </h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- Alert messages -->
{% include 'mainapp/components/_messages.html' %}
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
<strong>Внимание!</strong> Процесс заполнения данных может занять продолжительное время, <strong>Внимание!</strong> Процесс заполнения данных может занять продолжительное время,
так как выполняются запросы к внешнему сайту Lyngsat. Пожалуйста, дождитесь завершения операции. так как выполняются запросы к внешнему сайту Lyngsat. Пожалуйста, дождитесь завершения операции.

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

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

View File

@@ -13,13 +13,10 @@
</h3> </h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- Alert messages -->
{% include 'mainapp/components/_messages.html' %}
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i> <i class="bi bi-info-circle"></i>
<strong>Информация:</strong> Эта функция автоматически привязывает источники из базы LyngSat к объектам <strong>Информация:</strong> Эта функция автоматически привязывает источники из базы LyngSat к объектам
на основе совпадения частоты (с округлением) и поляризации. Объекты с привязанными источниками LyngSat на основе совпадения частоты и поляризации. Объекты с привязанными источниками LyngSat
будут отмечены как "ТВ" в списке объектов. будут отмечены как "ТВ" в списке объектов.
</div> </div>
@@ -67,23 +64,6 @@
</form> </form>
</div> </div>
</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> </div>
</div> </div>

View File

@@ -11,15 +11,6 @@
<h2 class="mb-0">Привязка ВЧ загрузки</h2> <h2 class="mb-0">Привязка ВЧ загрузки</h2>
</div> </div>
<div class="card-body"> <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"> <div class="alert alert-info" role="alert">
<strong>Информация о привязке:</strong> <strong>Информация о привязке:</strong>
<p class="mb-2">Погрешность центральной частоты определяется автоматически в зависимости от полосы:</p> <p class="mb-2">Погрешность центральной частоты определяется автоматически в зависимости от полосы:</p>
@@ -67,8 +58,8 @@
</div> </div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <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>
{% comment %} <a href="{% url 'mainapp:home' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %} {% comment %} <a href="{% url 'mainapp:source_list' %}" class="btn btn-danger me-md-2">Сбросить привязку</a> {% endcomment %}
<button type="submit" class="btn btn-info">Выполнить привязку</button> <button type="submit" class="btn btn-info">Выполнить привязку</button>
</div> </div>
</form> </form>

File diff suppressed because it is too large Load Diff

View File

@@ -1,383 +0,0 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Наличие сигнала объектов{% endblock %}
{% block extra_css %}
<style>
.marks-table {
width: 100%;
border-collapse: collapse;
}
.marks-table th,
.marks-table td {
border: 1px solid #dee2e6;
padding: 8px;
vertical-align: middle;
}
.marks-table th {
background-color: #f8f9fa;
font-weight: 600;
text-align: center;
}
.source-info-cell {
min-width: 250px;
background-color: #f8f9fa;
}
.marks-cell {
min-width: 150px;
text-align: center;
}
.actions-cell {
min-width: 180px;
text-align: center;
}
.mark-status {
font-size: 1.1rem;
}
.mark-present {
color: #28a745;
font-weight: 600;
}
.mark-absent {
color: #dc3545;
font-weight: 600;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.btn-mark {
padding: 6px 16px;
font-size: 0.875rem;
min-width: 100px;
}
.btn-edit-mark {
padding: 2px 8px;
font-size: 0.75rem;
}
.filter-section {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.no-marks {
color: #6c757d;
font-style: italic;
text-align: center;
}
.btn-mark:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-edit-mark:disabled {
opacity: 0.5;
cursor: wait;
}
.mark-status {
transition: color 0.3s ease;
}
.btn-edit-mark:hover:not(:disabled) {
background-color: #6c757d;
color: white;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Наличие сигнала объектов</h2>
</div>
<!-- Фильтры -->
<div class="filter-section">
<form method="get" class="row g-3">
<div class="col-md-6">
<label for="satellite" class="form-label">Спутник</label>
<select class="form-select" id="satellite" name="satellite">
<option value="">Все спутники</option>
{% for sat in satellites %}
<option value="{{ sat.id }}" {% if request.GET.satellite == sat.id|stringformat:"s" %}selected{% endif %}>
{{ sat.name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 d-flex align-items-end">
<button type="submit" class="btn btn-primary me-2">Применить</button>
<a href="{% url 'mainapp:object_marks' %}" class="btn btn-secondary">Сбросить</a>
</div>
</form>
</div>
<!-- Таблица с наличие сигналами -->
<div class="table-responsive">
<table class="marks-table table table-bordered">
<thead>
<tr>
<th class="source-info-cell">Информация об объекте</th>
<th class="marks-cell">Наличие</th>
<th class="marks-cell">Дата и время</th>
<th class="actions-cell">Действия</th>
</tr>
</thead>
<tbody>
{% for source in sources %}
{% with marks=source.marks.all %}
{% if marks %}
<!-- Первая строка с информацией об объекте и первой отметкой -->
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell" rowspan="{{ marks.count }}">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Дата создания:</strong> {{ source.created_at|date:"d.m.Y H:i" }}</div>
<div><strong>Кол-во объектов:</strong> {{ source.source_objitems.count }}</div>
{% if source.coords_average %}
<div><strong>Усреднённые координаты:</strong> Есть</div>
{% endif %}
{% if source.coords_kupsat %}
<div><strong>Координаты Кубсата:</strong> Есть</div>
{% endif %}
{% if source.coords_valid %}
<div><strong>Координаты оперативников:</strong> Есть</div>
{% endif %}
</td>
{% with first_mark=marks.0 %}
<td class="marks-cell" data-mark-id="{{ first_mark.id }}">
<span class="mark-status {% if first_mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if first_mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if first_mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ first_mark.id }}, {{ first_mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ first_mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ first_mark.created_by|default:"—" }}</small>
</td>
<td class="actions-cell" rowspan="{{ marks.count }}">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
{% endwith %}
</tr>
<!-- Остальные наличие сигнала -->
{% for mark in marks|slice:"1:" %}
<tr data-source-id="{{ source.id }}">
<td class="marks-cell" data-mark-id="{{ mark.id }}">
<span class="mark-status {% if mark.mark %}mark-present{% else %}mark-absent{% endif %}">
{% if mark.mark %}✓ Есть{% else %}✗ Нет{% endif %}
</span>
{% if mark.can_edit %}
<button class="btn btn-sm btn-outline-secondary btn-edit-mark ms-2"
onclick="toggleMark({{ mark.id }}, {{ mark.mark|yesno:'true,false' }})">
</button>
{% endif %}
</td>
<td class="marks-cell">
<div>{{ mark.timestamp|date:"d.m.Y H:i" }}</div>
<small class="text-muted">{{ mark.created_by|default:"—" }}</small>
</td>
</tr>
{% endfor %}
{% else %}
<!-- Объект без отметок -->
<tr data-source-id="{{ source.id }}">
<td class="source-info-cell">
<div><strong>ID:</strong> {{ source.id }}</div>
<div><strong>Дата создания:</strong> {{ source.created_at|date:"d.m.Y H:i" }}</div>
<div><strong>Кол-во объектов:</strong> {{ source.source_objitems.count }}</div>
{% if source.coords_average %}
<div><strong>Усреднённые координаты:</strong> Есть</div>
{% endif %}
{% if source.coords_kupsat %}
<div><strong>Координаты Кубсата:</strong> Есть</div>
{% endif %}
{% if source.coords_valid %}
<div><strong>Координаты оперативников:</strong> Есть</div>
{% endif %}
</td>
<td colspan="2" class="no-marks">Отметок нет</td>
<td class="actions-cell">
<div class="action-buttons" id="actions-{{ source.id }}">
<button class="btn btn-success btn-mark btn-sm"
onclick="addMark({{ source.id }}, true)">
✓ Есть
</button>
<button class="btn btn-danger btn-mark btn-sm"
onclick="addMark({{ source.id }}, false)">
✗ Нет
</button>
</div>
</td>
</tr>
{% endif %}
{% endwith %}
{% empty %}
<tr>
<td colspan="4" class="text-center py-4">
<p class="text-muted mb-0">Объекти не найдены</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if is_paginated %}
<nav aria-label="Навигация по страницам" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Предыдущая</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="?page={{ page_obj.next_page_number }}{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if request.GET.satellite %}&satellite={{ request.GET.satellite }}{% endif %}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
function addMark(sourceId, mark) {
// Отключить кнопки для этого объекта
const buttons = document.querySelectorAll(`#actions-${sourceId} button`);
buttons.forEach(btn => btn.disabled = true);
fetch("{% url 'mainapp:add_object_mark' %}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': '{{ csrf_token }}'
},
body: `source_id=${sourceId}&mark=${mark}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Перезагрузить страницу для обновления таблицы
location.reload();
} else {
// Включить кнопки обратно
buttons.forEach(btn => btn.disabled = false);
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(error => {
console.error('Error:', error);
buttons.forEach(btn => btn.disabled = false);
alert('Ошибка при добавлении наличие сигнала');
});
}
function toggleMark(markId, currentValue) {
const newValue = !currentValue;
const cell = document.querySelector(`td[data-mark-id="${markId}"]`);
const editBtn = cell.querySelector('.btn-edit-mark');
// Отключить кнопку редактирования на время запроса
if (editBtn) {
editBtn.disabled = true;
}
fetch("{% url 'mainapp:update_object_mark' %}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': '{{ csrf_token }}'
},
body: `mark_id=${markId}&mark=${newValue}`
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Обновить отображение наличие сигнала без перезагрузки страницы
const statusSpan = cell.querySelector('.mark-status');
if (data.mark.mark) {
statusSpan.textContent = '✓ Есть';
statusSpan.className = 'mark-status mark-present';
} else {
statusSpan.textContent = '✗ Нет';
statusSpan.className = 'mark-status mark-absent';
}
// Обновить значение в onclick для следующего переключения
if (editBtn) {
editBtn.setAttribute('onclick', `toggleMark(${markId}, ${data.mark.mark})`);
editBtn.disabled = false;
}
// Если больше нельзя редактировать, убрать кнопку
if (!data.mark.can_edit && editBtn) {
editBtn.remove();
}
} else {
// Включить кнопку обратно при ошибке
if (editBtn) {
editBtn.disabled = false;
}
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(error => {
console.error('Error:', error);
if (editBtn) {
editBtn.disabled = false;
}
alert('Ошибка при изменении наличие сигнала');
});
}
</script>
{% endblock %}

View File

@@ -16,7 +16,7 @@
<p>Вы уверены, что хотите удалить этот объект? Это действие нельзя будет отменить.</p> <p>Вы уверены, что хотите удалить этот объект? Это действие нельзя будет отменить.</p>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<button type="submit" class="btn btn-danger">Удалить</button> <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> </div>
</form> </form>
</div> </div>

View File

@@ -3,7 +3,7 @@
{% load static leaflet_tags %} {% load static leaflet_tags %}
{% load l10n %} {% load l10n %}
{% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать объект{% endif %}{% endblock %} {% block title %}{% if object %}Редактировать объект: {{ object.name }}{% else %}Создать новый объект{% endif %}{% endblock %}
{% block extra_css %} {% block extra_css %}
<link rel="stylesheet" href="{% static 'css/checkbox-select-multiple.css' %}"> <link rel="stylesheet" href="{% static 'css/checkbox-select-multiple.css' %}">
@@ -144,7 +144,7 @@
<div class="container-fluid px-3"> <div class="container-fluid px-3">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center"> <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> <div>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="submit" form="objitem-form" class="btn btn-primary btn-action">Сохранить</button> <button type="submit" form="objitem-form" class="btn btn-primary btn-action">Сохранить</button>
@@ -248,6 +248,7 @@
</div> </div>
</div> </div>
{% if object %}
<!-- Транспондер --> <!-- Транспондер -->
<div class="form-section"> <div class="form-section">
<div class="form-section-header"> <div class="form-section-header">
@@ -339,6 +340,7 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endif %}
<!-- Блок с картой --> <!-- Блок с картой -->
<div class="form-section"> <div class="form-section">

View File

@@ -1,13 +1,24 @@
{% extends 'mainapp/base.html' %} {% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Список объектов{% endblock %} {% block title %}Список объектов{% endblock %}
{% block extra_css %} {% block extra_css %}
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-draw/leaflet.draw.css' %}" rel="stylesheet">
<style> <style>
.table-responsive tr.selected { .table-responsive tr.selected {
background-color: #d4edff; background-color: #d4edff;
} }
#polygonFilterMap {
z-index: 1;
}
</style> </style>
{% endblock %} {% endblock %}
{% block extra_js %}
<script src="{% static 'js/sorting.js' %}"></script>
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-draw/leaflet.draw.js' %}"></script>
{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid px-3"> <div class="container-fluid px-3">
<div class="row mb-3"> <div class="row mb-3">
@@ -36,22 +47,22 @@
<!-- Action buttons bar --> <!-- Action buttons bar -->
<div class="d-flex gap-2"> <div class="d-flex gap-2">
{% comment %} <button type="button" class="btn btn-success btn-sm" title="Добавить">
<i class="bi bi-plus-circle"></i> Добавить
</button>
<button type="button" class="btn btn-info btn-sm" title="Изменить">
<i class="bi bi-pencil"></i> Изменить
</button> {% endcomment %}
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<a href="{% url 'mainapp:objitem_create' %}" class="btn btn-success btn-sm" title="Создать новый объект">
<i class="bi bi-plus-circle"></i> Создать
</a>
<button type="button" class="btn btn-danger btn-sm" title="Удалить" <button type="button" class="btn btn-danger btn-sm" title="Удалить"
onclick="deleteSelectedObjects()"> onclick="deleteSelectedObjects()">
<i class="bi bi-trash"></i> Удалить <i class="bi bi-trash"></i> Удалить
</button> </button>
{% endif %} {% endif %}
<button type="button" class="btn btn-outline-primary btn-sm" title="Показать на карте" <button type="button" class="btn btn-primary btn-sm" title="Показать на карте"
onclick="showSelectedOnMap()"> onclick="showSelectedOnMap()">
<i class="bi bi-map"></i> Карта <i class="bi bi-map"></i> Карта
</button> </button>
<!-- <a href="{% url 'mainapp:tech_analyze_entry' %}" class="btn btn-info btn-sm" title="Тех. анализ">
<i class="bi bi-clipboard-data"></i> Тех. анализ
</a> -->
</div> </div>
<!-- Items per page select moved here --> <!-- Items per page select moved here -->
@@ -114,19 +125,66 @@
</div> </div>
<div class="offcanvas-body"> <div class="offcanvas-body">
<form method="get" id="filter-form"> <form method="get" id="filter-form">
<!-- Satellite Selection - Multi-select --> <!-- Hidden field to preserve polygon filter -->
{% if polygon_coords %}
<input type="hidden" name="polygon" value="{{ polygon_coords }}">
{% endif %}
<!-- Polygon Filter Section -->
<div class="mb-3">
<label class="form-label fw-bold">
<i class="bi bi-pentagon"></i> Фильтр по полигону
</label>
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-success btn-sm"
onclick="openPolygonFilterMap()">
<i class="bi bi-pentagon"></i> Нарисовать полигон
{% if polygon_coords %}
<span class="badge bg-success ms-1">✓ Активен</span>
{% endif %}
</button>
{% if polygon_coords %}
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="clearPolygonFilter()" title="Очистить фильтр по полигону">
<i class="bi bi-x-circle"></i> Очистить полигон
</button>
{% endif %}
</div>
</div>
<hr class="my-3">
<!-- Satellite Filter -->
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Спутник:</label> <label class="form-label">Спутник:</label>
<div class="d-flex justify-content-between mb-1"> <div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary" <button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', true)">Выбрать</button> onclick="selectAllOptions('satellite', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary" <button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', false)">Снять</button> onclick="selectAllOptions('satellite', false)">Снять</button>
</div> </div>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6"> <select name="satellite" class="form-select form-select-sm mb-2" multiple size="6">
{% for satellite in satellites %} {% for sat in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}> <option value="{{ sat.id }}" {% if sat.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }} {{ sat.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Complex Filter -->
<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('complex', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('complex', false)">Снять</button>
</div>
<select name="complex" class="form-select form-select-sm mb-2" multiple size="2">
{% for complex_code, complex_name in complexes %}
<option value="{{ complex_code }}" {% if complex_code in selected_complexes %}selected{% endif %}>
{{ complex_name }}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
@@ -204,37 +262,37 @@
</select> </select>
</div> </div>
<!-- Standard Filter -->
<!-- Source Type Filter -->
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Тип точки:</label> <label class="form-label">Стандарт:</label>
<div> <div class="d-flex justify-content-between mb-1">
<div class="form-check form-check-inline"> <button type="button" class="btn btn-sm btn-outline-secondary"
<input class="form-check-input" type="checkbox" name="has_source_type" onclick="selectAllOptions('standard', true)">Выбрать</button>
id="has_source_type_1" value="1" {% if has_source_type == '1' %}checked{% endif %}> <button type="button" class="btn btn-sm btn-outline-secondary"
<label class="form-check-label" for="has_source_type_1">Есть (ТВ)</label> onclick="selectAllOptions('standard', false)">Снять</button>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_source_type"
id="has_source_type_0" value="0" {% if has_source_type == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_source_type_0">Нет</label>
</div>
</div> </div>
<select name="standard" class="form-select form-select-sm mb-2" multiple size="4">
{% for std in standards %}
<option value="{{ std.id }}" {% if std.id in selected_standards %}selected{% endif %}>
{{ std.name }}
</option>
{% endfor %}
</select>
</div> </div>
<!-- Sigma Filter --> <!-- Automatic Filter -->
<div class="mb-2"> <div class="mb-2">
<label class="form-label">Sigma:</label> <label class="form-label">Автоматическая:</label>
<div> <div>
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_1" value="1" <input class="form-check-input" type="checkbox" name="is_automatic" id="is_automatic_1" value="1"
{% if has_sigma == '1' %}checked{% endif %}> {% if is_automatic == '1' %}checked{% endif %}>
<label class="form-check-label" for="has_sigma_1">Есть</label> <label class="form-check-label" for="is_automatic_1">Да</label>
</div> </div>
<div class="form-check form-check-inline"> <div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="has_sigma" id="has_sigma_0" value="0" <input class="form-check-input" type="checkbox" name="is_automatic" id="is_automatic_0" value="0"
{% if has_sigma == '0' %}checked{% endif %}> {% if is_automatic == '0' %}checked{% endif %}>
<label class="form-check-label" for="has_sigma_0">Нет</label> <label class="form-check-label" for="is_automatic_0">Нет</label>
</div> </div>
</div> </div>
</div> </div>
@@ -262,6 +320,24 @@
value="{{ date_to|default:'' }}"> value="{{ date_to|default:'' }}">
</div> </div>
<!-- Mirrors Filter -->
<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('mirror', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('mirror', false)">Снять</button>
</div>
<select name="mirror" class="form-select form-select-sm mb-2" multiple size="6">
{% for mir in mirrors %}
<option value="{{ mir.id }}" {% if mir.id in selected_mirrors %}selected{% endif %}>
{{ mir.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Apply Filters and Reset Buttons --> <!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2"> <div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button> <button type="submit" class="btn btn-primary btn-sm">Применить</button>
@@ -289,7 +365,7 @@
{% include 'mainapp/components/_table_header.html' with label="Част, МГц" field="frequency" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Част, МГц" field="frequency" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Полоса, МГц" field="freq_range" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Полоса, МГц" field="freq_range" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Поляризация" field="polarization" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Поляризация" field="polarization" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Сим. V" field="bod_velocity" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Сим. скор." field="bod_velocity" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Модул" field="modulation" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Модул" field="modulation" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="ОСШ" field="snr" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="ОСШ" field="snr" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Время ГЛ" field="geo_timestamp" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Время ГЛ" field="geo_timestamp" sort=sort %}
@@ -303,8 +379,8 @@
{% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %} {% include 'mainapp/components/_table_header.html' with label="Усреднённое" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %} {% include 'mainapp/components/_table_header.html' with label="Стандарт" field="standard" sort=sort %}
{% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %} {% include 'mainapp/components/_table_header.html' with label="Тип точки" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Sigma" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %} {% include 'mainapp/components/_table_header.html' with label="Зеркала" field="" sortable=False %}
{% include 'mainapp/components/_table_header.html' with label="Автоматическая?" field="is_automatic" sort=sort %}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -315,13 +391,22 @@
</td> </td>
<td> <td>
<a href="{% if item.obj.id %}{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}{% url 'mainapp:objitem_update' item.obj.id %}?{{ request.GET.urlencode }}{% else %}{% url 'mainapp:objitem_detail' item.obj.id %}?{{ request.GET.urlencode }}{% endif %}{% endif %}">{{ item.name }}</a></td> <a href="{% if item.obj.id %}{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}{% url 'mainapp:objitem_update' item.obj.id %}?{{ request.GET.urlencode }}{% else %}{% url 'mainapp:objitem_detail' item.obj.id %}?{{ request.GET.urlencode }}{% endif %}{% endif %}">{{ item.name }}</a></td>
<td>{{ item.satellite_name }}</td> <td>
{% if item.satellite_id %}
<a href="#" class="text-decoration-underline"
onclick="showSatelliteModal({{ item.satellite_id }}); return false;">
{{ item.satellite_name }}
</a>
{% else %}
{{ item.satellite_name }}
{% endif %}
</td>
<td> <td>
{% if item.obj.transponder %} {% if item.obj.transponder %}
<a href="#" class="text-success text-decoration-none" <a href="#" class="text-decoration-underline"
onclick="showTransponderModal({{ item.obj.transponder.id }}); return false;" onclick="showTransponderModal({{ item.obj.transponder.id }}); return false;"
title="Показать данные транспондера"> title="Показать данные транспондера">
<i class="bi bi-broadcast"></i> {{ item.obj.transponder.downlink }}:{{ item.obj.transponder.frequency_range }} {{ item.obj.transponder.downlink }}:{{ item.obj.transponder.frequency_range }}
</a> </a>
{% else %} {% else %}
- -
@@ -353,18 +438,8 @@
- -
{% endif %} {% endif %}
</td> </td>
<td> <td>{{ item.mirrors_display|safe }}</td>
{% if item.has_sigma %} <td>{{ item.is_automatic }}</td>
<a href="#" class="text-info text-decoration-none"
onclick="showSigmaParameterModal({{ item.obj.parameter_obj.id }}); return false;"
title="{{ item.sigma_info }}">
<i class="bi bi-graph-up"></i> {{ item.sigma_info }}
</a>
{% else %}
-
{% endif %}
</td>
<td>{{ item.mirrors }}</td>
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
@@ -525,7 +600,29 @@
}); });
} }
// Остальной ваш JavaScript код остается без изменений // Column visibility functions with localStorage support
function getColumnVisibilityKey() {
return 'objitemListColumnVisibility';
}
function saveColumnVisibility() {
const columnCheckboxes = document.querySelectorAll('.column-toggle');
const visibility = {};
columnCheckboxes.forEach(checkbox => {
const columnIndex = checkbox.getAttribute('data-column');
visibility[columnIndex] = checkbox.checked;
});
localStorage.setItem(getColumnVisibilityKey(), JSON.stringify(visibility));
}
function loadColumnVisibility() {
const saved = localStorage.getItem(getColumnVisibilityKey());
if (saved) {
return JSON.parse(saved);
}
return null;
}
function toggleColumn(checkbox) { function toggleColumn(checkbox) {
const columnIndex = parseInt(checkbox.getAttribute('data-column')); const columnIndex = parseInt(checkbox.getAttribute('data-column'));
const table = document.querySelector('.table'); const table = document.querySelector('.table');
@@ -540,7 +637,27 @@
cell.style.display = 'none'; cell.style.display = 'none';
}); });
} }
// Save state after toggle
saveColumnVisibility();
} }
function toggleColumnWithoutSave(checkbox) {
const columnIndex = parseInt(checkbox.getAttribute('data-column'));
const table = document.querySelector('.table');
const cells = table.querySelectorAll(`td:nth-child(${columnIndex + 1}), th:nth-child(${columnIndex + 1})`);
if (checkbox.checked) {
cells.forEach(cell => {
cell.style.display = '';
});
} else {
cells.forEach(cell => {
cell.style.display = 'none';
});
}
}
function toggleAllColumns(selectAllCheckbox) { function toggleAllColumns(selectAllCheckbox) {
const columnCheckboxes = document.querySelectorAll('.column-toggle'); const columnCheckboxes = document.querySelectorAll('.column-toggle');
columnCheckboxes.forEach(checkbox => { columnCheckboxes.forEach(checkbox => {
@@ -599,6 +716,7 @@
setupRadioLikeCheckboxes('has_valid'); setupRadioLikeCheckboxes('has_valid');
setupRadioLikeCheckboxes('has_source_type'); setupRadioLikeCheckboxes('has_source_type');
setupRadioLikeCheckboxes('has_sigma'); setupRadioLikeCheckboxes('has_sigma');
setupRadioLikeCheckboxes('is_automatic');
// Date range quick selection functions // Date range quick selection functions
window.setDateRange = function (period) { window.setDateRange = function (period) {
@@ -704,36 +822,32 @@
// Initialize column visibility - hide creation columns by default // Initialize column visibility - hide creation columns by default
function initColumnVisibility() { function initColumnVisibility() {
const creationDateCheckbox = document.querySelector('input[data-column="15"]'); const savedVisibility = loadColumnVisibility();
const creationUserCheckbox = document.querySelector('input[data-column="16"]');
if (creationDateCheckbox) { if (savedVisibility) {
creationDateCheckbox.checked = false; // Restore saved state
toggleColumn(creationDateCheckbox); const columnCheckboxes = document.querySelectorAll('.column-toggle');
} columnCheckboxes.forEach(checkbox => {
const columnIndex = checkbox.getAttribute('data-column');
if (creationUserCheckbox) { if (savedVisibility.hasOwnProperty(columnIndex)) {
creationUserCheckbox.checked = false; checkbox.checked = savedVisibility[columnIndex];
toggleColumn(creationUserCheckbox); toggleColumnWithoutSave(checkbox);
} }
});
// Hide comment, is_average, and standard columns by default } else {
const commentCheckbox = document.querySelector('input[data-column="17"]'); // Default state: hide specific columns
const isAverageCheckbox = document.querySelector('input[data-column="18"]'); const columnsToHide = [15, 16, 17, 18, 19]; // Создано, Кем(созд), Комментарий, Усреднённое, Стандарт
const standardCheckbox = document.querySelector('input[data-column="19"]');
columnsToHide.forEach(columnIndex => {
if (commentCheckbox) { const checkbox = document.querySelector(`input[data-column="${columnIndex}"]`);
commentCheckbox.checked = false; if (checkbox) {
toggleColumn(commentCheckbox); checkbox.checked = false;
} toggleColumnWithoutSave(checkbox);
}
if (isAverageCheckbox) { });
isAverageCheckbox.checked = false;
toggleColumn(isAverageCheckbox); // Save initial state
} saveColumnVisibility();
if (standardCheckbox) {
standardCheckbox.checked = false;
toggleColumn(standardCheckbox);
} }
} }
// Filter counter functionality // Filter counter functionality
@@ -743,19 +857,24 @@
let filterCount = 0; let filterCount = 0;
// Count non-empty form fields // Count non-empty form fields
const multiSelectFieldNames = ['modulation', 'polarization', 'standard', 'satellite', 'mirror', 'complex'];
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') { if (value && value.trim() !== '') {
// For multi-select fields, we need to handle them separately // For multi-select fields, we need to handle them separately
if (key === 'satellite_id' || key === 'modulation' || key === 'polarization') { if (multiSelectFieldNames.includes(key)) {
// Skip counting individual selections - they'll be counted as one filter // Skip counting individual selections - they'll be counted as one filter
continue; continue;
} }
// Skip polygon hidden field - counted separately
if (key === 'polygon') {
continue;
}
filterCount++; filterCount++;
} }
} }
// Count selected options in multi-select fields // Count selected options in multi-select fields
const multiSelectFields = ['satellite_id', 'modulation', 'polarization']; const multiSelectFields = ['modulation', 'polarization', 'standard', 'satellite', 'mirror', 'complex'];
for (const field of multiSelectFields) { for (const field of multiSelectFields) {
const selectElement = document.querySelector(`select[name="${field}"]`); const selectElement = document.querySelector(`select[name="${field}"]`);
if (selectElement) { if (selectElement) {
@@ -766,14 +885,9 @@
} }
} }
// Count checkbox filters // Check if polygon filter is active
const hasKupsatCheckboxes = document.querySelectorAll('input[name="has_kupsat"]:checked'); const urlParams = new URLSearchParams(window.location.search);
const hasValidCheckboxes = document.querySelectorAll('input[name="has_valid"]:checked'); if (urlParams.has('polygon')) {
if (hasKupsatCheckboxes.length > 0) {
filterCount++;
}
if (hasValidCheckboxes.length > 0) {
filterCount++; filterCount++;
} }
@@ -902,7 +1016,7 @@
updated_by: row.cells[14].textContent, updated_by: row.cells[14].textContent,
created_at: row.cells[15].textContent, created_at: row.cells[15].textContent,
created_by: row.cells[16].textContent, created_by: row.cells[16].textContent,
mirrors: row.cells[22].textContent mirrors: row.cells[21].textContent
}; };
window.selectedItems.push(rowData); window.selectedItems.push(rowData);
@@ -993,16 +1107,19 @@
populateSelectedItemsTable(); populateSelectedItemsTable();
} }
// Function to send selected items (placeholder) // Function to show selected items on map
function sendSelectedItems() { function showSelectedItemsOnMap() {
const selectedCount = document.querySelectorAll('#selected-items-table-body .selected-item-checkbox:checked').length; if (!window.selectedItems || window.selectedItems.length === 0) {
if (selectedCount === 0) { alert('Список точек пуст');
alert('Пожалуйста, выберите хотя бы один элемент для отправки');
return; return;
} }
alert(`Отправка ${selectedCount} элементов... (функция в разработке)`); // Extract IDs from selected items
// Placeholder for actual send functionality const selectedIds = window.selectedItems.map(item => item.id);
// Redirect to the map view with selected IDs as query parameter
const url = '{% url "mainapp:show_selected_objects_map" %}' + '?ids=' + selectedIds.join(',');
window.open(url, '_blank'); // Open in a new tab
} }
// Function to toggle all checkboxes in the selected items table // Function to toggle all checkboxes in the selected items table
@@ -1337,4 +1454,193 @@
</div> </div>
</div> </div>
<!-- Include the satellite modal component -->
{% include 'mainapp/components/_satellite_modal.html' %}
<!-- Polygon Filter Modal -->
<div class="modal fade" id="polygonFilterModal" tabindex="-1" aria-labelledby="polygonFilterModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="polygonFilterModalLabel">
<i class="bi bi-pentagon"></i> Фильтр по полигону
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body p-0">
<div id="polygonFilterMap" style="height: 500px; width: 100%;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger" onclick="clearPolygonOnMap()">
<i class="bi bi-trash"></i> Очистить
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="button" class="btn btn-success" onclick="applyPolygonFilter()">
<i class="bi bi-check-lg"></i> Применить
</button>
</div>
</div>
</div>
</div>
<script>
// Polygon filter map variables
let polygonFilterMapInstance = null;
let drawnItems = null;
let drawControl = null;
let currentPolygon = null;
// Initialize polygon filter map
function initPolygonFilterMap() {
if (polygonFilterMapInstance) {
return; // Already initialized
}
// Create map centered on Russia
polygonFilterMapInstance = L.map('polygonFilterMap').setView([55.7558, 37.6173], 4);
// Add OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(polygonFilterMapInstance);
// Initialize FeatureGroup to store drawn items
drawnItems = new L.FeatureGroup();
polygonFilterMapInstance.addLayer(drawnItems);
// Initialize draw control
drawControl = new L.Control.Draw({
position: 'topright',
draw: {
polygon: {
allowIntersection: false,
showArea: true,
drawError: {
color: '#e1e100',
message: '<strong>Ошибка:</strong> полигон не должен пересекать сам себя!'
},
shapeOptions: {
color: '#3388ff',
fillOpacity: 0.2
}
},
polyline: false,
rectangle: {
shapeOptions: {
color: '#3388ff',
fillOpacity: 0.2
}
},
circle: false,
circlemarker: false,
marker: false
},
edit: {
featureGroup: drawnItems,
remove: true
}
});
polygonFilterMapInstance.addControl(drawControl);
// Handle polygon creation
polygonFilterMapInstance.on(L.Draw.Event.CREATED, function (event) {
const layer = event.layer;
// Remove existing polygon
drawnItems.clearLayers();
// Add new polygon
drawnItems.addLayer(layer);
currentPolygon = layer;
});
// Handle polygon edit
polygonFilterMapInstance.on(L.Draw.Event.EDITED, function (event) {
const layers = event.layers;
layers.eachLayer(function (layer) {
currentPolygon = layer;
});
});
// Handle polygon deletion
polygonFilterMapInstance.on(L.Draw.Event.DELETED, function () {
currentPolygon = null;
});
// Load existing polygon if present
{% if polygon_coords %}
try {
const coords = {{ polygon_coords|safe }};
if (coords && coords.length > 0) {
const latLngs = coords.map(coord => [coord[1], coord[0]]); // [lng, lat] -> [lat, lng]
const polygon = L.polygon(latLngs, {
color: '#3388ff',
fillOpacity: 0.2
});
drawnItems.addLayer(polygon);
currentPolygon = polygon;
// Fit map to polygon bounds
polygonFilterMapInstance.fitBounds(polygon.getBounds());
}
} catch (e) {
console.error('Error loading existing polygon:', e);
}
{% endif %}
}
// Open polygon filter map modal
function openPolygonFilterMap() {
const modal = new bootstrap.Modal(document.getElementById('polygonFilterModal'));
modal.show();
// Initialize map after modal is shown (to ensure proper rendering)
setTimeout(() => {
initPolygonFilterMap();
if (polygonFilterMapInstance) {
polygonFilterMapInstance.invalidateSize();
}
}, 300);
}
// Clear polygon on map
function clearPolygonOnMap() {
if (drawnItems) {
drawnItems.clearLayers();
currentPolygon = null;
}
}
// Apply polygon filter
function applyPolygonFilter() {
if (!currentPolygon) {
alert('Пожалуйста, нарисуйте полигон на карте');
return;
}
// Get polygon coordinates
const latLngs = currentPolygon.getLatLngs()[0]; // Get first ring for polygon
const coords = latLngs.map(latLng => [latLng.lng, latLng.lat]); // [lat, lng] -> [lng, lat]
// Close the polygon by adding first point at the end
coords.push(coords[0]);
// Add polygon coordinates to URL and reload
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('polygon', JSON.stringify(coords));
urlParams.delete('page'); // Reset to first page
window.location.search = urlParams.toString();
}
// Clear polygon filter
function clearPolygonFilter() {
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('polygon');
urlParams.delete('page');
window.location.search = urlParams.toString();
}
</script>
{% endblock %} {% endblock %}

View File

@@ -52,7 +52,7 @@
attribution: 'Tiles &copy; Esri' attribution: 'Tiles &copy; Esri'
}); });
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', { const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}); });

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

View File

@@ -40,7 +40,7 @@
</div> </div>
<div class="d-flex justify-content-between"> <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> <button type="submit" class="btn btn-success">Выполнить</button>
</div> </div>
</form> </form>

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,971 @@
{% load static %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🎉 Итоги {{ year }} года</title>
<link href="{% static 'bootstrap/bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'bootstrap-icons/bootstrap-icons.css' %}" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700;900&display=swap" rel="stylesheet">
<style>
:root {
--gradient-1: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-2: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-3: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
--gradient-4: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
--gradient-5: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
--gradient-6: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%);
--dark-bg: #0d1117;
--card-bg: rgba(255, 255, 255, 0.05);
}
* { box-sizing: border-box; }
body {
font-family: 'Montserrat', sans-serif;
background: var(--dark-bg);
color: #fff;
overflow-x: hidden;
min-height: 100vh;
}
.particles {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.particle {
position: absolute;
width: 10px;
height: 10px;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 15s infinite ease-in-out;
}
@keyframes float {
0%, 100% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-100vh) rotate(720deg); opacity: 0; }
}
.slide {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px 20px;
position: relative;
z-index: 1;
}
.slide-intro { background: var(--gradient-1); }
.slide-points { background: var(--gradient-2); }
.slide-new { background: var(--gradient-3); }
.slide-satellites { background: var(--gradient-4); }
.slide-time { background: var(--gradient-5); }
.slide-summary { background: var(--gradient-1); }
.big-number {
font-size: clamp(4rem, 15vw, 12rem);
font-weight: 900;
line-height: 1;
text-shadow: 0 10px 30px rgba(0,0,0,0.3);
opacity: 0;
transform: scale(0.5);
animation: popIn 0.8s ease-out forwards;
}
.big-text {
font-size: clamp(1.5rem, 4vw, 3rem);
font-weight: 700;
text-shadow: 0 5px 15px rgba(0,0,0,0.2);
opacity: 0;
transform: translateY(30px);
animation: slideUp 0.6s ease-out 0.3s forwards;
}
.sub-text {
font-size: clamp(1rem, 2vw, 1.5rem);
font-weight: 400;
opacity: 0.9;
margin-top: 10px;
opacity: 0;
animation: fadeIn 0.6s ease-out 0.5s forwards;
}
@keyframes popIn {
0% { opacity: 0; transform: scale(0.5); }
70% { transform: scale(1.1); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes slideUp {
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
to { opacity: 0.9; }
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.stat-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 24px;
padding: 30px;
margin: 15px;
text-align: center;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.2);
opacity: 0;
transform: translateY(50px);
animation: cardSlideUp 0.6s ease-out forwards;
}
.stat-card:hover {
transform: translateY(-10px) scale(1.02);
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
}
@keyframes cardSlideUp {
to { opacity: 1; transform: translateY(0); }
}
.stat-card:nth-child(1) { animation-delay: 0.2s; }
.stat-card:nth-child(2) { animation-delay: 0.4s; }
.stat-card:nth-child(3) { animation-delay: 0.6s; }
.stat-card:nth-child(4) { animation-delay: 0.8s; }
.stat-value {
font-size: 3rem;
font-weight: 900;
margin-bottom: 10px;
}
.stat-label {
font-size: 1rem;
opacity: 0.9;
}
.satellite-list {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px;
max-width: 1200px;
margin-top: 30px;
}
.satellite-item {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 20px 30px;
text-align: center;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.3);
opacity: 0;
transform: scale(0.8);
animation: satelliteIn 0.5s ease-out forwards;
}
.satellite-item:hover {
transform: scale(1.1);
background: rgba(255, 255, 255, 0.3);
}
@keyframes satelliteIn {
to { opacity: 1; transform: scale(1); }
}
.satellite-item:nth-child(1) { animation-delay: 0.1s; }
.satellite-item:nth-child(2) { animation-delay: 0.2s; }
.satellite-item:nth-child(3) { animation-delay: 0.3s; }
.satellite-item:nth-child(4) { animation-delay: 0.4s; }
.satellite-item:nth-child(5) { animation-delay: 0.5s; }
.satellite-item:nth-child(6) { animation-delay: 0.6s; }
.satellite-item:nth-child(7) { animation-delay: 0.7s; }
.satellite-item:nth-child(8) { animation-delay: 0.8s; }
.satellite-item:nth-child(9) { animation-delay: 0.9s; }
.satellite-item:nth-child(10) { animation-delay: 1.0s; }
.satellite-name {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 5px;
}
.satellite-stats {
font-size: 0.9rem;
opacity: 0.9;
}
.chart-container {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 24px;
padding: 30px;
margin: 20px;
max-width: 800px;
width: 100%;
opacity: 0;
animation: fadeIn 0.8s ease-out 0.5s forwards;
}
.chart-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 20px;
text-align: center;
}
.year-selector {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 10px 20px;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.year-selector select {
background: transparent;
border: none;
color: #fff;
font-size: 1.2rem;
font-weight: 700;
cursor: pointer;
outline: none;
}
.year-selector select option {
background: #333;
color: #fff;
}
.scroll-indicator {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% { transform: translateX(-50%) translateY(0); }
40% { transform: translateX(-50%) translateY(-20px); }
60% { transform: translateX(-50%) translateY(-10px); }
}
.scroll-indicator i {
font-size: 2rem;
color: rgba(255, 255, 255, 0.7);
}
.emoji-rain {
position: fixed;
top: -50px;
font-size: 2rem;
animation: rain 3s linear forwards;
z-index: 100;
}
@keyframes rain {
to { transform: translateY(110vh) rotate(360deg); }
}
.glow-text {
text-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor;
}
.counter {
display: inline-block;
}
.progress-bar-custom {
height: 30px;
border-radius: 15px;
background: rgba(255, 255, 255, 0.2);
overflow: hidden;
margin: 10px 0;
}
.progress-fill {
height: 100%;
border-radius: 15px;
background: linear-gradient(90deg, #fff 0%, rgba(255,255,255,0.7) 100%);
transition: width 1.5s ease-out;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 15px;
font-weight: 700;
font-size: 0.9rem;
color: #333;
}
.new-emissions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 15px;
max-width: 1200px;
margin-top: 30px;
width: 100%;
padding: 0 20px;
}
.emission-card {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 15px 20px;
border: 1px solid rgba(255, 255, 255, 0.2);
opacity: 0;
transform: translateX(-30px);
animation: slideRight 0.5s ease-out forwards;
}
@keyframes slideRight {
to { opacity: 1; transform: translateX(0); }
}
.emission-name {
font-weight: 700;
font-size: 1.1rem;
margin-bottom: 5px;
}
.emission-info {
font-size: 0.85rem;
opacity: 0.8;
}
.confetti {
position: fixed;
width: 10px;
height: 10px;
top: -10px;
z-index: 1000;
animation: confetti-fall 3s linear forwards;
}
@keyframes confetti-fall {
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
}
.heatmap-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
justify-content: center;
margin-top: 20px;
}
.heatmap-cell {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
transition: all 0.3s ease;
cursor: pointer;
}
.heatmap-cell:hover {
transform: scale(1.2);
z-index: 10;
}
.nav-dots {
position: fixed;
right: 20px;
top: 50%;
transform: translateY(-50%);
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.nav-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: all 0.3s ease;
}
.nav-dot:hover, .nav-dot.active {
background: #fff;
transform: scale(1.3);
}
@media (max-width: 768px) {
.big-number { font-size: 4rem; }
.big-text { font-size: 1.5rem; }
.stat-card { padding: 20px; margin: 10px; }
.stat-value { font-size: 2rem; }
.nav-dots { display: none; }
.year-selector { top: 10px; right: 10px; padding: 8px 15px; }
}
</style>
</head>
<body>
<!-- Particles Background -->
<div class="particles" id="particles"></div>
<!-- Year Selector -->
<div class="year-selector">
<select id="yearSelect" onchange="changeYear(this.value)">
{% for y in available_years %}
<option value="{{ y }}" {% if y == year %}selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
</div>
<!-- Navigation Dots -->
<div class="nav-dots">
<div class="nav-dot active" data-slide="0" title="Начало"></div>
<div class="nav-dot" data-slide="1" title="Точки ГЛ"></div>
<div class="nav-dot" data-slide="2" title="Новые излучения"></div>
<div class="nav-dot" data-slide="3" title="Спутники"></div>
<div class="nav-dot" data-slide="4" title="Время"></div>
<div class="nav-dot" data-slide="5" title="Итоги"></div>
</div>
<!-- Scroll Indicator -->
<div class="scroll-indicator" id="scrollIndicator">
<i class="bi bi-chevron-double-down"></i>
</div>
<!-- Slide 1: Intro -->
<section class="slide slide-intro" data-slide="0">
<div class="text-center">
<div class="big-text" style="animation-delay: 0s;">🎉 Ваш {{ year }} год</div>
<div class="big-number" style="animation-delay: 0.3s;">в цифрах</div>
<div class="sub-text" style="animation-delay: 0.6s;">Итоги работы системы геолокации</div>
</div>
</section>
<!-- Slide 2: Total Points -->
<section class="slide slide-points" data-slide="1">
<div class="text-center">
<div class="sub-text">За {{ year }} год вы получили</div>
<div class="big-number counter" data-target="{{ total_points }}">0</div>
<div class="big-text">точек геолокации</div>
<div class="sub-text">по <span class="counter" data-target="{{ total_sources }}">0</span> объектам</div>
{% if busiest_day %}
<div class="stat-card mt-5" style="display: inline-block;">
<div class="stat-label">🔥 Самый активный день</div>
<div class="stat-value">{{ busiest_day.date|date:"d.m.Y" }}</div>
<div class="stat-label">{{ busiest_day.points }} точек</div>
</div>
{% endif %}
</div>
</section>
<!-- Slide 3: New Emissions -->
<section class="slide slide-new" data-slide="2">
<div class="text-center">
<div class="sub-text">✨ Новые открытия</div>
<div class="big-number counter" data-target="{{ new_emissions_count }}">0</div>
<div class="big-text">новых излучений</div>
<div class="sub-text">впервые обнаруженных в {{ year }} году</div>
<div class="sub-text">по <span class="counter" data-target="{{ new_emissions_sources }}">0</span> объектам</div>
{% if new_emission_objects %}
<div class="new-emissions-grid">
{% for obj in new_emission_objects %}
<div class="emission-card" style="animation-delay: {{ forloop.counter0|divisibleby:10 }}s;">
<div class="emission-name">{{ obj.name }}</div>
<div class="emission-info">{{ obj.info }} • {{ obj.ownership }}</div>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</section>
<!-- Slide 4: Satellites -->
<section class="slide slide-satellites" data-slide="3">
<div class="text-center">
<div class="sub-text">📡 Спутники</div>
<div class="big-number counter" data-target="{{ satellite_count }}">0</div>
<div class="big-text">спутников с данными</div>
<div class="satellite-list">
{% for sat in satellite_stats %}
<div class="satellite-item">
<div class="satellite-name">{{ sat.parameter_obj__id_satellite__name }}</div>
<div class="satellite-stats">
<strong>{{ sat.points_count }}</strong> точек •
<strong>{{ sat.sources_count }}</strong> объектов
</div>
</div>
{% endfor %}
</div>
<div class="chart-container mt-4">
<div class="chart-title">Распределение точек по спутникам</div>
<canvas id="satelliteChart" height="300"></canvas>
</div>
</div>
</section>
<!-- Slide 5: Time Analysis -->
<section class="slide slide-time" data-slide="4">
<div class="text-center">
<div class="sub-text">⏰ Когда вы работали</div>
<div class="big-text">Анализ по времени</div>
<div class="row justify-content-center mt-4">
<div class="col-md-5">
<div class="chart-container">
<div class="chart-title">По месяцам</div>
<canvas id="monthlyChart" height="250"></canvas>
</div>
</div>
<div class="col-md-5">
<div class="chart-container">
<div class="chart-title">По дням недели</div>
<canvas id="weekdayChart" height="250"></canvas>
</div>
</div>
</div>
<div class="chart-container" style="max-width: 600px;">
<div class="chart-title">По часам</div>
<canvas id="hourlyChart" height="200"></canvas>
</div>
</div>
</section>
<!-- Slide 6: Summary -->
<section class="slide slide-summary" data-slide="5">
<div class="text-center">
<div class="big-text">🏆 Итоги {{ year }}</div>
<div class="row justify-content-center mt-4">
<div class="col-auto">
<div class="stat-card">
<div class="stat-value glow-text">{{ total_points }}</div>
<div class="stat-label">Точек ГЛ</div>
</div>
</div>
<div class="col-auto">
<div class="stat-card">
<div class="stat-value glow-text">{{ total_sources }}</div>
<div class="stat-label">Объектов</div>
</div>
</div>
<div class="col-auto">
<div class="stat-card">
<div class="stat-value glow-text">{{ new_emissions_count }}</div>
<div class="stat-label">Новых излучений</div>
</div>
</div>
<div class="col-auto">
<div class="stat-card">
<div class="stat-value glow-text">{{ satellite_count }}</div>
<div class="stat-label">Спутников</div>
</div>
</div>
</div>
<div class="chart-container mt-4" style="max-width: 700px;">
<div class="chart-title">🌟 Топ-10 объектов по количеству точек</div>
<canvas id="topObjectsChart" height="300"></canvas>
</div>
<div class="mt-5">
<div class="big-text">До встречи в {{ year|add:1 }}! 🚀</div>
</div>
</div>
</section>
<script src="{% static 'chartjs/chart.js' %}"></script>
<script src="{% static 'chartjs/chart-datalabels.js' %}"></script>
<script>
// Data from Django
const monthlyData = {{ monthly_data_json|safe }};
const satelliteStats = {{ satellite_stats_json|safe }};
const weekdayData = {{ weekday_data_json|safe }};
const hourlyData = {{ hourly_data_json|safe }};
const topObjects = {{ top_objects_json|safe }};
// Create particles
function createParticles() {
const container = document.getElementById('particles');
for (let i = 0; i < 50; i++) {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 15 + 's';
particle.style.animationDuration = (10 + Math.random() * 10) + 's';
particle.style.width = (5 + Math.random() * 10) + 'px';
particle.style.height = particle.style.width;
container.appendChild(particle);
}
}
createParticles();
// Counter animation
function animateCounters() {
const counters = document.querySelectorAll('.counter');
counters.forEach(counter => {
const target = parseInt(counter.dataset.target) || 0;
const duration = 2000;
const step = target / (duration / 16);
let current = 0;
const updateCounter = () => {
current += step;
if (current < target) {
counter.textContent = Math.floor(current).toLocaleString('ru-RU');
requestAnimationFrame(updateCounter);
} else {
counter.textContent = target.toLocaleString('ru-RU');
}
};
updateCounter();
});
}
// Intersection Observer for animations
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const slideIndex = entry.target.dataset.slide;
document.querySelectorAll('.nav-dot').forEach((dot, i) => {
dot.classList.toggle('active', i == slideIndex);
});
// Trigger counter animation when slide is visible
if (slideIndex == 1 || slideIndex == 2 || slideIndex == 3 || slideIndex == 5) {
entry.target.querySelectorAll('.counter').forEach(counter => {
if (!counter.dataset.animated) {
counter.dataset.animated = 'true';
const target = parseInt(counter.dataset.target) || 0;
animateCounter(counter, target);
}
});
}
// Create confetti on summary slide
if (slideIndex == 5 && !window.confettiCreated) {
window.confettiCreated = true;
createConfetti();
}
}
});
}, { threshold: 0.5 });
document.querySelectorAll('.slide').forEach(slide => observer.observe(slide));
function animateCounter(element, target) {
const duration = 2000;
const step = target / (duration / 16);
let current = 0;
const update = () => {
current += step;
if (current < target) {
element.textContent = Math.floor(current).toLocaleString('ru-RU');
requestAnimationFrame(update);
} else {
element.textContent = target.toLocaleString('ru-RU');
}
};
update();
}
// Confetti effect
function createConfetti() {
const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7', '#dfe6e9', '#fd79a8', '#a29bfe'];
for (let i = 0; i < 100; i++) {
setTimeout(() => {
const confetti = document.createElement('div');
confetti.className = 'confetti';
confetti.style.left = Math.random() * 100 + '%';
confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
confetti.style.animationDuration = (2 + Math.random() * 2) + 's';
confetti.style.borderRadius = Math.random() > 0.5 ? '50%' : '0';
document.body.appendChild(confetti);
setTimeout(() => confetti.remove(), 4000);
}, i * 30);
}
}
// Navigation dots click
document.querySelectorAll('.nav-dot').forEach(dot => {
dot.addEventListener('click', () => {
const slideIndex = dot.dataset.slide;
document.querySelector(`[data-slide="${slideIndex}"]`).scrollIntoView({ behavior: 'smooth' });
});
});
// Hide scroll indicator on scroll
window.addEventListener('scroll', () => {
const indicator = document.getElementById('scrollIndicator');
if (window.scrollY > 100) {
indicator.style.opacity = '0';
} else {
indicator.style.opacity = '1';
}
});
// Year change
function changeYear(year) {
window.location.href = '?year=' + year;
}
// Chart.js configuration
Chart.defaults.color = '#fff';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
// Monthly Chart
if (monthlyData.length > 0) {
const monthNames = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'];
new Chart(document.getElementById('monthlyChart'), {
type: 'bar',
data: {
labels: monthlyData.map(d => {
if (d.month) {
const [year, month] = d.month.split('-');
return monthNames[parseInt(month) - 1];
}
return '';
}),
datasets: [{
label: 'Точки',
data: monthlyData.map(d => d.points),
backgroundColor: 'rgba(255, 255, 255, 0.7)',
borderRadius: 8,
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
datalabels: { display: false }
},
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' } },
x: { grid: { display: false } }
}
}
});
}
// Weekday Chart
if (weekdayData.length > 0) {
const weekdayNames = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
const sortedWeekday = [...weekdayData].sort((a, b) => {
// Convert Sunday (1) to 7 for proper sorting (Mon-Sun)
const aDay = a.weekday === 1 ? 8 : a.weekday;
const bDay = b.weekday === 1 ? 8 : b.weekday;
return aDay - bDay;
});
new Chart(document.getElementById('weekdayChart'), {
type: 'polarArea',
data: {
labels: sortedWeekday.map(d => weekdayNames[d.weekday - 1]),
datasets: [{
data: sortedWeekday.map(d => d.points),
backgroundColor: [
'rgba(255, 99, 132, 0.7)',
'rgba(54, 162, 235, 0.7)',
'rgba(255, 206, 86, 0.7)',
'rgba(75, 192, 192, 0.7)',
'rgba(153, 102, 255, 0.7)',
'rgba(255, 159, 64, 0.7)',
'rgba(199, 199, 199, 0.7)'
],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: true,
plugins: {
legend: { position: 'right' },
datalabels: { display: false }
}
}
});
}
// Hourly Chart
if (hourlyData.length > 0) {
// Fill missing hours with 0
const fullHourlyData = Array.from({length: 24}, (_, i) => {
const found = hourlyData.find(d => d.hour === i);
return found ? found.points : 0;
});
new Chart(document.getElementById('hourlyChart'), {
type: 'line',
data: {
labels: Array.from({length: 24}, (_, i) => i + ':00'),
datasets: [{
label: 'Точки',
data: fullHourlyData,
borderColor: 'rgba(255, 255, 255, 0.9)',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointBackgroundColor: '#fff'
}]
},
options: {
responsive: true,
plugins: {
legend: { display: false },
datalabels: { display: false }
},
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(255,255,255,0.1)' } },
x: { grid: { display: false } }
}
}
});
}
// Satellite Pie Chart
if (satelliteStats.length > 0) {
const top10 = satelliteStats.slice(0, 10);
const otherPoints = satelliteStats.slice(10).reduce((sum, s) => sum + s.points_count, 0);
const labels = top10.map(s => s.parameter_obj__id_satellite__name);
const data = top10.map(s => s.points_count);
if (otherPoints > 0) {
labels.push('Другие');
data.push(otherPoints);
}
const colors = [
'#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7',
'#dfe6e9', '#fd79a8', '#a29bfe', '#00b894', '#e17055', '#636e72'
];
new Chart(document.getElementById('satelliteChart'), {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: data,
backgroundColor: colors.slice(0, data.length),
borderWidth: 3,
borderColor: 'rgba(255,255,255,0.3)'
}]
},
options: {
responsive: true,
cutout: '60%',
plugins: {
legend: { position: 'right', labels: { padding: 15 } },
datalabels: {
color: '#fff',
font: { weight: 'bold', size: 11 },
formatter: (value, ctx) => {
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const pct = ((value / total) * 100).toFixed(1);
return pct > 5 ? pct + '%' : '';
}
}
}
},
plugins: [ChartDataLabels]
});
}
// Top Objects Chart
if (topObjects.length > 0) {
const colors = [
'#ffd700', '#c0c0c0', '#cd7f32', '#4ecdc4', '#45b7d1',
'#96ceb4', '#ffeaa7', '#fd79a8', '#a29bfe', '#00b894'
];
new Chart(document.getElementById('topObjectsChart'), {
type: 'bar',
data: {
labels: topObjects.map(o => o.name.length > 20 ? o.name.substring(0, 20) + '...' : o.name),
datasets: [{
label: 'Точки',
data: topObjects.map(o => o.points),
backgroundColor: colors,
borderRadius: 8,
borderSkipped: false
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {
legend: { display: false },
datalabels: {
anchor: 'end',
align: 'end',
color: '#fff',
font: { weight: 'bold' },
formatter: (value) => value.toLocaleString('ru-RU')
}
},
scales: {
x: {
beginAtZero: true,
grid: { color: 'rgba(255,255,255,0.1)' },
grace: '15%'
},
y: { grid: { display: false } }
}
},
plugins: [ChartDataLabels]
});
}
// Emoji rain on intro
setTimeout(() => {
const emojis = ['🛰️', '📡', '🌍', '✨', '🎯', '📍', '🔭', '⭐'];
for (let i = 0; i < 20; i++) {
setTimeout(() => {
const emoji = document.createElement('div');
emoji.className = 'emoji-rain';
emoji.textContent = emojis[Math.floor(Math.random() * emojis.length)];
emoji.style.left = Math.random() * 100 + '%';
emoji.style.animationDuration = (2 + Math.random() * 2) + 's';
document.body.appendChild(emoji);
setTimeout(() => emoji.remove(), 4000);
}, i * 200);
}
}, 1000);
</script>
</body>
</html>

View File

@@ -0,0 +1,642 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Отметки сигналов{% endblock %}
{% block extra_css %}
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
<style>
.satellite-selector {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.nav-tabs .nav-link.active {
background-color: #0d6efd;
color: white;
border-color: #0d6efd;
}
/* Стили для ячеек истории */
.mark-cell {
text-align: center;
padding: 4px 6px;
font-size: 0.8rem;
min-width: 70px;
}
.mark-present {
background-color: #d4edda !important;
color: #155724;
}
.mark-absent {
background-color: #f8d7da !important;
color: #721c24;
}
.mark-empty {
background-color: #f8f9fa;
color: #adb5bd;
}
.mark-user {
font-size: 0.7rem;
color: #6c757d;
display: block;
}
/* Стили для кнопок отметок */
.mark-btn-group {
display: flex;
gap: 4px;
justify-content: center;
}
.mark-btn {
padding: 2px 10px;
font-size: 0.8rem;
border-radius: 4px;
cursor: pointer;
border: 2px solid transparent;
}
.mark-btn-yes {
background-color: #e8f5e9;
color: #2e7d32;
border-color: #a5d6a7;
}
.mark-btn-yes.selected {
background-color: #4caf50;
color: white;
}
.mark-btn-no {
background-color: #ffebee;
color: #c62828;
border-color: #ef9a9a;
}
.mark-btn-no.selected {
background-color: #f44336;
color: white;
}
.filter-panel {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 0.375rem;
padding: 1rem;
margin-bottom: 1rem;
}
/* Таблица истории */
.history-table {
font-size: 0.85rem;
}
.history-table th {
position: sticky;
top: 0;
background: #343a40;
color: white;
font-weight: 500;
white-space: nowrap;
padding: 6px 8px;
font-size: 0.75rem;
}
.history-table td {
padding: 4px 6px;
vertical-align: middle;
}
.history-table .name-col {
position: sticky;
left: 0;
background: #f8f9fa;
min-width: 250px;
white-space: normal;
word-break: break-word;
}
.history-table thead .name-col {
background: #343a40;
z-index: 10;
}
.history-wrapper {
max-height: 65vh;
overflow: auto;
}
</style>
{% endblock %}
{% block content %}
<div class="{% if full_width_page %}container-fluid{% else %}container{% endif %} px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Отметки сигналов</h2>
</div>
</div>
<!-- Satellite Selector -->
<div class="row mb-3">
<div class="col-12">
<div class="satellite-selector">
<h5> Выберите спутник:</h5>
<div class="row">
<div class="col-md-6">
<select id="satellite-select" class="form-select" onchange="selectSatellite()">
<option value="">-- Выберите спутник --</option>
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id == selected_satellite_id %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
{% if selected_satellite %}
<!-- Tabs -->
<ul class="nav nav-tabs" id="marksTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="entry-tab" data-bs-toggle="tab" data-bs-target="#entry-pane"
type="button" role="tab">
<i class="bi bi-pencil-square"></i> Проставить отметки
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history-pane"
type="button" role="tab">
<i class="bi bi-clock-history"></i> История отметок
</button>
</li>
</ul>
<div class="tab-content" id="marksTabsContent">
<!-- Entry Tab -->
<div class="tab-pane fade show active" id="entry-pane" role="tabpanel">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<input type="text" id="entry-search" class="form-control form-control-sm"
placeholder="Поиск по имени..." style="width: 200px;">
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-primary btn-sm" onclick="openCreateModal()">
<i class="bi bi-plus-lg"></i> Создать теханализ
</button>
<button class="btn btn-success" id="save-marks-btn" onclick="saveMarks()" disabled>
<i class="bi bi-check-lg"></i> Сохранить
<span class="badge bg-light text-dark" id="marks-count">0</span>
</button>
</div>
</div>
<div class="card-body p-0">
<div id="entry-table"></div>
</div>
</div>
</div>
<!-- History Tab -->
<div class="tab-pane fade" id="history-pane" role="tabpanel">
<div class="filter-panel">
<div class="row align-items-end">
<div class="col-md-2">
<label class="form-label">Дата от:</label>
<input type="date" id="history-date-from" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label">Дата до:</label>
<input type="date" id="history-date-to" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label">Показывать:</label>
<select id="history-page-size" class="form-select form-select-sm">
<option value="0" selected>Все</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Поиск по имени:</label>
<input type="text" id="history-search" class="form-control form-control-sm"
placeholder="Введите имя..." oninput="filterHistoryTable()">
</div>
<div class="col-md-3">
<button class="btn btn-primary btn-sm" onclick="loadHistory()">
Показать
</button>
<button class="btn btn-secondary btn-sm" onclick="resetHistoryFilters()">
Сбросить
</button>
</div>
</div>
</div>
<div class="card">
<div class="card-body p-0">
<div class="history-wrapper" id="history-container">
<div class="text-center p-4 text-muted">
Нажмите "Показать" для загрузки данных
</div>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="alert alert-info text-center">
<h5> Пожалуйста, выберите спутник</h5>
</div>
{% endif %}
</div>
<!-- Modal for creating TechAnalyze -->
<div class="modal fade" id="createTechAnalyzeModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-plus-circle"></i> Создать теханализ</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="create-tech-analyze-form">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Имя <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="ta-name" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Частота, МГц</label>
<input type="number" step="0.001" class="form-control" id="ta-frequency">
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Полоса, МГц</label>
<input type="number" step="0.001" class="form-control" id="ta-freq-range">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Сим. скорость</label>
<input type="number" class="form-control" id="ta-bod-velocity">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Поляризация</label>
<select class="form-select" id="ta-polarization">
<option value="">-- Выберите --</option>
{% for p in polarizations %}
<option value="{{ p.name }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Модуляция</label>
<select class="form-select" id="ta-modulation">
<option value="">-- Выберите --</option>
{% for m in modulations %}
<option value="{{ m.name }}">{{ m.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Стандарт</label>
<select class="form-select" id="ta-standard">
<option value="">-- Выберите --</option>
{% for s in standards %}
<option value="{{ s.name }}">{{ s.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="ta-add-mark" checked>
<label class="form-check-label" for="ta-add-mark">
Сразу добавить отметку "Есть сигнал"
</label>
</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="createTechAnalyze()">Создать</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
<script>
const SATELLITE_ID = {% if selected_satellite_id %}{{ selected_satellite_id }}{% else %}null{% endif %};
const CSRF_TOKEN = '{{ csrf_token }}';
let entryTable = null;
let pendingMarks = {};
function selectSatellite() {
const select = document.getElementById('satellite-select');
if (select.value) {
window.location.search = `satellite_id=${select.value}`;
} else {
window.location.search = '';
}
}
// Entry table
function initEntryTable() {
if (!SATELLITE_ID) return;
entryTable = new Tabulator("#entry-table", {
ajaxURL: "{% url 'mainapp:signal_marks_entry_api' %}",
ajaxParams: { satellite_id: SATELLITE_ID },
pagination: true,
paginationMode: "remote",
paginationSize: 100,
paginationSizeSelector: [50, 100, 200, 500, true],
layout: "fitColumns",
height: "65vh",
placeholder: "Нет данных",
columns: [
{title: "ID", field: "id", width: 60},
{title: "Имя", field: "name", width: 500},
{title: "Частота", field: "frequency", width: 120, hozAlign: "right",
formatter: c => c.getValue() ? c.getValue().toFixed(3) : '-'},
{title: "Полоса", field: "freq_range", width: 120, hozAlign: "right",
formatter: c => c.getValue() ? c.getValue().toFixed(3) : '-'},
{title: "Сим.v", field: "bod_velocity", width: 120, hozAlign: "right",
formatter: c => c.getValue() ? Math.round(c.getValue()) : '-'},
{title: "Пол.", field: "polarization", width: 105, hozAlign: "center"},
{title: "Мод.", field: "modulation", width: 95, hozAlign: "center"},
{title: "Станд.", field: "standard", width: 125},
{title: "Посл. отметка", field: "last_mark", width: 190,
formatter: function(c) {
const d = c.getValue();
if (!d) return '<span class="text-muted">—</span>';
const icon = d.mark ? '✓' : '✗';
const cls = d.mark ? 'text-success' : 'text-danger';
return `<span class="${cls}">${icon}</span> ${d.timestamp}`;
}
},
{title: "Отметка", field: "id", width: 100, hozAlign: "center", headerSort: false,
formatter: function(c) {
const row = c.getRow().getData();
const id = row.id;
if (!row.can_add_mark) return '<span class="text-muted small">5 мин</span>';
const yesS = pendingMarks[id] === true ? 'selected' : '';
const noS = pendingMarks[id] === false ? 'selected' : '';
return `<div class="mark-btn-group">
<button type="button" class="mark-btn mark-btn-yes ${yesS}" data-id="${id}" data-val="true">✓</button>
<button type="button" class="mark-btn mark-btn-no ${noS}" data-id="${id}" data-val="false">✗</button>
</div>`;
}
},
],
});
// Делегирование событий для кнопок отметок - без перерисовки таблицы
document.getElementById('entry-table').addEventListener('click', function(e) {
const btn = e.target.closest('.mark-btn');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const id = parseInt(btn.dataset.id);
const val = btn.dataset.val === 'true';
// Переключаем отметку
if (pendingMarks[id] === val) {
delete pendingMarks[id];
} else {
pendingMarks[id] = val;
}
// Обновляем только кнопки в этой строке
const container = btn.closest('.mark-btn-group');
if (container) {
const yesBtn = container.querySelector('.mark-btn-yes');
const noBtn = container.querySelector('.mark-btn-no');
yesBtn.classList.toggle('selected', pendingMarks[id] === true);
noBtn.classList.toggle('selected', pendingMarks[id] === false);
}
updateMarksCount();
});
}
function updateMarksCount() {
const count = Object.keys(pendingMarks).length;
document.getElementById('marks-count').textContent = count;
document.getElementById('save-marks-btn').disabled = count === 0;
}
function saveMarks() {
const marks = Object.entries(pendingMarks).map(([id, mark]) => ({
tech_analyze_id: parseInt(id), mark: mark
}));
if (!marks.length) return;
const btn = document.getElementById('save-marks-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Сохранение...';
fetch("{% url 'mainapp:save_signal_marks' %}", {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN},
body: JSON.stringify({ marks })
})
.then(r => r.json())
.then(data => {
// Восстанавливаем кнопку сначала
btn.innerHTML = '<i class="bi bi-check-lg"></i> Сохранить <span class="badge bg-light text-dark" id="marks-count">0</span>';
if (data.success) {
pendingMarks = {};
updateMarksCount();
// Перезагружаем данные таблицы
if (entryTable) {
entryTable.setData("{% url 'mainapp:signal_marks_entry_api' %}", { satellite_id: SATELLITE_ID });
}
alert(`Сохранено: ${data.created}` + (data.skipped ? `, пропущено: ${data.skipped}` : ''));
} else {
updateMarksCount();
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(e => {
console.error('Save error:', e);
btn.innerHTML = '<i class="bi bi-check-lg"></i> Сохранить <span class="badge bg-light text-dark" id="marks-count">0</span>';
updateMarksCount();
alert('Ошибка сохранения: ' + e.message);
});
}
// History
function loadHistory() {
const dateFrom = document.getElementById('history-date-from').value;
const dateTo = document.getElementById('history-date-to').value;
const pageSize = document.getElementById('history-page-size').value;
const container = document.getElementById('history-container');
container.innerHTML = '<div class="text-center p-4"><span class="spinner-border"></span></div>';
let url = `{% url 'mainapp:signal_marks_history_api' %}?satellite_id=${SATELLITE_ID}`;
if (dateFrom) url += `&date_from=${dateFrom}`;
if (dateTo) url += `&date_to=${dateTo}`;
// size=0 означает "все записи"
url += `&size=${pageSize || 0}`;
fetch(url)
.then(r => r.json())
.then(data => {
if (data.error) {
container.innerHTML = `<div class="alert alert-danger m-3">${data.error}</div>`;
return;
}
if (data.message) {
container.innerHTML = `<div class="alert alert-info m-3">${data.message}</div>`;
return;
}
// Build HTML table
let html = '<table class="table table-bordered table-sm history-table mb-0">';
html += '<thead><tr>';
html += '<th class="name-col">Имя</th>';
for (const period of data.periods) {
html += `<th class="mark-cell">${period}</th>`;
}
html += '</tr></thead><tbody>';
for (const row of data.data) {
html += '<tr>';
html += `<td class="name-col">${row.name}</td>`;
for (const mark of row.marks) {
if (mark) {
const cls = mark.mark ? 'mark-present' : 'mark-absent';
const icon = mark.mark ? '✓' : '✗';
html += `<td class="mark-cell ${cls}">
<strong>${icon}</strong>
<span class="mark-user">${mark.user}</span>
<span class="mark-user">${mark.time}</span>
</td>`;
} else {
html += '<td class="mark-cell mark-empty">—</td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
})
.catch(e => {
container.innerHTML = '<div class="alert alert-danger m-3">Ошибка загрузки</div>';
});
}
function resetHistoryFilters() {
document.getElementById('history-date-from').value = '';
document.getElementById('history-date-to').value = '';
document.getElementById('history-page-size').value = '0';
document.getElementById('history-search').value = '';
loadHistory();
}
function filterHistoryTable() {
const searchValue = document.getElementById('history-search').value.toLowerCase().trim();
const table = document.querySelector('.history-table');
if (!table) return;
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const nameCell = row.querySelector('.name-col');
if (nameCell) {
const name = nameCell.textContent.toLowerCase();
row.style.display = name.includes(searchValue) ? '' : 'none';
}
});
}
// Init
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('entry-search');
if (searchInput) {
let timeout;
searchInput.addEventListener('input', function() {
clearTimeout(timeout);
timeout = setTimeout(() => {
if (entryTable) {
entryTable.setData("{% url 'mainapp:signal_marks_entry_api' %}", {
satellite_id: SATELLITE_ID, search: this.value
});
}
}, 300);
});
}
initEntryTable();
document.getElementById('history-tab').addEventListener('shown.bs.tab', function() {
loadHistory();
});
});
// Modal
function openCreateModal() {
document.getElementById('create-tech-analyze-form').reset();
document.getElementById('ta-add-mark').checked = true;
new bootstrap.Modal(document.getElementById('createTechAnalyzeModal')).show();
}
function createTechAnalyze() {
const name = document.getElementById('ta-name').value.trim();
if (!name) { alert('Укажите имя'); return; }
fetch("{% url 'mainapp:create_tech_analyze_for_marks' %}", {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRFToken': CSRF_TOKEN},
body: JSON.stringify({
satellite_id: SATELLITE_ID,
name: name,
frequency: document.getElementById('ta-frequency').value,
freq_range: document.getElementById('ta-freq-range').value,
bod_velocity: document.getElementById('ta-bod-velocity').value,
polarization: document.getElementById('ta-polarization').value,
modulation: document.getElementById('ta-modulation').value,
standard: document.getElementById('ta-standard').value,
})
})
.then(r => r.json())
.then(result => {
if (result.success) {
bootstrap.Modal.getInstance(document.getElementById('createTechAnalyzeModal')).hide();
if (document.getElementById('ta-add-mark').checked) {
pendingMarks[result.tech_analyze.id] = true;
updateMarksCount();
}
entryTable.setData();
} else {
alert('Ошибка: ' + (result.error || 'Неизвестная ошибка'));
}
})
.catch(e => alert('Ошибка создания'));
}
</script>
{% endblock %}

View File

@@ -86,7 +86,7 @@
attribution: 'Tiles &copy; Esri' attribution: 'Tiles &copy; Esri'
}); });
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', { const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}); });

View File

@@ -67,7 +67,7 @@
<input type="hidden" name="ids" value="{{ ids }}"> <input type="hidden" name="ids" value="{{ ids }}">
<div class="d-flex justify-content-between mt-4"> <div class="d-flex justify-content-between mt-4">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary"> <a href="{% url 'mainapp:source_list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Отмена <i class="bi bi-arrow-left"></i> Отмена
</a> </a>
<button type="submit" class="btn btn-danger" id="confirmDeleteBtn"> <button type="submit" class="btn btn-danger" id="confirmDeleteBtn">
@@ -119,7 +119,7 @@ document.getElementById('deleteForm').addEventListener('submit', function(e) {
.then(data => { .then(data => {
if (data.success) { if (data.success) {
alert(data.message); alert(data.message);
window.location.href = '{% url "mainapp:home" %}'; window.location.href = '{% url "mainapp:source_list" %}';
} else { } else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка')); alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
btn.disabled = false; btn.disabled = false;

View File

@@ -129,14 +129,16 @@
<div class="container-fluid px-3"> <div class="container-fluid px-3">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-12 d-flex justify-content-between align-items-center"> <div class="col-12 d-flex justify-content-between align-items-center">
<h2>Редактировать объект #{{ object.id }}</h2> <h2>{% if object %}Редактировать объект #{{ object.id }}{% else %}Создать новый источник{% endif %}</h2>
<div> <div>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %} {% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="submit" form="source-form" class="btn btn-primary btn-action">Сохранить</button> <button type="submit" form="source-form" class="btn btn-primary btn-action">Сохранить</button>
{% if object %}
<a href="{% url 'mainapp:source_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" <a href="{% url 'mainapp:source_delete' object.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-danger btn-action">Удалить</a> class="btn btn-danger btn-action">Удалить</a>
{% endif %} {% endif %}
<a href="{% url 'mainapp:home' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" {% endif %}
<a href="{% url 'mainapp:source_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}"
class="btn btn-secondary btn-action">Назад</a> class="btn btn-secondary btn-action">Назад</a>
</div> </div>
</div> </div>
@@ -193,6 +195,59 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Дата подтверждения:</label>
<div class="readonly-field">
{% if object.confirm_at %}{{ object.confirm_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}
</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.last_signal_at %}{{ object.last_signal_at|date:"d.m.Y H:i" }}{% else %}-{% endif %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="id_info" class="form-label">{{ form.info.label }}:</label>
{{ form.info }}
{% if form.info.errors %}
<div class="invalid-feedback d-block">
{{ form.info.errors }}
</div>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="id_ownership" class="form-label">{{ form.ownership.label }}:</label>
{{ form.ownership }}
{% if form.ownership.errors %}
<div class="invalid-feedback d-block">
{{ form.ownership.errors }}
</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="mb-3">
<label for="id_ownership" class="form-label">{{ form.note.label }}:</label>
{{ form.note }}
{% if form.note.errors %}
<div class="invalid-feedback d-block">
{{ form.note.errors }}
</div>
{% endif %}
</div>
</div> </div>
</div> </div>
@@ -214,7 +269,7 @@
<!-- Координаты ГЛ --> <!-- Координаты ГЛ -->
<div class="coord-group"> <div class="coord-group">
<div class="coord-group-header">Координаты ГЛ (усреднённые)</div> <div class="coord-group-header">Координаты ГЛ</div>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
@@ -289,6 +344,7 @@
</div> </div>
</div> </div>
{% if object %}
<!-- Привязанные объекты --> <!-- Привязанные объекты -->
<div class="form-section"> <div class="form-section">
<div class="form-section-header"> <div class="form-section-header">
@@ -374,6 +430,7 @@
<p class="text-muted">Нет привязанных объектов</p> <p class="text-muted">Нет привязанных объектов</p>
{% endif %} {% endif %}
</div> </div>
{% endif %}
</form> </form>
</div> </div>
{% endblock %} {% endblock %}
@@ -568,9 +625,8 @@
document.getElementById('save-btn').disabled = false; document.getElementById('save-btn').disabled = false;
document.getElementById('cancel-btn').disabled = false; document.getElementById('cancel-btn').disabled = false;
// Включаем drag для всех маркеров Object.entries(markers).forEach(([key, m]) => {
Object.values(markers).forEach(m => { if (key !== 'average' && m.marker.options.opacity !== 0) {
if (m.marker.options.opacity !== 0) {
m.marker.enableEditing(); m.marker.enableEditing();
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -75,7 +75,7 @@
attribution: 'Tiles &copy; Esri' attribution: 'Tiles &copy; Esri'
}); });
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', { const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}); });
@@ -182,8 +182,50 @@
`; `;
{% endfor %} {% endfor %}
{% if polygon_coords %}
div.innerHTML += `
<div class="legend-item" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #ddd;">
<div style="width: 18px; height: 18px; margin-right: 6px; background-color: rgba(51, 136, 255, 0.2); border: 2px solid #3388ff;"></div>
<span>Область фильтра</span>
</div>
`;
{% endif %}
return div; return div;
}; };
legend.addTo(map); legend.addTo(map);
// Добавляем полигон фильтра на карту, если он есть
{% if polygon_coords %}
try {
const polygonCoords = {{ polygon_coords|safe }};
if (polygonCoords && polygonCoords.length > 0) {
// Преобразуем координаты из [lng, lat] в [lat, lng] для Leaflet
const latLngs = polygonCoords.map(coord => [coord[1], coord[0]]);
// Создаем полигон
const filterPolygon = L.polygon(latLngs, {
color: '#3388ff',
fillColor: '#3388ff',
fillOpacity: 0.2,
weight: 2,
dashArray: '5, 5'
});
// Добавляем полигон на карту
filterPolygon.addTo(map);
// Добавляем popup с информацией
filterPolygon.bindPopup('<strong>Область фильтра</strong><br>Отображаются только объекты с точками в этой области');
// Если нет других точек, центрируем карту на полигоне
{% if not groups %}
map.fitBounds(filterPolygon.getBounds());
{% endif %}
}
} catch (e) {
console.error('Ошибка при отображении полигона фильтра:', e);
}
{% endif %}
</script> </script>
{% endblock extra_js %} {% endblock extra_js %}

View File

@@ -0,0 +1,159 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Импорт заявок из Excel{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-file-earmark-excel"></i> Импорт заявок из Excel</h5>
</div>
<div class="card-body">
<form id="importForm" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-3">
<label for="file" class="form-label">Выберите Excel файл (.xlsx)</label>
<input type="file" class="form-control" id="file" name="file" accept=".xlsx,.xls" required>
</div>
<div class="alert alert-info">
<h6>Ожидаемые столбцы в файле:</h6>
<ul class="mb-0 small">
<li><strong>Дата постановки задачи</strong> → Дата заявки</li>
<li><strong>Спутник</strong> → Спутник (ищется по NORAD в скобках, например "NSS 12 (36032)")</li>
<li><strong>Дата формирования карточки</strong> → Дата формирования карточки</li>
<li><strong>Дата проведения</strong> → Дата и время планирования</li>
<li><strong>Частота Downlink</strong> → Частота Downlink</li>
<li><strong>Частота Uplink</strong> → Частота Uplink</li>
<li><strong>Перенос</strong> → Перенос</li>
<li><strong>Координаты ГСО</strong> → Координаты ГСО (формат: "широта. долгота")</li>
<li><strong>Район</strong> → Район</li>
<li><strong>Результат ГСО</strong> → Если "Успешно", то ГСО успешно = Да, иначе Нет + в комментарий</li>
<li><strong>Результат кубсата</strong><span class="text-danger">Красная ячейка</span> = Кубсат неуспешно, иначе успешно. Значение добавляется в комментарий</li>
<li><strong>Координаты источника</strong> → Координаты источника</li>
<li><strong>Координаты объекта</strong> → Координаты объекта (формат: "26.223, 33.969" или пусто)</li>
</ul>
<hr>
<h6>Проверка дубликатов:</h6>
<p class="mb-0 small">Строки пропускаются, если уже существует заявка с такой же комбинацией: спутник + downlink + uplink + перенос + координаты ГСО + дата проведения</p>
<hr>
<h6>Логика определения статуса:</h6>
<ul class="mb-0 small">
<li>Если есть <strong>координаты источника</strong> → статус "Результат получен"</li>
<li>Если нет координат источника, но <strong>ГСО успешно</strong> → статус "Успешно"</li>
<li>Если нет координат источника и <strong>ГСО неуспешно</strong> → статус "Неуспешно"</li>
<li>Иначе → статус "Запланировано"</li>
</ul>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="bi bi-upload"></i> Загрузить
</button>
<a href="{% url 'mainapp:kubsat' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Назад
</a>
</form>
<!-- Результаты импорта -->
<div id="results" class="mt-4" style="display: none;">
<h6>Результаты импорта:</h6>
<div id="resultsContent"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('importForm').addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitBtn');
const resultsDiv = document.getElementById('results');
const resultsContent = document.getElementById('resultsContent');
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Загрузка...';
resultsDiv.style.display = 'none';
const formData = new FormData(this);
try {
const response = await fetch('{% url "mainapp:source_request_import" %}', {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
});
const data = await response.json();
resultsDiv.style.display = 'block';
if (data.success) {
let html = `
<div class="alert alert-success">
<strong>Успешно!</strong> Создано заявок: ${data.created}
${data.skipped > 0 ? `, пропущено: ${data.skipped}` : ''}
</div>
`;
if (data.headers && data.headers.length > 0) {
html += `
<div class="alert alert-secondary">
<strong>Найденные заголовки:</strong> ${data.headers.join(', ')}
</div>
`;
}
if (data.skipped_rows && data.skipped_rows.length > 0) {
html += `
<div class="alert alert-info">
<strong>Пропущенные строки (дубликаты):</strong>
<ul class="mb-0 small">
${data.skipped_rows.map(e => `<li>${e}</li>`).join('')}
</ul>
${data.skipped > 20 ? '<p class="mb-0 mt-2"><em>Показаны первые 20 пропущенных</em></p>' : ''}
</div>
`;
}
if (data.errors && data.errors.length > 0) {
html += `
<div class="alert alert-warning">
<strong>Ошибки (${data.total_errors}):</strong>
<ul class="mb-0 small">
${data.errors.map(e => `<li>${e}</li>`).join('')}
</ul>
${data.total_errors > 20 ? '<p class="mb-0 mt-2"><em>Показаны первые 20 ошибок</em></p>' : ''}
</div>
`;
}
resultsContent.innerHTML = html;
} else {
resultsContent.innerHTML = `
<div class="alert alert-danger">
<strong>Ошибка:</strong> ${data.error}
</div>
`;
}
} catch (error) {
resultsDiv.style.display = 'block';
resultsContent.innerHTML = `
<div class="alert alert-danger">
<strong>Ошибка:</strong> ${error.message}
</div>
`;
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-upload"></i> Загрузить';
}
});
</script>
{% endblock %}

View File

@@ -7,6 +7,7 @@
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet"> <link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.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"> <link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-playback@1.0.2/dist/LeafletPlayback.css" /> -->
<style> <style>
body { body {
overflow: hidden; overflow: hidden;
@@ -57,11 +58,112 @@
font-size: 11px; font-size: 11px;
margin-bottom: 4px; margin-bottom: 4px;
} }
.playback-control {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: white;
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
display: flex;
align-items: center;
gap: 15px;
}
.playback-control button {
padding: 6px 12px;
border: none;
border-radius: 4px;
background: #007bff;
color: white;
cursor: pointer;
font-size: 13px;
min-width: auto;
}
.playback-control button:hover {
background: #0056b3;
}
.playback-control button:disabled {
background: #ccc;
cursor: not-allowed;
}
.playback-control .time-display {
font-size: 13px;
font-weight: bold;
min-width: 160px;
text-align: center;
}
.playback-control input[type="range"] {
width: 250px;
}
.playback-control .speed-control {
display: flex;
align-items: center;
gap: 6px;
}
.playback-control .speed-control label {
font-size: 11px;
margin: 0;
}
.playback-control .speed-control select {
padding: 3px 6px;
border-radius: 4px;
border: 1px solid #ccc;
font-size: 12px;
}
.playback-control .visibility-controls {
display: flex;
flex-direction: column;
gap: 4px;
border-left: 1px solid #ddd;
padding-left: 15px;
margin-left: 10px;
}
.playback-control .visibility-controls label {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
margin: 0;
cursor: pointer;
}
.playback-control .visibility-controls input[type="checkbox"] {
cursor: pointer;
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div id="map"></div> <div id="map"></div>
<div class="playback-control" id="playbackControl" style="display: none;">
<button id="playBtn"></button>
<button id="pauseBtn" disabled></button>
<button id="resetBtn"></button>
<input type="range" id="timeSlider" min="0" max="100" value="0" step="1">
<div class="time-display" id="timeDisplay">--</div>
<div class="speed-control">
<label for="speedSelect">Скорость:</label>
<select id="speedSelect">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="5">5x</option>
<option value="10">10x</option>
</select>
</div>
<div class="visibility-controls">
<label>
<input type="checkbox" id="showPointsCheckbox" checked>
Точки
</label>
<label>
<input type="checkbox" id="showTrackCheckbox" checked>
Трек
</label>
</div>
</div>
{% endblock content %} {% endblock content %}
{% block extra_js %} {% block extra_js %}
@@ -69,6 +171,8 @@
<script src="{% static 'leaflet/leaflet.js' %}"></script> <script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script> <script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script> <script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
<script src="{% static 'leaflet-polylineDecorator/leaflet.polylineDecorator.js' %}"></script>
<script src="{% static 'leaflet-playback/leaflet-playback.js' %}"></script>
<script> <script>
// Инициализация карты // Инициализация карты
@@ -89,7 +193,7 @@
attribution: 'Tiles &copy; Esri' attribution: 'Tiles &copy; Esri'
}); });
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', { const street_local = L.tileLayer('/tiles/styles/basic-preview/512/{z}/{x}/{y}.png', {
maxZoom: 19, maxZoom: 19,
attribution: 'Local Tiles' attribution: 'Local Tiles'
}); });
@@ -119,6 +223,28 @@
var sourceOverlays = []; var sourceOverlays = [];
var glPointLayers = []; var glPointLayers = [];
var glPointCoordinates = [];
var glPointsData = [];
var trackLayer = L.layerGroup(); // Layer group for track and related elements
var glPointsGroupLayer = L.layerGroup(); // Layer group specifically for GL points
// Сначала собираем все данные о точках ГЛ с временными метками
{% for group in groups %}
{% for point_data in group.points %}
{% if not point_data.source_id %}
glPointsData.push({
name: "{{ point_data.name|escapejs }}",
frequency: "{{ point_data.frequency|escapejs }}",
coords: [{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}],
timestamp: {% if point_data.timestamp %}"{{ point_data.timestamp|escapejs }}"{% else %}null{% endif %}
});
{% endif %}
{% endfor %}
{% endfor %}
// Фильтруем точки с временными метками и сортируем
var glPointsWithTime = glPointsData.filter(p => p.timestamp !== null);
glPointsWithTime.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
// Создаём слои для координат объекта и точек ГЛ // Создаём слои для координат объекта и точек ГЛ
{% for group in groups %} {% for group in groups %}
@@ -138,14 +264,31 @@
{% else %} {% else %}
// Это точка ГЛ // Это точка ГЛ
var pointName = "{{ point_data.name|escapejs }}"; var pointName = "{{ point_data.name|escapejs }}";
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], { var pointCoords = [{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}];
icon: groupIcon var pointNumber = {{ forloop.counter }};
}).bindPopup(pointName + '<br>' + "{{ point_data.frequency|escapejs }}");
// Определяем цвет маркера: первый - зеленый, последний - оранжевый, остальные - обычный
var markerIcon;
if (pointNumber === 1) {
markerIcon = getColorIcon('green');
} else if (pointNumber === glPointsData.length) {
markerIcon = getColorIcon('orange');
} else {
markerIcon = groupIcon;
}
var marker = L.marker(pointCoords, {
icon: markerIcon
}).bindPopup(pointNumber + '. ' + pointName + '<br>' + "{{ point_data.frequency|escapejs }}");
groupLayer.addLayer(marker); groupLayer.addLayer(marker);
glPointsGroupLayer.addLayer(marker); // Also add to GL points group layer
// Сохраняем координаты для построения трека
glPointCoordinates.push(pointCoords);
// Добавляем каждую точку ГЛ отдельно в список // Добавляем каждую точку ГЛ отдельно в список
glPointLayers.push({ glPointLayers.push({
label: "{{ forloop.counter }} - {{ point_data.name|escapejs }} ({{ point_data.frequency|escapejs }})", label: pointNumber + " - {{ point_data.name|escapejs }} ({{ point_data.frequency|escapejs }})",
layer: marker layer: marker
}); });
{% endif %} {% endif %}
@@ -158,11 +301,79 @@
layer: groupLayer layer: groupLayer
}); });
{% endif %} {% endif %}
// НЕ добавляем слой группы на карту при загрузке - будет управляться через layer control
{% if group.color in 'blue,orange,green,violet' %}
// groupLayer.addTo(map); // Закомментировано - слои скрыты по умолчанию
{% endif %}
{% endfor %} {% endfor %}
// Создаём трек между точками ГЛ с номерами на сегментах
if (glPointCoordinates.length > 1) {
// Создаём отдельные сегменты линий между точками
for (var i = 0; i < glPointCoordinates.length - 1; i++) {
var segmentNumber = i + 1;
// Создаем линию с стрелкой
var segment = L.polyline([glPointCoordinates[i], glPointCoordinates[i + 1]], {
color: 'blue',
weight: 3,
opacity: 0.7
});
trackLayer.addLayer(segment);
// Добавляем стрелку через декоратор
setTimeout(function(seg, segLayer, num) {
return function() {
var decorator = L.polylineDecorator(seg, {
patterns: [
{
offset: '65%',
repeat: 0,
symbol: L.Symbol.arrowHead({
pixelSize: 15,
polygon: false,
pathOptions: {
stroke: true,
color: 'blue',
weight: 3,
opacity: 0.7
}
})
}
]
});
segLayer.addLayer(decorator);
};
}(segment, trackLayer, segmentNumber), 100);
// Вычисляем центр сегмента для размещения номера
var midLat = (glPointCoordinates[i][0] + glPointCoordinates[i + 1][0]) / 2;
var midLng = (glPointCoordinates[i][1] + glPointCoordinates[i + 1][1]) / 2;
// Добавляем номер сегмента
var segmentIcon = L.divIcon({
className: 'segment-label',
html: '<div style="background: rgba(0, 0, 255, 0.9); color: white; border: 2px solid white; border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 14px; box-shadow: 0 2px 4px rgba(0,0,0,0.4);">' + segmentNumber + '</div>',
iconSize: [28, 28],
iconAnchor: [14, 14]
});
var segmentMarker = L.marker([midLat, midLng], { icon: segmentIcon });
trackLayer.addLayer(segmentMarker);
}
// НЕ добавляем слой трека на карту при загрузке - будет управляться через playback
// trackLayer.addTo(map);
}
// НЕ добавляем GL точки на карту при загрузке - будет управляться через playback
// if (glPointLayers.length > 0) {
// glPointsGroupLayer.addTo(map);
// }
// Создаём иерархию // Создаём иерархию
var treeOverlays = []; var treeOverlays = [];
if (sourceOverlays.length > 0) { if (sourceOverlays.length > 0) {
treeOverlays.push({ treeOverlays.push({
label: "Координаты объекта #{{ source_id }}", label: "Координаты объекта #{{ source_id }}",
@@ -171,13 +382,21 @@
layer: L.layerGroup() layer: L.layerGroup()
}); });
} }
if (glPointLayers.length > 0) { if (glPointLayers.length > 0) {
treeOverlays.push({ treeOverlays.push({
label: "Точки ГЛ", label: "Точки ГЛ",
selectAllCheckbox: true, selectAllCheckbox: true,
children: glPointLayers, children: glPointLayers,
layer: L.layerGroup() layer: glPointsGroupLayer
});
}
// Добавляем слой трека в контрол
if (glPointCoordinates.length > 1) {
treeOverlays.push({
label: "Трек между точками ГЛ",
layer: trackLayer
}); });
} }
@@ -224,7 +443,7 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
// Точки ГЛ (все одним цветом) // Точки ГЛ
{% for group in groups %} {% for group in groups %}
{% if group.color not in 'blue,orange,green,violet' %} {% if group.color not in 'blue,orange,green,violet' %}
div.innerHTML += '<div class="legend-section"><div class="legend-section-title">Точки ГЛ:</div></div>'; div.innerHTML += '<div class="legend-section"><div class="legend-section-title">Точки ГЛ:</div></div>';
@@ -236,9 +455,273 @@
`; `;
{% endif %} {% endif %}
{% endfor %} {% endfor %}
// Добавляем информацию о треке
if (glPointCoordinates.length > 1) {
div.innerHTML += '<div class="legend-section"><div class="legend-section-title">Трек между точками:</div></div>';
div.innerHTML += `
<div class="legend-item">
<div style="width: 18px; height: 3px; background: blue; margin-right: 6px;"></div>
<span>Соединительная линия</span>
</div>
`;
}
// Добавляем информацию о специальных точках
if (glPointCoordinates.length > 1) {
div.innerHTML += '<div class="legend-section"><div class="legend-section-title">Специальные точки:</div></div>';
div.innerHTML += `
<div class="legend-item">
<div class="legend-marker" style="background-image: url('{% static "leaflet-markers/img/marker-icon-green.png" %}');"></div>
<span>Первая точка</span>
</div>
<div class="legend-item">
<div class="legend-marker" style="background-image: url('{% static "leaflet-markers/img/marker-icon-orange.png" %}');"></div>
<span>Последняя точка</span>
</div>
`;
}
// Добавляем информацию о playback (если доступен)
if (glPointsWithTime && glPointsWithTime.length > 1) {
div.innerHTML += '<div class="legend-section"><div class="legend-section-title">Режим воспроизведения:</div></div>';
div.innerHTML += `
<div class="legend-item">
<div class="legend-marker" style="background-image: url('{% static "leaflet-markers/img/marker-icon-grey.png" %}');"></div>
<span>Пройденные точки</span>
</div>
<div class="legend-item">
<div style="width: 24px; height: 24px; margin-right: 6px; background: rgba(0, 0, 255, 0.9); color: white; border: 2px solid white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 11px;">1</div>
<span>Номер сегмента трека</span>
</div>
<div class="legend-item">
<div style="width: 18px; height: 18px; margin-right: 6px; background: #007bff; border-radius: 3px; display: flex; align-items: center; justify-content: center; color: white; font-size: 10px;">▶</div>
<span>Используйте панель внизу</span>
</div>
`;
}
return div; return div;
}; };
legend.addTo(map); legend.addTo(map);
// ============ PLAYBACK FUNCTIONALITY ============
var playbackData = null;
var currentPlaybackIndex = 0;
var playbackInterval = null;
var playbackMarkers = [];
var playbackPolyline = null;
var playbackSpeed = 1000; // milliseconds per step
// Показываем контроллер только если есть точки с временными метками
if (glPointsWithTime.length > 1) {
document.getElementById('playbackControl').style.display = 'flex';
// Инициализация слайдера
var timeSlider = document.getElementById('timeSlider');
timeSlider.max = glPointsWithTime.length - 1;
// Функция для форматирования даты
function formatDate(isoString) {
var date = new Date(isoString);
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
// Функция для обновления отображения времени
function updateTimeDisplay(index) {
var timeDisplay = document.getElementById('timeDisplay');
if (index >= 0 && index < glPointsWithTime.length) {
timeDisplay.textContent = formatDate(glPointsWithTime[index].timestamp);
}
}
// Функция для отрисовки точек до указанного индекса
function renderPlaybackState(index) {
// Очищаем предыдущие маркеры
playbackMarkers.forEach(m => map.removeLayer(m));
playbackMarkers = [];
if (playbackPolyline) {
map.removeLayer(playbackPolyline);
}
// Проверяем состояние чекбоксов
var showPoints = document.getElementById('showPointsCheckbox').checked;
var showTrack = document.getElementById('showTrackCheckbox').checked;
// Рисуем все точки до текущего индекса
var coords = [];
for (var i = 0; i <= index; i++) {
var point = glPointsWithTime[i];
coords.push(point.coords);
if (showPoints) {
var markerIcon;
// Первая точка - зеленая, текущая - оранжевая, остальные - серые
if (i === 0) {
markerIcon = getColorIcon('green');
} else if (i === index) {
markerIcon = getColorIcon('orange');
} else {
markerIcon = getColorIcon('grey');
}
var marker = L.marker(point.coords, { icon: markerIcon })
.bindPopup((i + 1) + '. ' + point.name + '<br>' + point.frequency + '<br>' + formatDate(point.timestamp));
marker.addTo(map);
playbackMarkers.push(marker);
}
}
// Рисуем линию трека с номерами сегментов
if (showTrack && coords.length > 1) {
// Рисуем отдельные сегменты с номерами
for (var i = 0; i < coords.length - 1; i++) {
var segmentNumber = i + 1;
// Создаем линию сегмента
var segment = L.polyline([coords[i], coords[i + 1]], {
color: 'blue',
weight: 3,
opacity: 0.7
}).addTo(map);
playbackMarkers.push(segment);
// Добавляем стрелку на последний сегмент
if (i === coords.length - 2) {
var decorator = L.polylineDecorator(segment, {
patterns: [{
offset: '65%',
repeat: 0,
symbol: L.Symbol.arrowHead({
pixelSize: 15,
polygon: false,
pathOptions: {
stroke: true,
color: 'blue',
weight: 3,
opacity: 0.7
}
})
}]
}).addTo(map);
playbackMarkers.push(decorator);
}
// Вычисляем центр сегмента для размещения номера
var midLat = (coords[i][0] + coords[i + 1][0]) / 2;
var midLng = (coords[i][1] + coords[i + 1][1]) / 2;
// Добавляем номер сегмента
var segmentIcon = L.divIcon({
className: 'segment-label',
html: '<div style="background: rgba(0, 0, 255, 0.9); color: white; border: 2px solid white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.4);">' + segmentNumber + '</div>',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
var segmentMarker = L.marker([midLat, midLng], { icon: segmentIcon });
segmentMarker.addTo(map);
playbackMarkers.push(segmentMarker);
}
}
updateTimeDisplay(index);
timeSlider.value = index;
}
// Кнопка воспроизведения
document.getElementById('playBtn').addEventListener('click', function() {
if (playbackInterval) return;
this.disabled = true;
document.getElementById('pauseBtn').disabled = false;
playbackInterval = setInterval(function() {
currentPlaybackIndex++;
if (currentPlaybackIndex >= glPointsWithTime.length) {
// Достигли конца
clearInterval(playbackInterval);
playbackInterval = null;
document.getElementById('playBtn').disabled = false;
document.getElementById('pauseBtn').disabled = true;
currentPlaybackIndex = glPointsWithTime.length - 1;
} else {
renderPlaybackState(currentPlaybackIndex);
}
}, playbackSpeed);
});
// Кнопка паузы
document.getElementById('pauseBtn').addEventListener('click', function() {
if (playbackInterval) {
clearInterval(playbackInterval);
playbackInterval = null;
document.getElementById('playBtn').disabled = false;
this.disabled = true;
}
});
// Кнопка сброса
document.getElementById('resetBtn').addEventListener('click', function() {
if (playbackInterval) {
clearInterval(playbackInterval);
playbackInterval = null;
}
currentPlaybackIndex = 0;
renderPlaybackState(0);
document.getElementById('playBtn').disabled = false;
document.getElementById('pauseBtn').disabled = true;
});
// Слайдер времени
timeSlider.addEventListener('input', function() {
if (playbackInterval) {
clearInterval(playbackInterval);
playbackInterval = null;
document.getElementById('playBtn').disabled = false;
document.getElementById('pauseBtn').disabled = true;
}
currentPlaybackIndex = parseInt(this.value);
renderPlaybackState(currentPlaybackIndex);
});
// Выбор скорости
document.getElementById('speedSelect').addEventListener('change', function() {
var speedMultiplier = parseFloat(this.value);
playbackSpeed = 1000 / speedMultiplier;
// Если воспроизведение активно, перезапускаем с новой скоростью
if (playbackInterval) {
clearInterval(playbackInterval);
document.getElementById('pauseBtn').click();
setTimeout(function() {
document.getElementById('playBtn').click();
}, 100);
}
});
// Обработчики для чекбоксов видимости
document.getElementById('showPointsCheckbox').addEventListener('change', function() {
renderPlaybackState(currentPlaybackIndex);
});
document.getElementById('showTrackCheckbox').addEventListener('change', function() {
renderPlaybackState(currentPlaybackIndex);
});
// Инициализация - показываем первую точку
renderPlaybackState(0);
}
</script> </script>
{% endblock extra_js %} {% endblock extra_js %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,387 @@
{% 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);
}
#tech-analyze-table {
margin-top: 20px;
font-size: 12px;
}
#tech-analyze-table .tabulator-header {
font-size: 12px;
white-space: normal;
word-wrap: break-word;
}
#tech-analyze-table .tabulator-header .tabulator-col {
white-space: normal;
word-wrap: break-word;
height: auto;
min-height: 40px;
}
#tech-analyze-table .tabulator-header .tabulator-col-content {
white-space: normal;
word-wrap: break-word;
padding: 6px 4px;
}
#tech-analyze-table .tabulator-cell {
font-size: 12px;
padding: 6px 4px;
}
.btn-group-custom {
margin-top: 15px;
}
</style>
{% endblock %}
{% block content %}
<!-- Toast Container -->
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 9999;">
<div id="saveToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header">
<strong class="me-auto" id="toastTitle">Уведомление</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Закрыть"></button>
</div>
<div class="toast-body" id="toastBody">
</div>
</div>
</div>
<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">Спутник <span class="text-danger">*</span></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 d-flex align-items-end gap-2">
<a href="{% url 'mainapp:tech_analyze_list' %}" class="btn btn-primary">
<i class="bi bi-list"></i> Список данных
</a>
</div>
<!-- <div class="col-md-8 mb-3">
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i>
<strong>Инструкция:</strong>
<ul class="mb-0 mt-2" style="font-size: 0.9em;">
<li><strong>Порядок столбцов в Excel:</strong> Имя, Частота МГц, Полоса МГц, Сим. скорость БОД, Модуляция, Стандарт, Примечание</li>
<li><strong>Поляризация извлекается автоматически</strong> из имени (например: "Сигнал 11500 МГц L" → "Левая")</li>
<li>Поддерживаемые буквы: L=Левая, R=Правая, H=Горизонтальная, V=Вертикальная</li>
<li>Скопируйте данные из Excel и вставьте в таблицу (Ctrl+V)</li>
<li>Используйте стрелки, Tab, Enter для навигации и редактирования</li>
</ul>
</div>
</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="add-row" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Добавить строку
</button>
<button id="delete-selected" class="btn btn-warning ms-2">
<i class="bi bi-trash"></i> Удалить выбранные
</button>
<button id="save-data" class="btn btn-success ms-2">
<i class="bi bi-save"></i> Сохранить
</button>
<button id="clear-table" class="btn btn-danger ms-2">
<i class="bi bi-x-circle"></i> Очистить таблицу
</button>
</div>
</div>
<div id="tech-analyze-table"></div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize Tabulator
const table = new Tabulator("#tech-analyze-table", {
layout: "fitDataStretch",
height: "500px",
placeholder: "Нет данных. Скопируйте данные из Excel и вставьте в таблицу (Ctrl+V).",
headerWordWrap: true,
clipboard: true,
clipboardPasteAction: "replace",
clipboardPasteParser: function(clipboard) {
// Парсим данные из буфера обмена
const rows = clipboard.split('\n').filter(row => row.trim() !== '');
const data = [];
// Функция для извлечения поляризации из имени
function extractPolarization(name) {
if (!name) return '';
// Маппинг букв на полные названия
const polarizationMap = {
'L': 'Левая',
'R': 'Правая',
'H': 'Горизонтальная',
'V': 'Вертикальная'
};
// Ищем паттерн "МГц X" где X - буква поляризации
const match = name.match(/МГц\s+([LRHV])/i);
if (match) {
const letter = match[1].toUpperCase();
return polarizationMap[letter] || '';
}
// Альтернативный паттерн: просто последняя буква L/R/H/V
const lastChar = name.trim().slice(-1).toUpperCase();
if (polarizationMap[lastChar]) {
return polarizationMap[lastChar];
}
return '';
}
rows.forEach(row => {
// Разделяем по табуляции (стандартный разделитель Excel)
const cells = row.split('\t');
const name = cells[0] || '';
const polarization = extractPolarization(name);
// Создаем объект с правильными полями (новый порядок без поляризации в начале)
const rowData = {
name: name,
frequency: cells[1] || '',
freq_range: cells[2] || '',
bod_velocity: cells[3] || '',
modulation: cells[4] || '',
standard: cells[5] || '',
note: cells[6] || '',
polarization: polarization // Автоматически извлеченная поляризация
};
data.push(rowData);
});
return data;
},
columns: [
{
formatter: "rowSelection",
titleFormatter: "rowSelection",
hozAlign: "center",
headerSort: false,
width: 40,
clipboard: false,
cellClick: function(e, cell) {
cell.getRow().toggleSelect();
}
},
{title: "Имя", field: "name", minWidth: 150, 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: 150, widthGrow: 1.5, editor: "input"},
{title: "Вид модуляции", field: "modulation", minWidth: 120, widthGrow: 1.2, editor: "input"},
{title: "Стандарт", field: "standard", minWidth: 100, widthGrow: 1, editor: "input"},
{title: "Примечание", field: "note", minWidth: 150, widthGrow: 2, editor: "input"},
{title: "Поляризация", field: "polarization", minWidth: 100, widthGrow: 1, 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);
table.on("rowDeleted", updateRowCount);
// Add row button
document.getElementById('add-row').addEventListener('click', function() {
table.addRow({
name: '',
frequency: '',
freq_range: '',
bod_velocity: '',
modulation: '',
standard: '',
note: '',
polarization: ''
});
});
// Helper function to show toast
function showToast(title, message, type = 'info') {
const toastEl = document.getElementById('saveToast');
const toastTitle = document.getElementById('toastTitle');
const toastBody = document.getElementById('toastBody');
const toastHeader = toastEl.querySelector('.toast-header');
// Remove previous background classes
toastHeader.classList.remove('bg-success', 'bg-danger', 'bg-warning', 'text-white');
// Add appropriate background class
if (type === 'success') {
toastHeader.classList.add('bg-success', 'text-white');
} else if (type === 'error') {
toastHeader.classList.add('bg-danger', 'text-white');
} else if (type === 'warning') {
toastHeader.classList.add('bg-warning');
}
toastTitle.textContent = title;
toastBody.innerHTML = message;
const toast = new bootstrap.Toast(toastEl, { delay: 5000 });
toast.show();
}
// Delete selected rows
document.getElementById('delete-selected').addEventListener('click', function() {
const selectedRows = table.getSelectedRows();
if (selectedRows.length === 0) {
showToast('Внимание', 'Выберите строки для удаления', 'warning');
return;
}
if (confirm(`Удалить ${selectedRows.length} строк(и)?`)) {
selectedRows.forEach(row => row.delete());
showToast('Успешно', `Удалено строк: ${selectedRows.length}`, 'success');
}
});
// Save data
document.getElementById('save-data').addEventListener('click', async function() {
const satelliteId = document.getElementById('satellite-select').value;
if (!satelliteId) {
showToast('Внимание', 'Пожалуйста, выберите спутник', 'warning');
return;
}
const data = table.getData();
if (data.length === 0) {
showToast('Внимание', 'Нет данных для сохранения', 'warning');
return;
}
// Validate that all rows have names
const emptyNames = data.filter(row => !row.name || row.name.trim() === '');
if (emptyNames.length > 0) {
showToast('Внимание', 'Все строки должны иметь имя', 'warning');
return;
}
// Disable button while saving
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Сохранение...';
try {
const response = await fetch('/tech-analyze/save/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
satellite_id: satelliteId,
rows: data
})
});
const result = await response.json();
if (result.success) {
let message = `<strong>Успешно сохранено!</strong><br>`;
message += `Создано: ${result.created}<br>`;
message += `Обновлено: ${result.updated}<br>`;
message += `Всего: ${result.total}`;
if (result.errors && result.errors.length > 0) {
message += `<br><br><strong>Ошибки:</strong><br>${result.errors.join('<br>')}`;
}
showToast('Сохранение завершено', message, 'success');
// Clear table after successful save
if (!result.errors || result.errors.length === 0) {
table.clearData();
}
} else {
showToast('Ошибка', result.error || 'Неизвестная ошибка', 'error');
}
} catch (error) {
console.error('Error:', error);
showToast('Ошибка', 'Произошла ошибка при сохранении данных', 'error');
} finally {
// Re-enable button
this.disabled = false;
this.innerHTML = '<i class="bi bi-save"></i> Сохранить';
}
});
// Clear table
document.getElementById('clear-table').addEventListener('click', function() {
if (confirm('Вы уверены, что хотите очистить таблицу?')) {
table.clearData();
updateRowCount();
}
});
// Helper function to get CSRF token
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;
}
// Initialize row count
updateRowCount();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,528 @@
{% extends 'mainapp/base.html' %}
{% load static %}
{% block title %}Тех. анализ - Список{% endblock %}
{% block extra_css %}
<link href="{% static 'tabulator/css/tabulator_bootstrap5.min.css' %}" rel="stylesheet">
<style>
.sticky-top {
position: sticky;
top: 0;
z-index: 10;
}
#tech-analyze-table {
font-size: 12px;
}
#tech-analyze-table .tabulator-header {
font-size: 12px;
}
#tech-analyze-table .tabulator-cell {
font-size: 12px;
white-space: normal;
word-wrap: break-word;
}
#tech-analyze-table .tabulator-row {
min-height: 40px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Тех. анализ - Список данных</h2>
</div>
</div>
<!-- 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">
<!-- 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="Поиск по ID или имени...">
<button type="button" class="btn btn-outline-primary" onclick="performSearch()">Найти</button>
<button type="button" class="btn btn-outline-secondary" onclick="clearSearch()">Очистить</button>
</div>
</div>
<!-- Action buttons -->
<div class="d-flex gap-2">
<a href="{% url 'mainapp:tech_analyze_entry' %}" class="btn btn-success btn-sm" title="Ввод данных">
<i class="bi bi-plus-circle"></i> Ввод данных
</a>
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="button" class="btn btn-danger btn-sm" title="Удалить выбранные" onclick="deleteSelected()">
<i class="bi bi-trash"></i> Удалить
</button>
{% endif %}
<button type="button" class="btn btn-info btn-sm" title="Привязать к существующим точкам" onclick="showLinkModal()">
<i class="bi bi-link-45deg"></i> Привязать к точкам
</button>
</div>
<!-- Filter Toggle Button -->
<div>
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters">
<i class="bi bi-funnel"></i> Фильтры
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Offcanvas Filter Panel -->
<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">
<!-- 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>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</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>
</form>
</div>
</div>
<!-- Main Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<div id="tech-analyze-table"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Link to Points Modal -->
<div class="modal fade" id="linkToPointsModal" tabindex="-1" aria-labelledby="linkToPointsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="linkToPointsModalLabel">
<i class="bi bi-link-45deg"></i> Привязать к существующим точкам
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<strong>Будут обновлены точки с отсутствующими данными:</strong>
<ul class="mb-0 mt-2">
<li>Модуляция (если "-")</li>
<li>Символьная скорость (если -1, 0 или пусто)</li>
<li>Стандарт (если "-")</li>
<li>Частота (если 0, -1 или пусто)</li>
<li>Полоса частот (если 0, -1 или пусто)</li>
<li>Поляризация (если "-")</li>
<li>Транспондер (если не привязан)</li>
<li>Источник LyngSat (если не привязан)</li>
</ul>
</div>
<div class="mb-3">
<label for="linkSatelliteSelect" class="form-label">Выберите спутник <span class="text-danger">*</span></label>
<select class="form-select" id="linkSatelliteSelect" required>
<option value="">Выберите спутник</option>
{% for satellite in satellites %}
<option value="{{ satellite.id }}">{{ satellite.name }}</option>
{% endfor %}
</select>
</div>
<div id="linkResultMessage" class="alert" style="display: none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
<button type="button" class="btn btn-primary" id="confirmLinkBtn" onclick="confirmLink(event)">
<i class="bi bi-check-circle"></i> Привязать
</button>
</div>
</div>
</div>
</div>
<!-- Include the satellite modal component -->
{% include 'mainapp/components/_satellite_modal.html' %}
{% endblock %}
{% block extra_js %}
<script src="{% static 'tabulator/js/tabulator.min.js' %}"></script>
<script>
// Helper function to get CSRF token
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;
}
// Initialize Tabulator
const urlParams = new URLSearchParams(window.location.search);
const ajaxParams = {};
for (const [key, value] of urlParams.entries()) {
if (ajaxParams[key]) {
if (!Array.isArray(ajaxParams[key])) {
ajaxParams[key] = [ajaxParams[key]];
}
ajaxParams[key].push(value);
} else {
ajaxParams[key] = value;
}
}
const table = new Tabulator("#tech-analyze-table", {
ajaxURL: "{% url 'mainapp:tech_analyze_api' %}",
ajaxParams: ajaxParams,
pagination: true,
paginationMode: "remote",
paginationSize: {{ items_per_page }},
paginationSizeSelector: [25, 50, 100, 200, 500],
layout: "fitDataStretch",
height: "70vh",
placeholder: "Нет данных для отображения",
rowHeight: null, // Автоматическая высота строк
columns: [
{
formatter: "rowSelection",
titleFormatter: "rowSelection",
hozAlign: "center",
headerSort: false,
width: 40,
cellClick: function(e, cell) {
cell.getRow().toggleSelect();
}
},
{title: "ID", field: "id", width: 80, hozAlign: "center"},
{
title: "Имя",
field: "name",
minWidth: 250,
widthGrow: 3,
formatter: function(cell) {
return '<div style="white-space: normal; word-wrap: break-word; padding: 4px 0;">' +
(cell.getValue() || '-') + '</div>';
}
},
{
title: "Спутник",
field: "satellite_name",
minWidth: 120,
widthGrow: 1,
formatter: function(cell) {
const data = cell.getData();
if (data.satellite_id) {
return '<a href="#" class="text-decoration-underline" onclick="showSatelliteModal(' + data.satellite_id + '); return false;">' +
(data.satellite_name || '-') + '</a>';
}
return data.satellite_name || '-';
}
},
{title: "Частота, МГц", field: "frequency", width: 120, hozAlign: "right", formatter: function(cell) {
const val = cell.getValue();
return val && val !== 0 ? val.toFixed(3) : '-';
}},
{title: "Полоса, МГц", field: "freq_range", width: 120, hozAlign: "right", formatter: function(cell) {
const val = cell.getValue();
return val && val !== 0 ? val.toFixed(3) : '-';
}},
{title: "Сим. скорость, БОД", field: "bod_velocity", width: 150, hozAlign: "right", formatter: function(cell) {
const val = cell.getValue();
return val && val !== 0 ? val.toFixed(0) : '-';
}},
{title: "Поляризация", field: "polarization_name", width: 120},
{title: "Модуляция", field: "modulation_name", width: 120},
{title: "Стандарт", field: "standard_name", width: 120},
{
title: "Примечание",
field: "note",
minWidth: 150,
widthGrow: 2,
formatter: function(cell) {
return '<div style="white-space: normal; word-wrap: break-word; padding: 4px 0;">' +
(cell.getValue() || '-') + '</div>';
}
},
{
title: "Создано",
field: "created_at",
width: 140,
formatter: function(cell) {
const val = cell.getValue();
if (!val) return '-';
try {
const date = new Date(val);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
} catch (e) {
return '-';
}
}
},
{
title: "Обновлено",
field: "updated_at",
width: 140,
formatter: function(cell) {
const val = cell.getValue();
if (!val) return '-';
try {
const date = new Date(val);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return day + '.' + month + '.' + year + ' ' + hours + ':' + minutes;
} catch (e) {
return '-';
}
}
},
],
});
// Search functionality
function performSearch() {
const searchValue = document.getElementById('toolbar-search').value.trim();
const urlParams = new URLSearchParams(window.location.search);
if (searchValue) {
urlParams.set('search', searchValue);
} else {
urlParams.delete('search');
}
urlParams.delete('page');
window.location.search = urlParams.toString();
}
function clearSearch() {
document.getElementById('toolbar-search').value = '';
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('search');
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Handle Enter key in search input
document.getElementById('toolbar-search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
// 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;
}
}
}
// Filter counter functionality
function updateFilterCounter() {
const form = document.getElementById('filter-form');
let filterCount = 0;
// Count selected satellites
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
if (satelliteSelect) {
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(function(opt) { return opt.selected; });
if (selectedOptions.length > 0) {
filterCount++;
}
}
// Display the filter counter
const counterElement = document.getElementById('filterCounter');
if (counterElement) {
if (filterCount > 0) {
counterElement.textContent = filterCount;
counterElement.style.display = 'inline';
} else {
counterElement.style.display = 'none';
}
}
}
// Delete selected items
function deleteSelected() {
const selectedRows = table.getSelectedRows();
if (selectedRows.length === 0) {
alert('Пожалуйста, выберите хотя бы одну запись для удаления');
return;
}
if (!confirm('Удалить ' + selectedRows.length + ' записей?')) {
return;
}
const selectedIds = selectedRows.map(function(row) { return row.getData().id; });
fetch('{% url "mainapp:tech_analyze_delete" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
ids: selectedIds
})
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
table.replaceData();
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
})
.catch(function(error) {
console.error('Error:', error);
alert('Произошла ошибка при удалении записей');
});
}
// Show link modal
function showLinkModal() {
const modal = new bootstrap.Modal(document.getElementById('linkToPointsModal'));
document.getElementById('linkResultMessage').style.display = 'none';
modal.show();
}
// Confirm link
function confirmLink(event) {
const satelliteId = document.getElementById('linkSatelliteSelect').value;
const resultDiv = document.getElementById('linkResultMessage');
if (!satelliteId) {
resultDiv.className = 'alert alert-warning';
resultDiv.textContent = 'Пожалуйста, выберите спутник';
resultDiv.style.display = 'block';
return;
}
// Show loading state
const btn = document.getElementById('confirmLinkBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Обработка...';
resultDiv.style.display = 'none';
fetch('{% url "mainapp:tech_analyze_link_existing" %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
satellite_id: satelliteId
})
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.success) {
resultDiv.className = 'alert alert-success';
resultDiv.innerHTML = '<strong>Привязка завершена!</strong><br>' +
'Обновлено точек: ' + data.updated + '<br>' +
'Пропущено: ' + data.skipped + '<br>' +
'Всего обработано: ' + data.total;
if (data.errors && data.errors.length > 0) {
resultDiv.innerHTML += '<br><br><strong>Ошибки:</strong><br>' + data.errors.join('<br>');
}
} else {
resultDiv.className = 'alert alert-danger';
resultDiv.textContent = 'Ошибка: ' + (data.error || 'Неизвестная ошибка');
}
resultDiv.style.display = 'block';
})
.catch(function(error) {
console.error('Error:', error);
resultDiv.className = 'alert alert-danger';
resultDiv.textContent = 'Произошла ошибка при привязке точек';
resultDiv.style.display = 'block';
})
.finally(function() {
// Re-enable button
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-circle"></i> Привязать';
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
// Update filter counter on page load
updateFilterCounter();
// Add event listeners to form elements to update counter when filters change
const form = document.getElementById('filter-form');
if (form) {
const selectFields = form.querySelectorAll('select');
selectFields.forEach(function(select) {
select.addEventListener('change', updateFilterCounter);
});
}
// Update counter when offcanvas is shown
const offcanvasElement = document.getElementById('offcanvasFilters');
if (offcanvasElement) {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
// Set search value from URL
const urlParams = new URLSearchParams(window.location.search);
const searchQuery = urlParams.get('search');
if (searchQuery) {
document.getElementById('toolbar-search').value = searchQuery;
}
});
</script>
{% endblock %}

View File

@@ -61,6 +61,9 @@
<a href="{% url 'mainapp:transponder_create' %}" class="btn btn-success btn-sm" title="Создать"> <a href="{% url 'mainapp:transponder_create' %}" class="btn btn-success btn-sm" title="Создать">
<i class="bi bi-plus-circle"></i> Создать <i class="bi bi-plus-circle"></i> Создать
</a> </a>
<a href="{% url 'mainapp:add_trans' %}" class="btn btn-warning btn-sm" title="Загрузить из XML">
<i class="bi bi-upload"></i> Загрузить XML
</a>
<button type="button" class="btn btn-danger btn-sm" title="Удалить" <button type="button" class="btn btn-danger btn-sm" title="Удалить"
onclick="deleteSelectedTransponders()"> onclick="deleteSelectedTransponders()">
<i class="bi bi-trash"></i> Удалить <i class="bi bi-trash"></i> Удалить
@@ -330,7 +333,16 @@
</td> </td>
<td class="text-center">{{ transponder.id }}</td> <td class="text-center">{{ transponder.id }}</td>
<td>{{ transponder.name }}</td> <td>{{ transponder.name }}</td>
<td>{{ transponder.satellite }}</td> <td>
{% if transponder.satellite_id %}
<a href="#" class="text-decoration-underline"
onclick="showSatelliteModal({{ transponder.satellite_id }}); return false;">
{{ transponder.satellite }}
</a>
{% else %}
{{ transponder.satellite }}
{% endif %}
</td>
<td>{{ transponder.downlink }}</td> <td>{{ transponder.downlink }}</td>
<td>{{ transponder.uplink }}</td> <td>{{ transponder.uplink }}</td>
<td>{{ transponder.frequency_range }}</td> <td>{{ transponder.frequency_range }}</td>
@@ -574,4 +586,8 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
</script> </script>
<!-- Include the satellite modal component -->
{% include 'mainapp/components/_satellite_modal.html' %}
{% endblock %} {% endblock %}

View File

@@ -11,15 +11,6 @@
<h2 class="mb-0">Загрузка данных транспондеров из CellNet</h2> <h2 class="mb-0">Загрузка данных транспондеров из CellNet</h2>
</div> </div>
<div class="card-body"> <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 %}
<p class="card-text">Загрузите xml-файл и выберите спутник для загрузки данных в базу.</p> <p class="card-text">Загрузите xml-файл и выберите спутник для загрузки данных в базу.</p>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
@@ -41,7 +32,7 @@
{% endif %} {% endif %}
</div> {% endcomment %} </div> {% endcomment %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <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-warning">Добавить в базу</button> <button type="submit" class="btn btn-warning">Добавить в базу</button>
</div> </div>
</form> </form>

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